Merge pull request #3077 from GSA/2970-service-status-display-live-vs-trial

Add service editing to organization dashboard with confirmation modal
This commit is contained in:
ccostino
2025-11-06 16:01:41 -05:00
committed by GitHub
6 changed files with 397 additions and 101 deletions

View File

@@ -60,6 +60,7 @@ import { initCurrentYear } from './date.js';
import './sidenav.js';
import './validation.js';
import './scrollPosition.js';
import './organizationDashboard.js';
initCurrentYear();

View File

@@ -0,0 +1,68 @@
(function(window) {
'use strict';
function scrollToElement(element, delay) {
setTimeout(function() {
element.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
});
}, delay || 0);
}
function focusFirstInput(container, delay) {
setTimeout(function() {
var input = container.querySelector('input[type="text"], input[type="email"]');
if (input) input.focus();
}, delay || 0);
}
function highlightAndScrollToService(serviceId) {
var serviceRow = document.getElementById('service-' + serviceId);
if (serviceRow) {
scrollToElement(serviceRow, 300);
setTimeout(function() {
serviceRow.classList.remove('is-highlighted');
if (serviceRow.className === '') {
serviceRow.removeAttribute('class');
}
}, 3300);
}
}
function initForms() {
requestAnimationFrame(function() {
var form = document.getElementById('create-service-form') ||
document.getElementById('invite-user-form') ||
document.getElementById('edit-service-form');
if (form) {
scrollToElement(form, 50);
focusFirstInput(form, 150);
}
});
}
function initEditServiceConfirmation() {
var confirmEditButton = document.getElementById('edit-service-confirm-btn');
if (confirmEditButton) {
confirmEditButton.addEventListener('click', function() {
var editForm = document.getElementById('edit-service-form');
if (editForm) {
editForm.submit();
}
});
}
}
document.addEventListener('DOMContentLoaded', function() {
initForms();
initEditServiceConfirmation();
});
window.OrganizationDashboard = {
highlightAndScrollToService: highlightAndScrollToService
};
})(window);

View File

