Merge branch 'main' into 1484-dashboard-visualizations

This commit is contained in:
Beverly Nguyen
2024-08-01 15:42:44 -07:00
17 changed files with 500 additions and 52 deletions

View File

@@ -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 {

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

@@ -22,7 +22,7 @@
{% else %}
{% if notifications %}
<div class="dashboard-table table-overflow-x-auto">
<div class="dashboard-table job-status-table table-overflow-x-auto">
{% endif %}
{% if job.still_processing %}
<p class="bottom-gutter hint">
@@ -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() %}
<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">
{{ item.preview_of_content }}
</p>

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

@@ -25,8 +25,8 @@
</div>
{% endif %}
<p class="notification-status">
Messages will remain in pending state until carrier status is received, typically 5 minutes.
<p class="notification-status max-width-full">
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>
{% if not job.processing_finished %}
<div

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

@@ -2,7 +2,6 @@ from collections import namedtuple
from datetime import datetime, time, timedelta
import pytz
from govuk_bank_holidays.bank_holidays import BankHolidays
from notifications_utils.countries.data import Postage
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):
return (
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)
if non_working_days.is_work_day(
if non_working_days and non_working_days.is_work_day(
date=next_day.date(),
division=BankHolidays.ENGLAND_AND_WALES,
):
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):
"""
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):
"""
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):

16
poetry.lock generated
View File

@@ -966,20 +966,6 @@ files = [
{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]]
name = "greenlet"
version = "3.0.3"
@@ -3106,4 +3092,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "^3.12.2"
content-hash = "6420327e4cabd4b5e6b7903607f5c435c53f98b10b3da42da988a18da5fd1717"
content-hash = "b271104f669ce0a8e78fb09299b61cf0502cc81a18213dda00f77c759b6e0209"

View File

@@ -17,7 +17,6 @@ flask-basicauth = "~=0.2"
flask-login = "^0.6"
flask-talisman = "*"
flask-wtf = "^1.2"
govuk-bank-holidays = "^0.14"
gunicorn = {version = "==22.0.0", extras = ["eventlet"]}
humanize = "~=4.10"
itsdangerous = "~=2.2"

View File

@@ -353,10 +353,7 @@ def test_should_show_scheduled_job(
)
assert page.select("main p a")[0]["href"] == url_for(
"main.view_template_version",
service_id=SERVICE_ONE_ID,
template_id="5d729fbd-239c-44ab-b498-75a985f3198f",
version=1,
"main.message_status",
)
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 "Recipient" 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 "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 "Recipient" 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 "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) == (
"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.'
)

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