mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-08-19 14:09:20 -04:00
Merge branch 'main' of https://github.com/GSA/notifications-admin into notify-786
This commit is contained in:
@@ -17,6 +17,7 @@ from notifications_utils.formatters import nl2br as utils_nl2br
|
||||
from notifications_utils.recipients import InvalidPhoneError, validate_phone_number
|
||||
from notifications_utils.take import Take
|
||||
|
||||
from app.utils.csv import get_user_preferred_timezone
|
||||
from app.utils.time import parse_naive_dt
|
||||
|
||||
|
||||
@@ -31,13 +32,14 @@ def convert_to_boolean(value):
|
||||
|
||||
|
||||
def format_datetime(date):
|
||||
return "{} at {} UTC".format(format_date(date), format_time_24h(date))
|
||||
return "{} at {} {}".format(
|
||||
format_date(date), format_time_24h(date), get_user_preferred_timezone()
|
||||
)
|
||||
|
||||
|
||||
def format_datetime_24h(date):
|
||||
return "{} at {} UTC".format(
|
||||
format_date(date),
|
||||
format_time_24h(date),
|
||||
return "{} at {} {}".format(
|
||||
format_date(date), format_time_24h(date), get_user_preferred_timezone()
|
||||
)
|
||||
|
||||
|
||||
@@ -46,39 +48,52 @@ def format_time(date):
|
||||
|
||||
|
||||
def format_datetime_normal(date):
|
||||
return "{} at {} UTC".format(format_date_normal(date), format_time_24h(date))
|
||||
return "{} at {} {}".format(
|
||||
format_date_normal(date), format_time_24h(date), get_user_preferred_timezone()
|
||||
)
|
||||
|
||||
|
||||
def format_datetime_short(date):
|
||||
return "{} at {} UTC".format(format_date_short(date), format_time_24h(date))
|
||||
return "{} at {} {}".format(
|
||||
format_date_short(date), format_time_24h(date), get_user_preferred_timezone()
|
||||
)
|
||||
|
||||
|
||||
def format_datetime_relative(date):
|
||||
return "{} at {} UTC".format(get_human_day(date), format_time_24h(date))
|
||||
return "{} at {} {}".format(
|
||||
get_human_day(date), format_time_24h(date), get_user_preferred_timezone()
|
||||
)
|
||||
|
||||
|
||||
def format_datetime_numeric(date):
|
||||
return "{} {} UTC".format(
|
||||
format_date_numeric(date),
|
||||
format_time_24h(date),
|
||||
return "{} {} {}".format(
|
||||
format_date_numeric(date), format_time_24h(date), get_user_preferred_timezone()
|
||||
)
|
||||
|
||||
|
||||
def format_date_numeric(date):
|
||||
date = parse_naive_dt(date)
|
||||
return date.strftime("%Y-%m-%d")
|
||||
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
return (
|
||||
date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%Y-%m-%d")
|
||||
)
|
||||
|
||||
|
||||
def format_time_24h(date):
|
||||
date = parse_naive_dt(date)
|
||||
return date.strftime("%H:%M")
|
||||
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
return date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%H:%M")
|
||||
|
||||
|
||||
def get_human_day(time, date_prefix=""):
|
||||
# Add 1 minute to transform 00:00 into ‘midnight today’ instead of ‘midnight tomorrow’
|
||||
time = parse_naive_dt(time)
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
time = time.replace(tzinfo=timezone.utc).astimezone(preferred_tz)
|
||||
date = (time - timedelta(minutes=1)).date()
|
||||
now = datetime.now(pytz.utc)
|
||||
now = datetime.now(preferred_tz)
|
||||
|
||||
if date == (now + timedelta(days=1)).date():
|
||||
return "tomorrow"
|
||||
@@ -100,7 +115,12 @@ def get_human_day(time, date_prefix=""):
|
||||
|
||||
def format_date(date):
|
||||
date = parse_naive_dt(date)
|
||||
return date.strftime("%A %d %B %Y")
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
return (
|
||||
date.replace(tzinfo=timezone.utc)
|
||||
.astimezone(preferred_tz)
|
||||
.strftime("%A %d %B %Y")
|
||||
)
|
||||
|
||||
|
||||
def format_date_normal(date):
|
||||
@@ -110,7 +130,10 @@ def format_date_normal(date):
|
||||
|
||||
def format_date_short(date):
|
||||
date = parse_naive_dt(date)
|
||||
return _format_datetime_short(date)
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
return _format_datetime_short(
|
||||
date.replace(tzinfo=timezone.utc).astimezone(preferred_tz)
|
||||
)
|
||||
|
||||
|
||||
def format_date_human(date):
|
||||
@@ -118,15 +141,17 @@ def format_date_human(date):
|
||||
|
||||
|
||||
def format_datetime_human(date, date_prefix=""):
|
||||
return "{} at {} UTC".format(
|
||||
return "{} at {} {}".format(
|
||||
get_human_day(date, date_prefix="on"),
|
||||
format_time_24h(date),
|
||||
get_user_preferred_timezone(),
|
||||
)
|
||||
|
||||
|
||||
def format_day_of_week(date):
|
||||
date = parse_naive_dt(date)
|
||||
return date.strftime("%A")
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
return date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%A")
|
||||
|
||||
|
||||
def _format_datetime_short(datetime):
|
||||
|
||||
@@ -64,15 +64,18 @@ from app.main.validators import (
|
||||
from app.models.feedback import PROBLEM_TICKET_TYPE, QUESTION_TICKET_TYPE
|
||||
from app.models.organization import Organization
|
||||
from app.utils import branding, merge_jsonlike
|
||||
from app.utils.csv import get_user_preferred_timezone
|
||||
from app.utils.user_permissions import all_ui_permissions, permission_options
|
||||
|
||||
|
||||
def get_time_value_and_label(future_time):
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
return (
|
||||
future_time.astimezone(pytz.utc).replace(tzinfo=None).isoformat(),
|
||||
"{} at {} UTC".format(
|
||||
get_human_day(future_time.astimezone(pytz.utc)),
|
||||
get_human_time(future_time.astimezone(pytz.utc)),
|
||||
future_time.astimezone(preferred_tz).replace(tzinfo=None).isoformat(),
|
||||
"{} at {} {}".format(
|
||||
get_human_day(future_time.astimezone(preferred_tz)),
|
||||
get_human_time(future_time.astimezone(preferred_tz)),
|
||||
get_user_preferred_timezone(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -85,21 +88,25 @@ def get_human_time(time):
|
||||
|
||||
def get_human_day(time, prefix_today_with="T"):
|
||||
# Add 1 hour to get ‘midnight today’ instead of ‘midnight tomorrow’
|
||||
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
time = (time - timedelta(hours=1)).strftime("%A")
|
||||
if time == datetime.now(pytz.utc).strftime("%A"):
|
||||
if time == datetime.now(preferred_tz).strftime("%A"):
|
||||
return "{}oday".format(prefix_today_with)
|
||||
if time == (datetime.now(pytz.utc) + timedelta(days=1)).strftime("%A"):
|
||||
if time == (datetime.now(preferred_tz) + timedelta(days=1)).strftime("%A"):
|
||||
return "Tomorrow"
|
||||
return time
|
||||
|
||||
|
||||
def get_furthest_possible_scheduled_time():
|
||||
# We want local time to find date boundaries
|
||||
return (datetime.now(pytz.utc) + timedelta(days=4)).replace(hour=0)
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
return (datetime.now(preferred_tz) + timedelta(days=4)).replace(hour=0)
|
||||
|
||||
|
||||
def get_next_hours_until(until):
|
||||
now = datetime.now(pytz.utc)
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
now = datetime.now(preferred_tz)
|
||||
hours = int((until - now).total_seconds() / (60 * 60))
|
||||
return [
|
||||
(now + timedelta(hours=i)).replace(minute=0, second=0, microsecond=0)
|
||||
@@ -108,7 +115,8 @@ def get_next_hours_until(until):
|
||||
|
||||
|
||||
def get_next_days_until(until):
|
||||
now = datetime.now(pytz.utc)
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
now = datetime.now(preferred_tz)
|
||||
days = int((until - now).total_seconds() / (60 * 60 * 24))
|
||||
return [
|
||||
get_human_day((now + timedelta(days=i)), prefix_today_with="Later t")
|
||||
|
||||
@@ -8,13 +8,15 @@ from flask import render_template
|
||||
|
||||
from app import performance_dashboard_api_client, status_api_client
|
||||
from app.main import main
|
||||
from app.utils.csv import get_user_preferred_timezone
|
||||
|
||||
|
||||
@main.route("/performance")
|
||||
def performance():
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
stats = performance_dashboard_api_client.get_performance_dashboard_stats(
|
||||
start_date=(datetime.now(pytz.utc) - timedelta(days=7)).date(),
|
||||
end_date=datetime.now(pytz.utc).date(),
|
||||
start_date=(datetime.now(preferred_tz) - timedelta(days=7)).date(),
|
||||
end_date=datetime.now(preferred_tz).date(),
|
||||
)
|
||||
stats["organizations_using_notify"] = sorted(
|
||||
[
|
||||
@@ -35,4 +37,5 @@ def performance():
|
||||
stats[
|
||||
"count_of_live_services_and_organizations"
|
||||
] = status_api_client.get_count_of_live_services_and_organizations()
|
||||
|
||||
return render_template("views/performance.html", **stats)
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
from flask import (
|
||||
Markup,
|
||||
abort,
|
||||
current_app,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
@@ -21,22 +26,84 @@ from app.utils import hide_from_search_engines
|
||||
from app.utils.login import is_safe_redirect_url
|
||||
|
||||
|
||||
def _get_access_token(code, state):
|
||||
client_id = os.getenv("LOGIN_DOT_GOV_CLIENT_ID")
|
||||
access_token_url = os.getenv("LOGIN_DOT_GOV_ACCESS_TOKEN_URL")
|
||||
keystring = os.getenv("LOGIN_PEM")
|
||||
payload = {
|
||||
"iss": client_id,
|
||||
"sub": client_id,
|
||||
"aud": access_token_url,
|
||||
"jti": str(uuid.uuid4()),
|
||||
# JWT expiration time (10 minute maximum)
|
||||
"exp": int(time.time()) + (10 * 60),
|
||||
}
|
||||
|
||||
token = jwt.encode(payload, keystring, algorithm="RS256")
|
||||
base_url = f"{access_token_url}?"
|
||||
cli_assert = f"client_assertion={token}"
|
||||
cli_assert_type = "client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer"
|
||||
code_param = f"code={code}"
|
||||
url = f"{base_url}{cli_assert}&{cli_assert_type}&{code_param}&grant_type=authorization_code"
|
||||
headers = {"Authorization": "Bearer %s" % token}
|
||||
response = requests.post(url, headers=headers)
|
||||
current_app.logger.info(f"GOT A RESPONSE {response.json()}")
|
||||
access_token = response.json()["access_token"]
|
||||
return access_token
|
||||
|
||||
|
||||
def _get_user_email(access_token):
|
||||
headers = {"Authorization": "Bearer %s" % access_token}
|
||||
user_info_url = os.getenv("LOGIN_DOT_GOV_USER_INFO_URL")
|
||||
user_attributes = requests.get(
|
||||
user_info_url,
|
||||
headers=headers,
|
||||
)
|
||||
user_email = user_attributes.json()["email"]
|
||||
return user_email
|
||||
|
||||
|
||||
@main.route("/sign-in", methods=(["GET", "POST"]))
|
||||
@hide_from_search_engines
|
||||
def sign_in():
|
||||
# start login.gov
|
||||
code = request.args.get("code")
|
||||
state = request.args.get("state")
|
||||
login_gov_error = request.args.get("error")
|
||||
if code and state:
|
||||
access_token = _get_access_token(code, state)
|
||||
user_email = _get_user_email(access_token)
|
||||
redirect_url = request.args.get("next")
|
||||
|
||||
# activate the user
|
||||
user = user_api_client.get_user_by_email(user_email)
|
||||
activate_user(user["id"])
|
||||
return redirect(url_for("main.show_accounts_or_dashboard", next=redirect_url))
|
||||
|
||||
elif login_gov_error:
|
||||
current_app.logger.error(f"login.gov error: {login_gov_error}")
|
||||
raise Exception(f"Could not login with login.gov {login_gov_error}")
|
||||
# end login.gov
|
||||
|
||||
redirect_url = request.args.get("next")
|
||||
|
||||
if os.getenv("NOTIFY_E2E_TEST_EMAIL"):
|
||||
current_app.logger.warning("E2E TESTS ARE ENABLED.")
|
||||
current_app.logger.warning(
|
||||
"If you are getting a 404 on signin, comment out E2E vars in .env file!"
|
||||
)
|
||||
user = user_api_client.get_user_by_email(os.getenv("NOTIFY_E2E_TEST_EMAIL"))
|
||||
activate_user(user["id"])
|
||||
return redirect(url_for("main.show_accounts_or_dashboard", next=redirect_url))
|
||||
|
||||
current_app.logger.info(f"current user is {current_user}")
|
||||
if current_user and current_user.is_authenticated:
|
||||
if redirect_url and is_safe_redirect_url(redirect_url):
|
||||
return redirect(redirect_url)
|
||||
return redirect(url_for("main.show_accounts_or_dashboard"))
|
||||
|
||||
form = LoginForm()
|
||||
current_app.logger.info("Got the login form")
|
||||
password_reset_url = url_for(".forgot_password", next=request.args.get("next"))
|
||||
|
||||
if form.validate_on_submit():
|
||||
@@ -84,11 +151,14 @@ def sign_in():
|
||||
)
|
||||
|
||||
other_device = current_user.logged_in_elsewhere()
|
||||
notify_env = os.getenv("NOTIFY_ENVIRONMENT")
|
||||
current_app.logger.info("should render the sign in template")
|
||||
return render_template(
|
||||
"views/signin.html",
|
||||
form=form,
|
||||
again=bool(redirect_url),
|
||||
other_device=other_device,
|
||||
notify_env_is_dev=bool(notify_env == "development"),
|
||||
password_reset_url=password_reset_url,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
from flask import redirect, url_for
|
||||
import os
|
||||
|
||||
import requests
|
||||
from flask import current_app, redirect, url_for
|
||||
from flask_login import current_user
|
||||
|
||||
from app.main import main
|
||||
|
||||
# ask login.gov if we really need manual logout and what's up with one hour sessions
|
||||
# ask login.gov how they recommend approaching dev environment
|
||||
# ask Tim Donaworth the same for #2
|
||||
|
||||
@main.route("/sign-out", methods=(["GET"]))
|
||||
|
||||
def _sign_out_at_login_dot_gov():
|
||||
base_url = os.getenv("LOGIN_DOT_GOV_BASE_LOGOUT_URL")
|
||||
client_id = f"client_id={os.getenv('LOGIN_DOT_GOV_CLIENT_ID')}"
|
||||
post_logout_redirect_uri = (
|
||||
f"post_logout_redirect_uri={os.getenv('LOGIN_DOT_GOV_SIGNOUT_REDIRECT')}"
|
||||
)
|
||||
|
||||
url = f"{base_url}{client_id}&{post_logout_redirect_uri}"
|
||||
current_app.logger.info(f"url={url}")
|
||||
|
||||
response = requests.post(url)
|
||||
|
||||
# response = requests.post(url)
|
||||
current_app.logger.info(f"login.gov response: {response.text}")
|
||||
|
||||
|
||||
@main.route("/sign-out", methods=(["GET", "POST"]))
|
||||
def sign_out():
|
||||
# An AnonymousUser does not have an id
|
||||
current_app.logger.info("HIT THE REGULAR SIGN OUT")
|
||||
if current_user.is_authenticated:
|
||||
# TODO This doesn't work yet, due to problems above.
|
||||
current_user.sign_out()
|
||||
|
||||
return redirect(os.getenv("LOGIN_DOT_GOV_LOGOUT_URL"))
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
@@ -47,12 +47,14 @@ class User(JSONModel, UserMixin):
|
||||
"password_changed_at",
|
||||
"permissions",
|
||||
"state",
|
||||
"preferred_timezone",
|
||||
}
|
||||
|
||||
def __init__(self, _dict):
|
||||
super().__init__(_dict)
|
||||
self.permissions = _dict.get("permissions", {})
|
||||
self._platform_admin = _dict["platform_admin"]
|
||||
self.preferred_timezone = "US/Eastern"
|
||||
|
||||
@classmethod
|
||||
def from_id(cls, user_id):
|
||||
|
||||
@@ -39,6 +39,7 @@ class UserApiClient(NotifyAdminAPIClient):
|
||||
return self.get("/user/{}".format(user_id))
|
||||
|
||||
def get_user_by_email(self, email_address):
|
||||
current_app.logger.info(f"Going to get user by email {email_address}")
|
||||
user_data = self.post("/user/email", data={"email": email_address})
|
||||
return user_data["data"]
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
from datetime import datetime
|
||||
from functools import reduce
|
||||
|
||||
import pytz
|
||||
from dateutil import parser
|
||||
|
||||
from app.utils.csv import get_user_preferred_timezone
|
||||
|
||||
|
||||
def sum_of_statistics(delivery_statistics):
|
||||
statistics_keys = (
|
||||
@@ -24,6 +27,7 @@ def sum_of_statistics(delivery_statistics):
|
||||
|
||||
|
||||
def add_rates_to(delivery_statistics):
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
return dict(
|
||||
emails_failure_rate=get_formatted_percentage(
|
||||
delivery_statistics["emails_failed"],
|
||||
@@ -33,7 +37,7 @@ def add_rates_to(delivery_statistics):
|
||||
delivery_statistics["sms_failed"], delivery_statistics["sms_requested"]
|
||||
),
|
||||
week_end_datetime=parser.parse(
|
||||
delivery_statistics.get("week_end", str(datetime.utcnow()))
|
||||
delivery_statistics.get("week_end", str(datetime.now(preferred_tz)))
|
||||
),
|
||||
**delivery_statistics
|
||||
)
|
||||
|
||||
@@ -28,11 +28,12 @@
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<h1 class="font-body-2xl margin-bottom-3">Sign in</h1>
|
||||
<!-- Removing temporarily for pilot -->
|
||||
<!-- <p>
|
||||
If you do not have an account, you can
|
||||
<a class="usa-link" href="{{ url_for('.register') }}">create one now</a>.
|
||||
</p> -->
|
||||
{% if notify_env_is_dev %}
|
||||
<p>
|
||||
Test login.gov authentication:
|
||||
<a class="usa-link" href="https://idp.int.identitysandbox.gov/openid_connect/authorize?acr_values=http%3A%2F%2Fidmanagement.gov%2Fns%2Fassurance%2Fial%2F1&client_id=urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:test_notify_gov&nonce=01234567890123456789012345&prompt=select_account&redirect_uri=http://localhost:6012/sign-in&response_type=code&scope=openid+email&state=abcdefghijklmnopabcdefghijklmnop">Login.gov</a>.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% call form_wrapper(autocomplete=True) %}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import datetime
|
||||
|
||||
import pytz
|
||||
from flask import current_app
|
||||
from flask_login import current_user
|
||||
from notifications_utils.recipients import RecipientCSV
|
||||
|
||||
from app.models.spreadsheet import Spreadsheet
|
||||
@@ -108,6 +112,10 @@ def generate_notifications_csv(**kwargs):
|
||||
**kwargs
|
||||
)
|
||||
for notification in notifications_resp["notifications"]:
|
||||
preferred_tz_created_at = convert_report_date_to_preferred_timezone(
|
||||
notification["created_at"]
|
||||
)
|
||||
|
||||
current_app.logger.info(f"\n\n{notification}")
|
||||
if kwargs.get("job_id"):
|
||||
values = (
|
||||
@@ -126,7 +134,7 @@ def generate_notifications_csv(**kwargs):
|
||||
notification["carrier"],
|
||||
notification["provider_response"],
|
||||
notification["status"],
|
||||
notification["created_at"],
|
||||
preferred_tz_created_at,
|
||||
]
|
||||
)
|
||||
else:
|
||||
@@ -139,7 +147,7 @@ def generate_notifications_csv(**kwargs):
|
||||
notification["carrier"],
|
||||
notification["provider_response"],
|
||||
notification["status"],
|
||||
notification["created_at"],
|
||||
preferred_tz_created_at,
|
||||
]
|
||||
yield Spreadsheet.from_rows([map(str, values)]).as_csv_data
|
||||
|
||||
@@ -148,3 +156,27 @@ def generate_notifications_csv(**kwargs):
|
||||
else:
|
||||
return
|
||||
raise Exception("Should never reach here")
|
||||
|
||||
|
||||
def convert_report_date_to_preferred_timezone(db_date_str_in_utc):
|
||||
"""
|
||||
Report dates in the db are in UTC. We need to convert them to the user's default timezone,
|
||||
which defaults to "US/Eastern"
|
||||
"""
|
||||
date_arr = db_date_str_in_utc.split(" ")
|
||||
db_date_str_in_utc = f"{date_arr[0]}T{date_arr[1]}+00:00"
|
||||
utc_date_obj = datetime.datetime.fromisoformat(db_date_str_in_utc)
|
||||
|
||||
utc_date_obj = utc_date_obj.astimezone(pytz.utc)
|
||||
preferred_timezone = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_date_obj = utc_date_obj.astimezone(preferred_timezone)
|
||||
preferred_tz_created_at = preferred_date_obj.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
return f"{preferred_tz_created_at} {get_user_preferred_timezone()}"
|
||||
|
||||
|
||||
def get_user_preferred_timezone():
|
||||
if current_user and hasattr(current_user, "preferred_timezone"):
|
||||
return current_user.preferred_timezone
|
||||
|
||||
return "US/Eastern"
|
||||
|
||||
@@ -3,9 +3,12 @@ from datetime import datetime
|
||||
import pytz
|
||||
from dateutil import parser
|
||||
|
||||
from app.utils.csv import get_user_preferred_timezone
|
||||
|
||||
|
||||
def get_current_financial_year():
|
||||
now = datetime.now(pytz.utc)
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
now = datetime.now(preferred_tz)
|
||||
current_month = int(now.strftime("%-m"))
|
||||
current_year = int(now.strftime("%Y"))
|
||||
return current_year if current_month > 9 else current_year - 1
|
||||
|
||||
Reference in New Issue
Block a user