@@ -365,6 +365,14 @@ td.table-empty-message {
}
}
.usa-fieldset {
background-color: transparent;
}
.usa-radio {
background-color: transparent;
}
.spark-bar-bar {
font-size: units(2);
background-color: transparent;

View File

@@ -1,8 +1,10 @@
import re
from collections import OrderedDict
from datetime import datetime
from functools import partial
from flask import (
Response,
current_app,
flash,
redirect,
@@ -12,6 +14,7 @@ from flask import (
url_for,
)
from flask_login import current_user
from markupsafe import escape
from app import current_organization, org_invite_api_client, organizations_client
from app.enums import OrganizationType
@@ -35,11 +38,15 @@ from app.main.views.dashboard import (
requested_and_current_financial_year,
)
from app.models.organization import AllOrganizations, Organization
from app.models.service import Service
from app.models.user import InvitedOrgUser, User
from app.notify_client import cache
from app.utils.csv import Spreadsheet
from app.utils.user import user_has_permissions, user_is_platform_admin
from notifications_python_client.errors import HTTPError
EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
@main.route("/organizations", methods=["GET"])
@user_is_platform_admin
@@ -88,6 +95,96 @@ def get_organization_message_allowance(org_id):
}
def _handle_create_service(org_id):
create_service_form = CreateServiceForm(
organization_type=current_user.default_organization_type
or OrganizationType.FEDERAL
)
if request.method == "POST" and create_service_form.validate_on_submit():
service_name = create_service_form.name.data
service_id, error = _create_service(
service_name,
create_service_form.organization_type.data,
email_safe(service_name),
create_service_form,
)
if not error:
current_organization.associate_service(service_id)
flash(f"Service '{service_name}' has been created", "default_with_tick")
session["new_service_id"] = service_id
return redirect(url_for(".organization_dashboard", org_id=org_id))
else:
flash("Error creating service", "error")
return create_service_form
def _handle_invite_user(org_id):
invite_user_form = InviteOrgUserForm(
inviter_email_address=current_user.email_address
)
if request.method == "POST" and invite_user_form.validate_on_submit():
try:
invited_org_user = InvitedOrgUser.create(
current_user.id, org_id, invite_user_form.email_address.data
)
flash(
f"Invite sent to {invited_org_user.email_address}",
"default_with_tick",
)
return redirect(url_for(".organization_dashboard", org_id=org_id))
except Exception:
flash("Error sending invitation", "error")
return invite_user_form
def _handle_edit_service(org_id, service_id):
service = Service.from_id(service_id)
if request.method == "POST":
service_name = request.form.get("service_name", "").strip()
primary_contact = request.form.get("primary_contact", "").strip()
new_status = request.form.get("status")
if not service_name:
flash("Service name is required", "error")
elif primary_contact and not EMAIL_REGEX.match(primary_contact):
flash("Please enter a valid email address", "error")
else:
if service_name != service.name:
service.update(name=service_name)
if primary_contact != (service.billing_contact_email_addresses or ""):
service.update(billing_contact_email_addresses=primary_contact)
current_status = "trial" if service.trial_mode else "live"
if new_status != current_status:
service.update_status(live=(new_status == "live"))
cache.redis_client.delete("organizations")
flash("Service updated successfully", "default_with_tick")
session["updated_service_id"] = str(service_id)
return redirect(url_for(".organization_dashboard", org_id=org_id))
return {
"id": service.id,
"name": (
escape(request.form.get("service_name", "").strip())
if request.method == "POST"
else service.name
),
"primary_contact": (
escape(request.form.get("primary_contact", "").strip())
if request.method == "POST"
else (service.billing_contact_email_addresses or "")
),
"status": "trial" if service.trial_mode else "live",
}
def get_services_dashboard_data(organization, year):
try:
dashboard_data = organizations_client.get_organization_dashboard(
@@ -133,73 +230,45 @@ def organization_dashboard(org_id):
year = requested_and_current_financial_year(request)[0]
action = request.args.get("action")
service_id = request.args.get("service_id")
create_service_form = None
invite_user_form = None
edit_service_data = None
if action == "create-service" or request.form.get("form_name") == "create_service":
create_service_form = CreateServiceForm(
organization_type=current_user.default_organization_type
or OrganizationType.FEDERAL
)
if action == "create-service":
result = _handle_create_service(org_id)
if isinstance(result, Response):
return result
create_service_form = result
if request.method == "POST" and create_service_form.validate_on_submit():
service_name = create_service_form.name.data
service_id, error = _create_service(
service_name,
create_service_form.organization_type.data,
email_safe(service_name),
create_service_form,
)
if not error:
current_organization.associate_service(service_id)
current_app.logger.info(
f"Service {service_id} created and associated with org {org_id}"
)
flash(f"Service '{service_name}' has been created", "default_with_tick")
session["new_service_id"] = service_id
return redirect(url_for(".organization_dashboard", org_id=org_id))
else:
current_app.logger.error(f"Error creating service: {error}")
flash("Error creating service", "error")
elif action == "invite-user":
result = _handle_invite_user(org_id)
if isinstance(result, Response):
return result
invite_user_form = result
if action == "invite-user" or request.form.get("form_name") == "invite_user":
invite_user_form = InviteOrgUserForm(
inviter_email_address=current_user.email_address
)
if request.method == "POST" and invite_user_form.validate_on_submit():
try:
invited_org_user = InvitedOrgUser.create(
current_user.id, org_id, invite_user_form.email_address.data
)
flash(
f"Invite sent to {invited_org_user.email_address}",
"default_with_tick",
)
return redirect(url_for(".organization_dashboard", org_id=org_id))
except Exception as e:
current_app.logger.error(f"Error inviting user: {e}")
flash("Error sending invitation", "error")
elif action == "edit-service" and service_id:
result = _handle_edit_service(org_id, service_id)
if isinstance(result, Response):
return result
edit_service_data = result
message_allowance = get_organization_message_allowance(org_id)
services_with_usage = get_services_dashboard_data(current_organization, year)
new_service_id = session.pop("new_service_id", None)
return render_template(
"views/organizations/organization/index.html",
selected_year=year,
services=services_with_usage,
services=get_services_dashboard_data(current_organization, year),
live_services=len(current_organization.live_services),
trial_services=len(current_organization.trial_services),
suspended_services=len(current_organization.suspended_services),
total_services=len(current_organization.services),
create_service_form=create_service_form,
invite_user_form=invite_user_form,
show_create_service=create_service_form is not None,
show_invite_user=invite_user_form is not None,
new_service_id=new_service_id,
edit_service_data=edit_service_data,
new_service_id=session.pop("new_service_id", None),
updated_service_id=session.pop("updated_service_id", None),
**message_allowance,
)
@@ -270,8 +339,6 @@ def download_organization_usage_report(org_id):
]
# Sanitize organization name for filename to prevent header injection
import re
safe_org_name = re.sub(r"[^\w\s-]", "", current_organization.name).strip()
safe_org_name = re.sub(r"[-\s]+", "-", safe_org_name)

View File

@@ -59,22 +59,21 @@
</div>
<div class="display-flex flex-gap-1 margin-bottom-3">
<a {% if not show_create_service %}href="{{ url_for('.organization_dashboard', org_id=current_org.id, action='create-service') }}"{% endif %}
class="usa-button {% if show_create_service %}usa-button--base button-not-clickable{% else %}usa-button--outline{% endif %}">
<a {% if not create_service_form %}href="{{ url_for('.organization_dashboard', org_id=current_org.id, action='create-service') }}"{% endif %}
class="usa-button {% if create_service_form %}usa-button--base button-not-clickable{% else %}usa-button--outline{% endif %}">
Create new service
</a>
<a {% if not show_invite_user %}href="{{ url_for('.organization_dashboard', org_id=current_org.id, action='invite-user') }}"{% endif %}
class="usa-button {% if show_invite_user %}usa-button--base button-not-clickable{% else %}usa-button--outline{% endif %}">
<a {% if not invite_user_form %}href="{{ url_for('.organization_dashboard', org_id=current_org.id, action='invite-user') }}"{% endif %}
class="usa-button {% if invite_user_form %}usa-button--base button-not-clickable{% else %}usa-button--outline{% endif %}">
Add org admin
</a>
</div>
{% if show_create_service and create_service_form %}
{% if create_service_form %}
<div id="create-service-form" class="bg-base-lightest padding-3 radius-md margin-bottom-3">
<h3 class="margin-top-0">Create a new service</h3>
<form method="post" action="{{ url_for('.organization_dashboard', org_id=current_org.id, action='create-service') }}">
<input type="hidden" name="csrf_token" value="{{ create_service_form.csrf_token._value() }}"/>
<input type="hidden" name="form_name" value="create_service"/>
{{ create_service_form.name(param_extensions={"hint": {"text": "You can change this later"}}) }}
@@ -86,13 +85,12 @@
</div>
{% endif %}
{% if show_invite_user and invite_user_form %}
{% if invite_user_form %}
<div id="invite-user-form" class="bg-base-lightest padding-3 radius-md margin-bottom-3">
<h3 class="margin-top-0">Invite a team member</h3>
<p class="margin-top-0">{{ current_org.name }} team members can see usage and team members for each service, and invite other team members.</p>
<form method="post" action="{{ url_for('.organization_dashboard', org_id=current_org.id, action='invite-user') }}">
<input type="hidden" name="csrf_token" value="{{ invite_user_form.csrf_token._value() }}"/>
<input type="hidden" name="form_name" value="invite_user"/>
{{ invite_user_form.email_address(param_extensions={"classes": ""}, error_message_with_html=True) }}
@@ -104,6 +102,81 @@
</div>
{% endif %}
{% if edit_service_data %}
<div class="bg-base-lightest padding-3 radius-md margin-bottom-3">
<h3 class="margin-top-0">Edit Service</h3>
<form id="edit-service-form" method="post" action="{{ url_for('.organization_dashboard', org_id=current_org.id, action='edit-service', service_id=edit_service_data.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="usa-form-group">
<label class="usa-label" for="service_name">Service Name</label>
<input class="usa-input" type="text" id="service_name" name="service_name"
value="{{ edit_service_data.name }}" required>
</div>
<div class="usa-form-group">
<label class="usa-label" for="primary_contact">Primary Contact Email</label>
<input class="usa-input" type="email" id="primary_contact" name="primary_contact"
value="{{ edit_service_data.primary_contact|default('') }}" placeholder="email@example.com">
</div>
<fieldset class="usa-fieldset margin-top-2">
<legend class="usa-legend">Service Status</legend>
<div class="usa-radio">
<input class="usa-radio__input" id="statusTrial" type="radio" name="status" value="trial"
{% if edit_service_data.status == 'trial' %}checked{% endif %}>
<label class="usa-radio__label" for="statusTrial">Trial</label>
</div>
<div class="usa-radio">
<input class="usa-radio__input" id="statusLive" type="radio" name="status" value="live"
{% if edit_service_data.status == 'live' %}checked{% endif %}>
<label class="usa-radio__label" for="statusLive">Live</label>
</div>
</fieldset>
<div class="display-flex flex-gap-1 margin-top-3">
<button type="button" class="usa-button" data-open-modal="confirmEditModal">Save Changes</button>
<a href="{{ url_for('.organization_dashboard', org_id=current_org.id) }}" class="usa-button usa-button--outline">Cancel</a>
</div>
</form>
</div>
<div
class="usa-modal"
data-module="usa-modal"
id="confirmEditModal"
aria-hidden="true"
role="dialog"
aria-modal="true"
aria-labelledby="confirmEditModalHeading"
aria-describedby="confirmEditModalDesc"
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading font-body-lg" id="confirmEditModalHeading">Are you sure you want to save these changes?</h2>
<p id="confirmEditModalDesc">This will update the service name, primary contact, and/or status. If you're changing the status to Live, team members will be notified.</p>
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button type="button" id="edit-service-confirm-btn" class="usa-button">Confirm changes</button>
</li>
<li class="usa-button-group__item">
<button class="usa-button usa-button--unstyled padding-105 text-center" data-close-modal type="button">
No, go back
</button>
</li>
</ul>
</div>
</div>
<button class="usa-button usa-modal__close" aria-label="Close this window" data-close-modal type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{{ asset_url('img/sprite.svg') }}#close"></use>
</svg>
</button>
</div>
</div>
{% endif %}
<div class="usa-accordion margin-bottom-3">
<h3 class="usa-accordion__heading">
<button
@@ -134,21 +207,23 @@
<div class="margin-bottom-5">
<h2 class="font-heading-lg margin-bottom-2">Services Overview</h2>
<div class="table-overflow-x-auto">
<table class="usa-table">
<table class="usa-table service-dashboard-table">
<thead>
<tr>
<th scope="col" role="columnheader">Name</th>
<th scope="col" role="columnheader">Status</th>
<th scope="col" role="columnheader">Usage</th>
<th scope="col" role="columnheader">Primary Contact</th>
<th scope="col" role="columnheader">Recent Template Used</th>
<th scope="col" role="columnheader">Last Used</th>
<th scope="col" role="columnheader">Actions</th>
</tr>
</thead>
<tbody>
{% if services %}
{% for service in services %}
{% set is_new_service = new_service_id and service.id == new_service_id %}
<tr id="service-{{ service.id }}" {% if is_new_service %}class="is-highlighted"{% endif %}>
{% set is_updated_service = updated_service_id and service.id == updated_service_id %}
<tr id="service-{{ service.id }}" {% if is_new_service or is_updated_service %}class="is-highlighted"{% endif %}>
<td><a href="{{ url_for('main.service_dashboard', service_id=service.id) }}" class="usa-link">{{ service.name }}</a></td>
<td>
{% if not service.active %}
@@ -162,11 +237,23 @@
<td>{{ service.usage }}</td>
<td>{{ service.primary_contact }}</td>
<td>{{ service.recent_template }}</td>
<td>
{% if service.active %}
{% set is_editing = edit_service_data and edit_service_data.id == service.id %}
{% if is_editing %}
<span class="text-base">Edit</span>
{% else %}
<a href="{{ url_for('.organization_dashboard', org_id=current_org.id, action='edit-service', service_id=service.id) }}" class="usa-link">
Edit
</a>
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="5" class="table-empty-message">No services found within this organization</td>
<td colspan="6" class="table-empty-message">No services found within this organization</td>
</tr>
{% endif %}
</tbody>
@@ -178,48 +265,19 @@
{% block extra_javascripts %}
<script nonce="{{ csp_nonce() }}">
(function() {
function scrollToElement(element, delay) {
setTimeout(function() {
element.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
});
}, delay || 0);
}
function focusFirstInput(container, delay) {
setTimeout(function() {
var input = container.querySelector('input[type="text"], input[type="search"]');
if (input) input.focus();
}, delay || 0);
}
document.addEventListener('DOMContentLoaded', function() {
{% if new_service_id %}
var serviceRow = document.getElementById('service-{{ new_service_id }}');
if (serviceRow) {
scrollToElement(serviceRow, 300);
setTimeout(function() {
serviceRow.classList.remove('is-highlighted');
if (serviceRow.className === '') {
serviceRow.removeAttribute('class');
}
}, 3300);
if (window.OrganizationDashboard) {
window.OrganizationDashboard.highlightAndScrollToService('{{ new_service_id }}');
}
{% endif %}
requestAnimationFrame(function() {
var form = document.getElementById('create-service-form') ||
document.getElementById('invite-user-form');
if (form) {
scrollToElement(form, 50);
focusFirstInput(form, 150);
}
});
})();
{% if updated_service_id %}
if (window.OrganizationDashboard) {
window.OrganizationDashboard.highlightAndScrollToService('{{ updated_service_id }}');
}
{% endif %}
});
</script>
{{ super() }}
{% endblock %}

