diff --git a/app/assets/sass/uswds/_uswds-theme-custom-styles.scss b/app/assets/sass/uswds/_uswds-theme-custom-styles.scss index 9606c5981..e3ef2be5d 100644 --- a/app/assets/sass/uswds/_uswds-theme-custom-styles.scss +++ b/app/assets/sass/uswds/_uswds-theme-custom-styles.scss @@ -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 { ul { list-style: none; @@ -433,27 +457,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 { @@ -468,6 +508,10 @@ td.table-empty-message { } } +.usa-prose > p.max-width-full { + max-width: 100%; +} + // Tabs .tabs { diff --git a/app/formatters.py b/app/formatters.py index 5cb3feeaf..c427c2a9a 100644 --- a/app/formatters.py +++ b/app/formatters.py @@ -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) diff --git a/app/main/__init__.py b/app/main/__init__.py index 8626582f2..2375c8e58 100644 --- a/app/main/__init__.py +++ b/app/main/__init__.py @@ -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, diff --git a/app/main/views/activity.py b/app/main/views/activity.py new file mode 100644 index 000000000..f9b32e9db --- /dev/null +++ b/app/main/views/activity.py @@ -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/") +@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"] + ] diff --git a/app/navigation.py b/app/navigation.py index 3c79598cc..3ce3b6e62 100644 --- a/app/navigation.py +++ b/app/navigation.py @@ -153,6 +153,9 @@ class HeaderNavigation(Navigation): class MainNavigation(Navigation): mapping = { + "activity": { + "all_jobs_activity", + }, "dashboard": { "conversation", "inbox", diff --git a/app/notify_client/job_api_client.py b/app/notify_client/job_api_client.py index 538bdd370..dfc4fe814 100644 --- a/app/notify_client/job_api_client.py +++ b/app/notify_client/job_api_client.py @@ -1,6 +1,8 @@ import datetime from zoneinfo import ZoneInfo +from flask import current_app + from app.extensions import redis_client from app.notify_client import NotifyAdminAPIClient, _attach_current_user, cache from app.utils.csv import get_user_preferred_timezone @@ -27,9 +29,7 @@ class JobApiClient(NotifyAdminAPIClient): def get_job(self, service_id, job_id): params = {} - job = self.get( - url="/service/{}/job/{}".format(service_id, job_id), params=params - ) + job = self.get(url=f"/service/{service_id}/job/{job_id}", params=params) return job @@ -40,13 +40,16 @@ class JobApiClient(NotifyAdminAPIClient): if statuses is not None: 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): params = {"page": page} if limit_days is not None: 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( self, service_id, template_id, template_version, original_file_name diff --git a/app/templates/new/components/main_nav.html b/app/templates/new/components/main_nav.html index a3b02823e..d3ee0fd08 100644 --- a/app/templates/new/components/main_nav.html +++ b/app/templates/new/components/main_nav.html @@ -8,6 +8,7 @@ {% if current_user.has_permissions() %} {% if current_user.has_permissions('view_activity') %}
  • Dashboard
  • +
  • Activity
  • {% endif %} {% if not current_user.has_permissions('view_activity') %}
  • Sent messages
  • diff --git a/app/templates/partials/jobs/notifications.html b/app/templates/partials/jobs/notifications.html index 22173e6bd..379a8efef 100644 --- a/app/templates/partials/jobs/notifications.html +++ b/app/templates/partials/jobs/notifications.html @@ -22,7 +22,7 @@ {% else %} {% if notifications %} -
    +
    {% endif %} {% if job.still_processing %}

    @@ -40,15 +40,16 @@ notifications, caption=uploaded_file_name, 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), field_headings=[ 'Recipient', - 'Status' + 'Message status' ], field_headings_visible=False ) %} {% call row_heading() %} - {{ item.to }} + {{ item.to | format_phone_number_human_readable }}

    {{ item.preview_of_content }}

    diff --git a/app/templates/views/activity/all-activity.html b/app/templates/views/activity/all-activity.html new file mode 100644 index 000000000..7eb7cb3c5 --- /dev/null +++ b/app/templates/views/activity/all-activity.html @@ -0,0 +1,126 @@ +{% extends "withnav_template.html" %} + +{% block service_page_title %} + All activity +{% endblock %} + +{% set show_pagination %} + {% if prev_page or next_page %} + + {% endif %} +{% endset %} + +{% block maincolumn_content %} +
    +

    All activity

    +

    All activity

    +

    Sent jobs

    +
    + + + + + + + + + + + + + {% if all_jobs_dict %} + {% for job in all_jobs_dict %} + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
    + Job ID# + + Template + + Time sent + + Sender + + Report +
    + + {{ job.job_id[:8] if job.job_id else 'Manually entered number' }} + + {{ job.template_name }} + {{ (job.processing_finished if job.processing_finished else job.processing_started + if job.processing_started else job.created_at)|format_datetime_table }} + {{ job.created_by.name }} + {% if job.time_left != "Data no longer available" %} + File Download Icon + {% elif job %} + N/A + {% endif %} +
    No batched job messages found (messages are kept for {{ service_data_retention_days }} days).
    +
    +

    Note: Report data is only available for 7 days after your message has been sent

    +
    + {{show_pagination}} +
    + +{% endblock %} diff --git a/app/templates/views/jobs/job.html b/app/templates/views/jobs/job.html index ce4e94b3d..7014e1987 100644 --- a/app/templates/views/jobs/job.html +++ b/app/templates/views/jobs/job.html @@ -25,8 +25,8 @@
    {% endif %} -

    - 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.

    {% if not job.processing_finished %}