View File

@@ -1711,3 +1711,97 @@ def test_organization_dashboard_services_table(
assert "249,900 remaining" in normalize_spaces(second_row_cells[2].text)
assert normalize_spaces(second_row_cells[3].text) == "N/A"
assert normalize_spaces(second_row_cells[4].text) == "Reminder SMS"
def test_organization_dashboard_shows_edit_service_form(
client_request,
mock_get_organization,
mocker,
active_user_with_permissions,
):
service = service_json(
id_=SERVICE_ONE_ID,
name="Test Service",
restricted=True,
active=True,
billing_contact_email_addresses="test@example.com",
)
mocker.patch("app.service_api_client.get_service", return_value={"data": service})
mocker.patch(
"app.organizations_client.get_organization_message_usage",
return_value={
"messages_sent": 0,
"messages_remaining": 0,
"total_message_limit": 0,
},
)
mocker.patch("app.organizations_client.get_organization_services", return_value=[service])
mocker.patch(
"app.organizations_client.get_organization_dashboard",
return_value={"services": []},
)
client_request.login(active_user_with_permissions)
page = client_request.get(
".organization_dashboard",
org_id=ORGANISATION_ID,
action="edit-service",
service_id=SERVICE_ONE_ID,
)
assert page.select_one("#edit-service-form")
assert page.select_one("#service_name")["value"] == "service one"
assert page.select_one("#primary_contact")
assert page.select_one("#statusTrial")["checked"] == ""
def test_organization_dashboard_edit_service_updates_service(
client_request,
mock_get_organization,
mocker,
active_user_with_permissions,
):
service = service_json(
id_=SERVICE_ONE_ID,
name="Old Name",
restricted=True,
active=True,
billing_contact_email_addresses="old@example.com",
)
mocker.patch("app.service_api_client.get_service", return_value={"data": service})
mock_update_service = mocker.patch("app.service_api_client.update_service")
mocker.patch(
"app.organizations_client.get_organization_message_usage",
return_value={
"messages_sent": 0,
"messages_remaining": 0,
"total_message_limit": 0,
},
)
mocker.patch("app.organizations_client.get_organization_services", return_value=[service])
mocker.patch(
"app.organizations_client.get_organization_dashboard",
return_value={"services": []},
)
mocker.patch("app.extensions.redis_client.delete")
client_request.login(active_user_with_permissions)
client_request.post(
".organization_dashboard",
org_id=ORGANISATION_ID,
action="edit-service",
service_id=SERVICE_ONE_ID,
_data={
"service_name": "New Name",
"primary_contact": "new@example.com",
"status": "live",
},
_expected_redirect=url_for(
".organization_dashboard",
org_id=ORGANISATION_ID,
),
)
assert mock_update_service.call_count == 3
mock_update_service.assert_any_call(SERVICE_ONE_ID, name="New Name")
mock_update_service.assert_any_call(SERVICE_ONE_ID, billing_contact_email_addresses="new@example.com")