Compare commits

..

1 Commits

Author SHA1 Message Date
Jonathan Bobel
cc6e1c5265 Updating content on Dashboard 2023-10-24 14:34:45 -04:00
588 changed files with 21429 additions and 70841 deletions

View File

@@ -1,688 +0,0 @@
{
"version": "1.5.0",
"plugins_used": [
{
"name": "ArtifactoryDetector"
},
{
"name": "AWSKeyDetector"
},
{
"name": "AzureStorageKeyDetector"
},
{
"name": "Base64HighEntropyString",
"limit": 4.5
},
{
"name": "BasicAuthDetector"
},
{
"name": "CloudantDetector"
},
{
"name": "DiscordBotTokenDetector"
},
{
"name": "GitHubTokenDetector"
},
{
"name": "GitLabTokenDetector"
},
{
"name": "HexHighEntropyString",
"limit": 3.0
},
{
"name": "IbmCloudIamDetector"
},
{
"name": "IbmCosHmacDetector"
},
{
"name": "IPPublicDetector"
},
{
"name": "JwtTokenDetector"
},
{
"name": "KeywordDetector",
"keyword_exclude": ""
},
{
"name": "MailchimpDetector"
},
{
"name": "NpmDetector"
},
{
"name": "OpenAIDetector"
},
{
"name": "PrivateKeyDetector"
},
{
"name": "PypiTokenDetector"
},
{
"name": "SendGridDetector"
},
{
"name": "SlackDetector"
},
{
"name": "SoftlayerDetector"
},
{
"name": "SquareOAuthDetector"
},
{
"name": "StripeDetector"
},
{
"name": "TelegramBotTokenDetector"
},
{
"name": "TwilioKeyDetector"
}
],
"filters_used": [
{
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
},
{
"path": "detect_secrets.filters.common.is_baseline_file",
"filename": ".ds.baseline"
},
{
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
"min_level": 2
},
{
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
},
{
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
},
{
"path": "detect_secrets.filters.heuristic.is_lock_file"
},
{
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
},
{
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
},
{
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
},
{
"path": "detect_secrets.filters.heuristic.is_sequential_string"
},
{
"path": "detect_secrets.filters.heuristic.is_swagger_file"
},
{
"path": "detect_secrets.filters.heuristic.is_templated_secret"
}
],
"results": {
".github/workflows/checks.yml": [
{
"type": "Secret Keyword",
"filename": ".github/workflows/checks.yml",
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
"is_verified": false,
"line_number": 68,
"is_secret": false
}
],
"app/assets/js/uswds.min.js": [
{
"type": "Secret Keyword",
"filename": "app/assets/js/uswds.min.js",
"hashed_secret": "372ea08cab33e71c02c651dbc83a474d32c676ea",
"is_verified": false,
"line_number": 85,
"is_secret": false
},
{
"type": "Secret Keyword",
"filename": "app/assets/js/uswds.min.js",
"hashed_secret": "53e07a32bf191d6917ee6fd863f0b52632a86798",
"is_verified": false,
"line_number": 85,
"is_secret": false
}
],
"app/config.py": [
{
"type": "Secret Keyword",
"filename": "app/config.py",
"hashed_secret": "577a4c667e4af8682ca431857214b3a920883efc",
"is_verified": false,
"line_number": 121,
"is_secret": false
}
],
"app/main/_commonly_used_passwords.py": [
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "82e19fa12aab7cfc718a002fc82c0f074bf070e7",
"is_verified": false,
"line_number": 123,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "a172ffc990129fe6f68b50f6037c54a1894ee3fd",
"is_verified": false,
"line_number": 240,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "4de69ee6b12b7fc91070873b71ba6e2929b90619",
"is_verified": false,
"line_number": 244,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "370194ff6e0f93a7432e16cc9badd9427e8b4e13",
"is_verified": false,
"line_number": 284,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "3dd635a808ddb6dd4b6731f7c409d53dd4b14df2",
"is_verified": false,
"line_number": 356,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "67a74306b06d0c01624fe0d0249a570f4d093747",
"is_verified": false,
"line_number": 374,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "61d6504733ca7757e259c644acd085c4dd471019",
"is_verified": false,
"line_number": 910,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "4ea872dfd7eefbde0036da7f0780826353dc7477",
"is_verified": false,
"line_number": 940,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "b214f706bb602c1cc2adc5c6165e73622305f4bb",
"is_verified": false,
"line_number": 1010,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "5cbabd43e49a1fedbbc3b86311aa6c8fe446abf9",
"is_verified": false,
"line_number": 1195,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "18ad10fd4a67f21fc07b1aa5046b410f6b2bedf1",
"is_verified": false,
"line_number": 1213,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "10470c3b4b1fed12c3baac014be15fac67c6e815",
"is_verified": false,
"line_number": 1263,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "65e1946c8f102eca8ba0af291f7c5e807516d94c",
"is_verified": false,
"line_number": 1346,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "0075df0a74c07ee295c98238c018401c9a80183b",
"is_verified": false,
"line_number": 1397,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "ca0023d7b345802fbc227b902cb9c57a3e02195f",
"is_verified": false,
"line_number": 1442,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "c8c6ca2e11c2dfd2a40914585b5944bffea15c8c",
"is_verified": false,
"line_number": 1555,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "b85b97a99eab8c809570c61d6404c1e49bdefbb4",
"is_verified": false,
"line_number": 1596,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "dec7dd342a499dfd4d283d872ccf598d8a7b6039",
"is_verified": false,
"line_number": 1789,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "2dc5053699a351121bf839c446bd4a878dda5735",
"is_verified": false,
"line_number": 1939,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "e5d54f0ac13abbdaa94b696c2469148b96dd11ab",
"is_verified": false,
"line_number": 2242,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "6059f42e2bbae78141e8a9e6286755ee691d5ce0",
"is_verified": false,
"line_number": 2305,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "fe703d258c7ef5f50b71e06565a65aa07194907f",
"is_verified": false,
"line_number": 2348,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "c229b68e1c3ffd9874838b5cb5354a0ee1367ddc",
"is_verified": false,
"line_number": 2349,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "756de479126e911b6f3400ae686d663d9d26b509",
"is_verified": false,
"line_number": 2920,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "6b174322afcdb440ee9cc3cc11eb16f9a00dec04",
"is_verified": false,
"line_number": 2975,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "9860783bfb510cbb2bf34471ec0b84a7ea587695",
"is_verified": false,
"line_number": 3359,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "b227cbd22eaa96019ebfc4aff35ad2add2a47439",
"is_verified": false,
"line_number": 3590,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "381d48209aecab8834eb495c5b5406100da07882",
"is_verified": false,
"line_number": 3811,
"is_secret": false
},
{
"type": "Hex High Entropy String",
"filename": "app/main/_commonly_used_passwords.py",
"hashed_secret": "508b38590a90d32990aadd7350d160b795c3ab41",
"is_verified": false,
"line_number": 3850,
"is_secret": false
}
],
"app/templates/new/components/head.html": [
{
"type": "Base64 High Entropy String",
"filename": "app/templates/new/components/head.html",
"hashed_secret": "ee5048791fc7ff45a1545e24f85bec3317371327",
"is_verified": false,
"line_number": 33,
"is_secret": false
}
],
"app/templates/old/admin_template.html": [
{
"type": "Base64 High Entropy String",
"filename": "app/templates/old/admin_template.html",
"hashed_secret": "ee5048791fc7ff45a1545e24f85bec3317371327",
"is_verified": false,
"line_number": 18,
"is_secret": false
}
],
"deploy-config/sandbox.yml": [
{
"type": "Secret Keyword",
"filename": "deploy-config/sandbox.yml",
"hashed_secret": "113151dd10316fcb0d5507b6215d78e2f3fe9e54",
"is_verified": false,
"line_number": 9,
"is_secret": false
}
],
"pytest.ini": [
{
"type": "Secret Keyword",
"filename": "pytest.ini",
"hashed_secret": "577a4c667e4af8682ca431857214b3a920883efc",
"is_verified": false,
"line_number": 7,
"is_secret": false
},
{
"type": "Base64 High Entropy String",
"filename": "pytest.ini",
"hashed_secret": "d347784b1ab6074a65cda7bc42f1561bed85493f",
"is_verified": false,
"line_number": 7,
"is_secret": false
},
{
"type": "Base64 High Entropy String",
"filename": "pytest.ini",
"hashed_secret": "ed1754d5cc82c8fd83205ebfb8c43fe4e88415a4",
"is_verified": false,
"line_number": 9,
"is_secret": false
},
{
"type": "Secret Keyword",
"filename": "pytest.ini",
"hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3",
"is_verified": false,
"line_number": 11,
"is_secret": false
}
],
"tests/__init__.py": [
{
"type": "Secret Keyword",
"filename": "tests/__init__.py",
"hashed_secret": "f8377c90fcfd699f0ddbdcb30c2c9183d2d933ea",
"is_verified": false,
"line_number": 388,
"is_secret": false
}
],
"tests/app/main/forms/test_register_user_form.py": [
{
"type": "Secret Keyword",
"filename": "tests/app/main/forms/test_register_user_form.py",
"hashed_secret": "8c6c978dc8e08771c7dea1ea2370fdf2446e5ba5",
"is_verified": false,
"line_number": 38,
"is_secret": false
}
],
"tests/app/main/test_errorhandlers.py": [
{
"type": "Base64 High Entropy String",
"filename": "tests/app/main/test_errorhandlers.py",
"hashed_secret": "005fa73b3f2be8f0d71d361c1f0a9d787cd09b4e",
"is_verified": false,
"line_number": 33,
"is_secret": false
}
],
"tests/app/main/test_request_header.py": [
{
"type": "Secret Keyword",
"filename": "tests/app/main/test_request_header.py",
"hashed_secret": "6866ef97a972ba3a2c6ff8bb2812981054770162",
"is_verified": false,
"line_number": 21,
"is_secret": false
}
],
"tests/app/main/views/organizations/test_organization_invites.py": [
{
"type": "Secret Keyword",
"filename": "tests/app/main/views/organizations/test_organization_invites.py",
"hashed_secret": "bdbb156d25d02fd7792865824201dda1c60f4473",
"is_verified": false,
"line_number": 274,
"is_secret": false
},
{
"type": "Secret Keyword",
"filename": "tests/app/main/views/organizations/test_organization_invites.py",
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
"is_verified": false,
"line_number": 282,
"is_secret": false
}
],
"tests/app/main/views/test_accept_invite.py": [
{
"type": "Secret Keyword",
"filename": "tests/app/main/views/test_accept_invite.py",
"hashed_secret": "07f0a6c13923fc3b5f0c57ffa2d29b715eb80d71",
"is_verified": false,
"line_number": 643,
"is_secret": false
}
],
"tests/app/main/views/test_new_password.py": [
{
"type": "Secret Keyword",
"filename": "tests/app/main/views/test_new_password.py",
"hashed_secret": "a41d5c3bbcd0b39c627b9cbf4897c6d25efa694f",
"is_verified": false,
"line_number": 89,
"is_secret": false
}
],
"tests/app/main/views/test_register.py": [
{
"type": "Secret Keyword",
"filename": "tests/app/main/views/test_register.py",
"hashed_secret": "bdbb156d25d02fd7792865824201dda1c60f4473",
"is_verified": false,
"line_number": 122,
"is_secret": false
},
{
"type": "Secret Keyword",
"filename": "tests/app/main/views/test_register.py",
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
"is_verified": false,
"line_number": 201,
"is_secret": false
},
{
"type": "Secret Keyword",
"filename": "tests/app/main/views/test_register.py",
"hashed_secret": "bb5b7caa27d005d38039e3797c3ddb9bcd22c3c8",
"is_verified": false,
"line_number": 274,
"is_secret": false
}
],
"tests/app/main/views/test_sign_in.py": [
{
"type": "Secret Keyword",
"filename": "tests/app/main/views/test_sign_in.py",
"hashed_secret": "8b8b69116ee882b5e987e330f55db81aba0636f9",
"is_verified": false,
"line_number": 97,
"is_secret": false
}
],
"tests/app/main/views/test_two_factor.py": [
{
"type": "Secret Keyword",
"filename": "tests/app/main/views/test_two_factor.py",
"hashed_secret": "dc66ad927c29e31c6c374231f57a4684b0687bfe",
"is_verified": false,
"line_number": 290,
"is_secret": false
}
],
"tests/app/main/views/test_user_profile.py": [
{
"type": "Secret Keyword",
"filename": "tests/app/main/views/test_user_profile.py",
"hashed_secret": "8072d7aad32964ec43fbcb699c75dc38890792f7",
"is_verified": false,
"line_number": 336,
"is_secret": false
},
{
"type": "Secret Keyword",
"filename": "tests/app/main/views/test_user_profile.py",
"hashed_secret": "4c9dbb972da179e4f66f023eaa5fb9451d835030",
"is_verified": false,
"line_number": 337,
"is_secret": false
}
],
"tests/app/main/views/test_verify.py": [
{
"type": "Secret Keyword",
"filename": "tests/app/main/views/test_verify.py",
"hashed_secret": "faafcfa63e128929409bf310b7ea5a415f2331ce",
"is_verified": false,
"line_number": 160,
"is_secret": false
}
],
"tests/app/notify_client/test_user_client.py": [
{
"type": "Secret Keyword",
"filename": "tests/app/notify_client/test_user_client.py",
"hashed_secret": "f2c57870308dc87f432e5912d4de6f8e322721ba",
"is_verified": false,
"line_number": 55,
"is_secret": false
}
],
"tests/app/test_cloudfoundry_config.py": [
{
"type": "Secret Keyword",
"filename": "tests/app/test_cloudfoundry_config.py",
"hashed_secret": "5e44dae2de8b6e57c797b968035265c9f2cd2b3e",
"is_verified": false,
"line_number": 12,
"is_secret": false
},
{
"type": "Secret Keyword",
"filename": "tests/app/test_cloudfoundry_config.py",
"hashed_secret": "e5e178db7317356946d13e5d2da037d39ac61c71",
"is_verified": false,
"line_number": 27,
"is_secret": false
}
],
"tests/conftest.py": [
{
"type": "Secret Keyword",
"filename": "tests/conftest.py",
"hashed_secret": "f8377c90fcfd699f0ddbdcb30c2c9183d2d933ea",
"is_verified": false,
"line_number": 3266,
"is_secret": false
}
],
"tests/notifications_utils/clients/antivirus/test_antivirus_client.py": [
{
"type": "Secret Keyword",
"filename": "tests/notifications_utils/clients/antivirus/test_antivirus_client.py",
"hashed_secret": "932b25270abe1301c22c709a19082dff07d469ff",
"is_verified": false,
"line_number": 16,
"is_secret": false
}
],
"tests/notifications_utils/clients/encryption/test_encryption_client.py": [
{
"type": "Secret Keyword",
"filename": "tests/notifications_utils/clients/encryption/test_encryption_client.py",
"hashed_secret": "f1e923a9667de11be6a210849a8651c1bfd81605",
"is_verified": false,
"line_number": 13,
"is_secret": false
}
],
"tests/notifications_utils/clients/zendesk/test_zendesk_client.py": [
{
"type": "Secret Keyword",
"filename": "tests/notifications_utils/clients/zendesk/test_zendesk_client.py",
"hashed_secret": "913a73b565c8e2c8ed94497580f619397709b8b6",
"is_verified": false,
"line_number": 16,
"is_secret": false
}
]
},
"generated_at": "2024-10-29T19:28:03Z"
}

View File

@@ -1,71 +0,0 @@
name: "Bug Report"
description: "Use this template to file a bug report"
title: "Bug: [Brief description of the bug]"
labels:
- "Type: Bug"
body:
- type: markdown
attributes:
value: |
Please use this form to file new bugs! Add screenshots to the comments of this initial form.
- type: input
id: contact
attributes:
label: Who discovered this?
description: Was it something you found or are you filing for someone else? If someone else, how can we contact them?
placeholder: ex. email@example.com
validations:
required: false
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you see!
value: "A bug happened!"
validations:
required: true
- type: dropdown
id: environment
attributes:
label: Environment
description: In which environment did you experience the bug?
options:
- staging
- demo
- production
validations:
required: true
- type: textarea
id: page
attributes:
label: What pages did this appear on?
description: please include URLs of where you saw the bug
validations:
required: true
- type: textarea
id: reproSteps
attributes:
label: "Detail the steps for someone to reproduce"
description: "Reproduction steps"
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: What browsers are you seeing the problem on?
multiple: true
options:
- Firefox
- Chrome
- Safari
- Microsoft Edge
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell

View File

@@ -1 +0,0 @@
blank_issues_enabled: true

View File

@@ -1,76 +0,0 @@
name: "User Story Template"
description: "Use this template for creating user stories"
title: "User Story: [Brief description of the user story]"
labels:
- "Type: User Story"
body:
- type: markdown
attributes:
value: '**User Story:**'
- type: textarea
id: userType
attributes:
label: "As a [type of user],"
description: "Describe the type of user involved in this story."
validations:
required: true
- type: textarea
id: actionFeature
attributes:
label: "I want [an action or feature],"
description: "Describe the desired action or feature from the user's perspective."
validations:
required: true
- type: textarea
id: benefitValue
attributes:
label: "So that [benefit or value]."
description: "Describe the benefit or value the user expects from the action or feature."
validations:
required: true
- type: markdown
attributes:
value: '**Acceptance Criteria:**'
- type: textarea
id: acceptanceCriteria
attributes:
label: "Detailed condition or criteria that must be met for the user story to be considered complete."
description: "List the acceptance criteria for the user story."
validations:
required: true
- type: markdown
attributes:
value: '**Tasks:**'
- type: textarea
id: tasks
attributes:
label: "List of specific tasks or sub-tasks that need to be done to implement the user story."
description: "Outline the tasks necessary to implement the user story."
validations:
required: true
- type: markdown
attributes:
value: '**Dependencies:**'
- type: textarea
id: dependencies
attributes:
label: "List any dependencies that need to be resolved before starting or completing this user story."
description: "Specify any dependencies related to the user story."
validations:
required: false
- type: markdown
attributes:
value: '**Notes:**'
- type: textarea
id: notes
attributes:
label: "Any additional information or context that might be useful for the team."
description: "Provide any extra notes or context for the user story."
validations:
required: false

View File

@@ -1,82 +0,0 @@
name: "QA Checklist"
description: "Use this template to manually QA for prod push"
title: "QA for prod push: Admin #[insert PR number], API #[insert PR number], Utils #[insert PR number]"
body:
- type: checkboxes
id: Logging_in
attributes:
label: Log In
options:
- label: notify-staging.app.cloud.gov resolves
- label: Clicking sign in takes you to login page
- label: 2FA code is sent promptly
- label: 2FA code input screen appears as expected
- label: After code is entered, you are brought to the “choose service” screen
- label: Choosing a service brings you to the dashboard for that service
- type: checkboxes
id: send_template
attributes:
label: Create a template and send
options:
- label: Clicking “Send messages” brings you to templates page
- label: When “Create a template” button is clicked, the option to create new or copy existing template appears
- label: Uploading a csv is quick and automatically directs you to the preview/send page
- label: Formatting looks clean and in place
- label: Message dashboard populates and shows “pending” until call is made to AWS for delivery receipt
- type: checkboxes
id: send__copied_template
attributes:
label: Send a message from copied template
options:
- label: When “Create a template” button is clicked, the option to create new or copy existing template appears
- label: Clicking “copy an existing template” allows you to choose from pre-existing template
- label: After editing, the new template saves and you prepare to send
- type: checkboxes
id: send__existing_template
attributes:
label: Send a message from existing template
options:
- label: Clicking on existing template brings you to preview screen with `prepare to send` button
- label: All else above persists as true (formatting, send, dashboard, etc)
- type: checkboxes
id: download_report
attributes:
label: Download the csv delivery report
options:
- label: Clicking on the csv download directly after a message send downloads a report that contains the data we expect
- label: The batch report appears on the main dashboard batch table with accurate information
- label: Clicking on the csv download link from the main dashboard downloads a report that contains the data we expect
- type: checkboxes
id: invite_member
attributes:
label: Invite a team member
options:
- label: Clicking on “Team” in settings brings you to team member screen
- label: Clicking “Invite a team member” brings you to email and permissions screen
- label: Clicking invite team member sends an invite and shows confirmation
- label: Invite email provides live link that works to register new team member
- type: checkboxes
id: delete_member
attributes:
label: Delete a team member
options:
- label: From team member screen, clicking “Change Details” on a specific profile brings you to that persons profile
- label: Clicking on “Remove this team member” prompts a confirmation
- label: Clicking the confirmation removes the team member
- type: checkboxes
id: sign_out
attributes:
label: Sign Out
options:
- label: Hitting “Sign Out” takes you back to the homepage and logs you out

View File

@@ -1,9 +1,6 @@
name: Deploy egress proxy
description: Set egress space security groups and deploy proxy
inputs:
cf_org:
description: The org the target app exists in.
required: true
cf_space:
description: The space the target app exists in.
required: true
@@ -19,19 +16,6 @@ inputs:
runs:
using: composite
steps:
- name: Install cf-cli
shell: bash
run: |
curl -A "cg-deploy-action" -v -L -o cf-cli_amd64.deb 'https://packages.cloudfoundry.org/stable?release=debian64&version=v8&source=github'
sudo dpkg -i cf-cli_amd64.deb
- name: Login to cf-cli
shell: bash
run: |
cf api api.fr.cloud.gov
cf auth
- name: Target org and space
shell: bash
run: cf target -o ${{ inputs.cf_org }} -s ${{ inputs.cf_space }}
- name: Set restricted space egress
shell: bash
run: ./terraform/set_space_egress.sh -t -s ${{ inputs.cf_space }}

View File

@@ -9,10 +9,10 @@ runs:
sudo apt-get update \
&& sudo apt-get install -y --no-install-recommends \
libcurl4-openssl-dev
- name: Set up Python 3.12.3
uses: actions/setup-python@v4
- name: Set up Python 3.9
uses: actions/setup-python@v3
with:
python-version: "3.12.3"
python-version: "3.9"
- name: Install poetry
shell: bash
run: pip install poetry

View File

@@ -3,22 +3,14 @@
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
version: 3
updates:
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "daily"
assignees:
- "A-Shumway42"
reviewers:
- "A-Shumway42"
- package-ecosystem: "npm"
directory: "/"
- package-ecosystem: 'npm'
directory: '/'
schedule:
interval: "daily"
versioning-strategy: increase
assignees:
- "alexjanousekGSA"
reviewers:
- "alexjanousekGSA"
interval: 'daily'

View File

@@ -1,22 +0,0 @@
<!--
Not sure what you should include or write in a pull request? Please read the
[pull request documentation in our docs!](https://github.com/GSA/notifications-api/blob/main/docs/all.md#pull-requests)
-->
*A note to PR reviewers: it may be helpful to review our [code review documentation](https://github.com/GSA/notifications-api/blob/main/docs/all.md#code-reviews) to know what to keep in mind while reviewing pull requests.*
## Description
Please enter a detailed description here.
## TODO (optional)
* [ ] TODO item 1
* [ ] TODO item 2
* [ ] TODO item ...
## Security Considerations
* Consideration 1
* Consideration 2
* Consideration ...

View File

@@ -7,12 +7,12 @@ permissions:
env:
NOTIFY_ENVIRONMENT: test
NEW_RELIC_CONFIG_FILE: newrelic.ini
NEW_RELIC_ENVIRONMENT: test
FLASK_APP: application.py
WERKZEUG_DEBUG_PIN: off
REDIS_ENABLED: 0
NODE_VERSION: 16.15.1
AWS_US_TOLL_FREE_NUMBER: "+18556438890"
ADMIN_BASE_URL: http://localhost:6012
jobs:
build:
@@ -22,13 +22,7 @@ jobs:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22.3.0"
- name: Install dependencies
run: npm install
- uses: actions/checkout@v3
- uses: ./.github/actions/setup-project
- uses: jwalton/gh-find-current-pr@v1
id: findPr
@@ -44,89 +38,44 @@ jobs:
run: poetry run isort --check-only ./app ./tests
- name: Check dead code
run: make dead-code
- name: Run js lint
run: npm run lint
- name: Run js tests
run: npm test
- name: Run py tests with coverage
run: poetry run coverage run --omit=*/notifications_utils/* -m pytest --maxfail=10 --ignore=tests/end_to_end tests/
# - name: Run E2E tests
# run: poetry run pytest -v --browser chromium --browser firefox --browser webkit tests/end_to_end
# env:
# NOTIFY_E2E_AUTH_STATE_PATH: ${{ secrets.NOTIFY_E2E_AUTH_STATE_PATH }}
# NOTIFY_E2E_TEST_EMAIL: ${{ secrets.NOTIFY_E2E_TEST_EMAIL }}
# NOTIFY_E2E_TEST_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD }}
# NOTIFY_E2E_TEST_URI: ${{ secrets.NOTIFY_E2E_TEST_URI }}
- name: Check coverage threshold
run: poetry run coverage report --fail-under=90
# - name: Health check
# run: |
# response=$(curl -url ${{secrets.NOTIFY_E2E_TEST_URI}}_status)
# if grep -q "ok" <<< "$response"; then
# echo "Health check passed"
# else
# echo "Health check failed"
# exit 1
# fi
# env:
# NOTIFY_E2E_AUTH_STATE_PATH: ${{ secrets.NOTIFY_E2E_AUTH_STATE_PATH }}
# NOTIFY_E2E_TEST_EMAIL: ${{ secrets.NOTIFY_E2E_TEST_EMAIL }}
# NOTIFY_E2E_TEST_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD }}
# NOTIFY_E2E_TEST_URI: ${{ secrets.NOTIFY_E2E_TEST_URI }}
end-to-end-tests:
if: ${{ github.actor != 'dependabot[bot]' }}
permissions:
checks: write
pull-requests: write
contents: write
runs-on: ubuntu-latest
environment: staging
services:
postgres:
image: postgres
env:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: test_notification_api
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
# Maps tcp port 5432 on service container to the host
- 5432:5432
redis:
image: redis
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
# Maps tcp port 6379 on service container to the host
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
- uses: jwalton/gh-find-current-pr@v1
id: findPr
- name: Check API Server availability
run: |
curl --fail -v https://notify-api-staging.app.cloud.gov || exit 1
- name: Run Admin server
# If we want to log stuff and see what's broken,
# insert this line:
# tail -f admin-server.log &
# above make e2e-test
run: |
make run-flask > admin-server.log 2>&1 &
tail -f admin-server.log &
make e2e-test
env:
API_HOST_NAME: https://notify-api-staging.app.cloud.gov/
SECRET_KEY: ${{ secrets.SECRET_KEY }}
DANGEROUS_SALT: ${{ secrets.DANGEROUS_SALT }}
ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }}
ADMIN_CLIENT_USERNAME: notify-admin
NOTIFY_ENVIRONMENT: e2etest
NOTIFY_E2E_AUTH_STATE_PATH: ${{ secrets.NOTIFY_E2E_AUTH_STATE_PATH }}
NOTIFY_E2E_TEST_EMAIL: ${{ secrets.NOTIFY_E2E_TEST_EMAIL }}
NOTIFY_E2E_TEST_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD }}
NOTIFY_E2E_TEST_URI: http://localhost:6012/
VCAP_SERVICES: ${{ secrets.VCAP_SERVICES }}
validate-new-relic-config:
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- uses: ./.github/actions/setup-project
- name: Validate NewRelic config
env:
NEW_RELIC_CONFIG_FILE: newrelic.ini
NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }}
# Need to set a NEW_RELIC_ENVIRONMENT with monitor_mode: true
NEW_RELIC_ENVIRONMENT: staging
@@ -135,22 +84,20 @@ jobs:
dependency-audits:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- uses: ./.github/actions/setup-project
- name: Create requirements.txt
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
- uses: pypa/gh-action-pip-audit@v1.0.8
- uses: pypa/gh-action-pip-audit@v1.0.6
with:
inputs: requirements.txt
ignore-vulns: |
PYSEC-2024-60
- name: Run npm audit
run: make npm-audit
static-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- uses: ./.github/actions/setup-project
- name: Run scan
run: poetry run bandit -r app/ --confidence-level medium
@@ -158,7 +105,7 @@ jobs:
dynamic-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- uses: ./.github/actions/setup-project
- name: Run server
run: make run-flask &
@@ -167,17 +114,17 @@ jobs:
- name: Run OWASP Baseline Scan
uses: zaproxy/action-baseline@v0.9.0
with:
docker_name: "ghcr.io/zaproxy/zaproxy:weekly"
target: "http://localhost:6012"
docker_name: 'ghcr.io/zaproxy/zaproxy:weekly'
target: 'http://localhost:6012'
fail_action: true
allow_issue_writing: false
rules_file_name: "zap.conf"
cmd_options: "-I"
rules_file_name: 'zap.conf'
cmd_options: '-I'
a11y-scan:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- uses: ./.github/actions/setup-project
- name: Run server
run: make run-flask &

View File

@@ -1,95 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ "main", "production" ]
pull_request:
branches: [ "main", "production" ]
schedule:
- cron: '18 5 * * 3'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none
# CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

View File

@@ -22,7 +22,7 @@ jobs:
dependency-audits:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- uses: ./.github/actions/setup-project
- name: Create requirements.txt
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
@@ -35,7 +35,7 @@ jobs:
static-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- uses: ./.github/actions/setup-project
- name: Run scan
run: poetry run bandit -r app/ --confidence-level medium
@@ -43,7 +43,7 @@ jobs:
dynamic-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- uses: ./.github/actions/setup-project
- name: Run server
run: make run-flask &

View File

@@ -12,17 +12,17 @@ jobs:
runs-on: ubuntu-latest
environment: demo
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
fetch-depth: 2
- name: Check for changes to Terraform
id: changed-terraform-files
uses: tj-actions/changed-files@v44
uses: tj-actions/changed-files@v1.1.2
with:
files: |
terraform/demo/**
terraform/shared/**
terraform/demo
terraform/shared
.github/workflows/deploy-demo.yml
- name: Terraform init
if: steps.changed-terraform-files.outputs.any_changed == 'true'
@@ -47,53 +47,30 @@ jobs:
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
- name: Deploy to cloud.gov
uses: cloud-gov/cg-cli-tools@main
uses: 18f/cg-deploy-action@main
env:
DANGEROUS_SALT: ${{ secrets.DANGEROUS_SALT }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }}
ADMIN_CLIENT_USERNAME: "notify-admin"
NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }}
NR_BROWSER_KEY: ${{ secrets.NR_BROWSER_KEY }}
COMMIT_HASH: ${{ github.sha }}
LOGIN_PEM: ${{ secrets.LOGIN_PEM }}
LOGIN_DOT_GOV_CLIENT_ID: "urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov"
LOGIN_DOT_GOV_USER_INFO_URL: "https://secure.login.gov/api/openid_connect/userinfo"
LOGIN_DOT_GOV_ACCESS_TOKEN_URL: "https://secure.login.gov/api/openid_connect/token"
LOGIN_DOT_GOV_LOGOUT_URL: "https://secure.login.gov/openid_connect/logout?client_id=urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov&post_logout_redirect_uri=https://notify-demo.app.cloud.gov/sign-out"
LOGIN_DOT_GOV_BASE_LOGOUT_URL: "https://secure.login.gov/openid_connect/logout?"
LOGIN_DOT_GOV_SIGNOUT_REDIRECT: "https://notify-demo.app.cloud.gov/sign-out"
LOGIN_DOT_GOV_INITIAL_SIGNIN_URL: "https://secure.login.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:notify-gov&nonce=NONCE&prompt=select_account&redirect_uri=https://notify-demo.app.cloud.gov/sign-in&response_type=code&scope=openid+email&state=STATE"
LOGIN_DOT_GOV_CERTS_URL: "https://secure.login.gov/api/openid_connect/certs"
with:
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
cf_org: gsa-tts-benefits-studio
cf_space: notify-demo
cf_command: >-
push -f manifest.yml
push_arguments: >-
--vars-file deploy-config/demo.yml
--var DANGEROUS_SALT="$DANGEROUS_SALT"
--var SECRET_KEY="$SECRET_KEY"
--var ADMIN_CLIENT_USERNAME="notify-admin"
--var ADMIN_CLIENT_SECRET="$ADMIN_CLIENT_SECRET"
--var ADMIN_CLIENT_USERNAME="$ADMIN_CLIENT_USERNAME"
--var NEW_RELIC_LICENSE_KEY="$NEW_RELIC_LICENSE_KEY"
--var NR_BROWSER_KEY="$NR_BROWSER_KEY"
--var COMMIT_HASH="$COMMIT_HASH"
--var LOGIN_DOT_GOV_CLIENT_ID="$LOGIN_DOT_GOV_CLIENT_ID"
--var LOGIN_DOT_GOV_USER_INFO_URL="$LOGIN_DOT_GOV_USER_INFO_URL"
--var LOGIN_DOT_GOV_ACCESS_TOKEN_URL="$LOGIN_DOT_GOV_ACCESS_TOKEN_URL"
--var LOGIN_DOT_GOV_LOGOUT_URL="$LOGIN_DOT_GOV_LOGOUT_URL"
--var LOGIN_DOT_GOV_BASE_LOGOUT_URL="$LOGIN_DOT_GOV_BASE_LOGOUT_URL"
--var LOGIN_DOT_GOV_SIGNOUT_REDIRECT="$LOGIN_DOT_GOV_SIGNOUT_REDIRECT"
--var LOGIN_DOT_GOV_INITIAL_SIGNIN_URL="$LOGIN_DOT_GOV_INITIAL_SIGNIN_URL"
--var LOGIN_DOT_GOV_CERTS_URL="$LOGIN_DOT_GOV_CERTS_URL"
--var LOGIN_PEM="$LOGIN_PEM"
--strategy rolling
- name: Check for changes to egress config
id: changed-egress-config
uses: tj-actions/changed-files@v44
uses: tj-actions/changed-files@v34
with:
files: |
deploy-config/egress_proxy/notify-admin-demo.*.acl
@@ -102,10 +79,6 @@ jobs:
- name: Deploy egress proxy
if: steps.changed-egress-config.outputs.any_changed == 'true'
uses: ./.github/actions/deploy-proxy
env:
CF_USERNAME: ${{ secrets.CLOUDGOV_USERNAME }}
CF_PASSWORD: ${{ secrets.CLOUDGOV_PASSWORD }}
with:
cf_org: gsa-tts-benefits-studio
cf_space: notify-demo
app: notify-admin-demo

View File

@@ -12,17 +12,17 @@ jobs:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
fetch-depth: 2
- name: Check for changes to Terraform
id: changed-terraform-files
uses: tj-actions/changed-files@v44
uses: tj-actions/changed-files@v1.1.2
with:
files: |
terraform/production/**
terraform/shared/**
terraform/production
terraform/shared
.github/workflows/deploy-prod.yml
- name: Terraform init
if: steps.changed-terraform-files.outputs.any_changed == 'true'
@@ -47,53 +47,30 @@ jobs:
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
- name: Deploy to cloud.gov
uses: cloud-gov/cg-cli-tools@main
uses: 18f/cg-deploy-action@main
env:
DANGEROUS_SALT: ${{ secrets.DANGEROUS_SALT }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }}
ADMIN_CLIENT_USERNAME: "notify-admin"
NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }}
NR_BROWSER_KEY: ${{ secrets.NR_BROWSER_KEY }}
COMMIT_HASH: ${{ github.sha }}
LOGIN_PEM: ${{ secrets.LOGIN_PEM }}
LOGIN_DOT_GOV_CLIENT_ID: "urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov"
LOGIN_DOT_GOV_USER_INFO_URL: "https://secure.login.gov/api/openid_connect/userinfo"
LOGIN_DOT_GOV_ACCESS_TOKEN_URL: "https://secure.login.gov/api/openid_connect/token"
LOGIN_DOT_GOV_LOGOUT_URL: "https://secure.login.gov/openid_connect/logout?client_id=urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov&post_logout_redirect_uri=https://beta.notify.gov/sign-out"
LOGIN_DOT_GOV_BASE_LOGOUT_URL: "https://secure.login.gov/openid_connect/logout?"
LOGIN_DOT_GOV_SIGNOUT_REDIRECT: "https://beta.notify.gov/sign-out"
LOGIN_DOT_GOV_INITIAL_SIGNIN_URL: "https://secure.login.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:notify-gov&nonce=NONCE&prompt=select_account&redirect_uri=https://beta.notify.gov/sign-in&response_type=code&scope=openid+email&state=STATE"
LOGIN_DOT_GOV_CERTS_URL: "https://secure.login.gov/api/openid_connect/certs"
with:
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
cf_org: gsa-tts-benefits-studio
cf_space: notify-production
cf_command: >-
push -f manifest.yml
push_arguments: >-
--vars-file deploy-config/production.yml
--var DANGEROUS_SALT="$DANGEROUS_SALT"
--var SECRET_KEY="$SECRET_KEY"
--var ADMIN_CLIENT_USERNAME="notify-admin"
--var ADMIN_CLIENT_SECRET="$ADMIN_CLIENT_SECRET"
--var ADMIN_CLIENT_USERNAME="$ADMIN_CLIENT_USERNAME"
--var NEW_RELIC_LICENSE_KEY="$NEW_RELIC_LICENSE_KEY"
--var NR_BROWSER_KEY="$NR_BROWSER_KEY"
--var COMMIT_HASH="$COMMIT_HASH"
--var LOGIN_DOT_GOV_CLIENT_ID="$LOGIN_DOT_GOV_CLIENT_ID"
--var LOGIN_DOT_GOV_USER_INFO_URL="$LOGIN_DOT_GOV_USER_INFO_URL"
--var LOGIN_DOT_GOV_ACCESS_TOKEN_URL="$LOGIN_DOT_GOV_ACCESS_TOKEN_URL"
--var LOGIN_DOT_GOV_LOGOUT_URL="$LOGIN_DOT_GOV_LOGOUT_URL"
--var LOGIN_DOT_GOV_BASE_LOGOUT_URL="$LOGIN_DOT_GOV_BASE_LOGOUT_URL"
--var LOGIN_DOT_GOV_SIGNOUT_REDIRECT="$LOGIN_DOT_GOV_SIGNOUT_REDIRECT"
--var LOGIN_DOT_GOV_INITIAL_SIGNIN_URL="$LOGIN_DOT_GOV_INITIAL_SIGNIN_URL"
--var LOGIN_DOT_GOV_CERTS_URL="$LOGIN_DOT_GOV_CERTS_URL"
--var LOGIN_PEM="$LOGIN_PEM"
--strategy rolling
- name: Check for changes to egress config
id: changed-egress-config
uses: tj-actions/changed-files@v44
uses: tj-actions/changed-files@v34
with:
files: |
deploy-config/egress_proxy/notify-admin-production.*.acl
@@ -102,10 +79,6 @@ jobs:
- name: Deploy egress proxy
if: steps.changed-egress-config.outputs.any_changed == 'true'
uses: ./.github/actions/deploy-proxy
env:
CF_USERNAME: ${{ secrets.CLOUDGOV_USERNAME }}
CF_PASSWORD: ${{ secrets.CLOUDGOV_PASSWORD }}
with:
cf_org: gsa-tts-benefits-studio
cf_space: notify-production
app: notify-admin-production

View File

@@ -17,105 +17,76 @@ jobs:
environment: staging
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: actions/checkout@v3
with:
fetch-depth: 2
- name: Check for changes to Terraform
id: changed-terraform-files
uses: tj-actions/changed-files@v44
with:
files: |
terraform/staging/**
terraform/shared/**
.github/workflows/deploy.yml
- name: Terraform init
if: steps.changed-terraform-files.outputs.any_changed == 'true'
working-directory: terraform/staging
env:
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }}
run: terraform init
- name: Terraform apply
if: steps.changed-terraform-files.outputs.any_changed == 'true'
working-directory: terraform/staging
env:
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }}
TF_VAR_cf_user: ${{ secrets.CLOUDGOV_USERNAME }}
TF_VAR_cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
run: terraform apply -auto-approve -input=false
- name: Check for changes to Terraform
id: changed-terraform-files
uses: tj-actions/changed-files@v1.1.2
with:
files: |
terraform/staging
terraform/shared
.github/workflows/deploy.yml
- name: Terraform init
if: steps.changed-terraform-files.outputs.any_changed == 'true'
working-directory: terraform/staging
env:
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }}
run: terraform init
- name: Terraform apply
if: steps.changed-terraform-files.outputs.any_changed == 'true'
working-directory: terraform/staging
env:
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }}
TF_VAR_cf_user: ${{ secrets.CLOUDGOV_USERNAME }}
TF_VAR_cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
run: terraform apply -auto-approve -input=false
- uses: ./.github/actions/setup-project
- uses: ./.github/actions/setup-project
- name: Create requirements.txt
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
- name: Create requirements.txt
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
- name: Deploy to cloud.gov
uses: 18f/cg-deploy-action@main
env:
DANGEROUS_SALT: ${{ secrets.DANGEROUS_SALT }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }}
NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }}
NR_BROWSER_KEY: ${{ secrets.NR_BROWSER_KEY }}
with:
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
cf_org: gsa-tts-benefits-studio
cf_space: notify-staging
push_arguments: >-
--vars-file deploy-config/staging.yml
--var DANGEROUS_SALT="$DANGEROUS_SALT"
--var SECRET_KEY="$SECRET_KEY"
--var ADMIN_CLIENT_USERNAME="notify-admin"
--var ADMIN_CLIENT_SECRET="$ADMIN_CLIENT_SECRET"
--var NEW_RELIC_LICENSE_KEY="$NEW_RELIC_LICENSE_KEY"
--var NR_BROWSER_KEY="$NR_BROWSER_KEY"
- name: Deploy to cloud.gov
uses: cloud-gov/cg-cli-tools@main
env:
DANGEROUS_SALT: ${{ secrets.DANGEROUS_SALT }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }}
ADMIN_CLIENT_USERNAME: "notify-admin"
NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }}
NR_BROWSER_KEY: ${{ secrets.NR_BROWSER_KEY }}
COMMIT_HASH: ${{ github.sha }}
LOGIN_PEM: ${{ secrets.LOGIN_PEM }}
LOGIN_DOT_GOV_CLIENT_ID: "urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov"
LOGIN_DOT_GOV_USER_INFO_URL: "https://secure.login.gov/api/openid_connect/userinfo"
LOGIN_DOT_GOV_ACCESS_TOKEN_URL: "https://secure.login.gov/api/openid_connect/token"
LOGIN_DOT_GOV_LOGOUT_URL: "https://secure.login.gov/openid_connect/logout?client_id=urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov&post_logout_redirect_uri=https://notify-staging.app.cloud.gov/sign-out"
LOGIN_DOT_GOV_BASE_LOGOUT_URL: "https://secure.login.gov/openid_connect/logout?"
LOGIN_DOT_GOV_SIGNOUT_REDIRECT: "https://notify-staging.app.cloud.gov/sign-out"
LOGIN_DOT_GOV_INITIAL_SIGNIN_URL: "https://secure.login.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:notify-gov&nonce=NONCE&prompt=select_account&redirect_uri=https://notify-staging.app.cloud.gov/sign-in&response_type=code&scope=openid+email&state=STATE"
LOGIN_DOT_GOV_CERTS_URL: "https://secure.login.gov/api/openid_connect/certs"
with:
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
cf_org: gsa-tts-benefits-studio
cf_space: notify-staging
cf_command: >-
push -f manifest.yml
--vars-file deploy-config/staging.yml
--var DANGEROUS_SALT="$DANGEROUS_SALT"
--var SECRET_KEY="$SECRET_KEY"
--var ADMIN_CLIENT_SECRET="$ADMIN_CLIENT_SECRET"
--var ADMIN_CLIENT_USERNAME="$ADMIN_CLIENT_USERNAME"
--var NEW_RELIC_LICENSE_KEY="$NEW_RELIC_LICENSE_KEY"
--var NR_BROWSER_KEY="$NR_BROWSER_KEY"
--var COMMIT_HASH="$COMMIT_HASH"
--var LOGIN_DOT_GOV_CLIENT_ID="$LOGIN_DOT_GOV_CLIENT_ID"
--var LOGIN_DOT_GOV_USER_INFO_URL="$LOGIN_DOT_GOV_USER_INFO_URL"
--var LOGIN_DOT_GOV_ACCESS_TOKEN_URL="$LOGIN_DOT_GOV_ACCESS_TOKEN_URL"
--var LOGIN_DOT_GOV_LOGOUT_URL="$LOGIN_DOT_GOV_LOGOUT_URL"
--var LOGIN_DOT_GOV_BASE_LOGOUT_URL="$LOGIN_DOT_GOV_BASE_LOGOUT_URL"
--var LOGIN_DOT_GOV_SIGNOUT_REDIRECT="$LOGIN_DOT_GOV_SIGNOUT_REDIRECT"
--var LOGIN_DOT_GOV_INITIAL_SIGNIN_URL="$LOGIN_DOT_GOV_INITIAL_SIGNIN_URL"
--var LOGIN_DOT_GOV_CERTS_URL="$LOGIN_DOT_GOV_CERTS_URL"
--var LOGIN_PEM="$LOGIN_PEM"
--strategy rolling
- name: Check for changes to egress config
id: changed-egress-config
uses: tj-actions/changed-files@v44
with:
files: |
deploy-config/egress_proxy/notify-admin-staging.*.acl
.github/actions/deploy-proxy/action.yml
.github/workflows/deploy.yml
- name: Deploy egress proxy
if: steps.changed-egress-config.outputs.any_changed == 'true'
uses: ./.github/actions/deploy-proxy
env:
CF_USERNAME: ${{ secrets.CLOUDGOV_USERNAME }}
CF_PASSWORD: ${{ secrets.CLOUDGOV_PASSWORD }}
with:
cf_org: gsa-tts-benefits-studio
cf_space: notify-staging
app: notify-admin-staging
- name: Check for changes to egress config
id: changed-egress-config
uses: tj-actions/changed-files@v34
with:
files: |
deploy-config/egress_proxy/notify-admin-staging.*.acl
.github/actions/deploy-proxy/action.yml
.github/workflows/deploy.yml
- name: Deploy egress proxy
if: steps.changed-egress-config.outputs.any_changed == 'true'
uses: ./.github/actions/deploy-proxy
with:
cf_space: notify-staging
app: notify-admin-staging
bail:
runs-on: ubuntu-latest

View File

@@ -13,7 +13,7 @@ jobs:
environment: staging
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Check for drift
uses: dflook/terraform-check@v1
@@ -31,7 +31,7 @@ jobs:
environment: demo
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
ref: 'production'
@@ -51,7 +51,7 @@ jobs:
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
ref: 'production'

View File

@@ -16,7 +16,7 @@ jobs:
environment: demo
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Terraform format
id: format

View File

@@ -16,7 +16,7 @@ jobs:
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Terraform format
id: format

View File

@@ -16,7 +16,7 @@ jobs:
environment: staging
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Terraform format
id: format

14
.gitignore vendored
View File

@@ -14,7 +14,6 @@
*.XLSX
## Non user files allowed to be commited
!app/assets/pdf/tcpa_overview.pdf
!tests/test_pdf_files/no_eof_marker.pdf
!tests/test_pdf_files/multi_page_pdf.pdf
!tests/test_pdf_files/big.pdf
@@ -124,19 +123,6 @@ app/templates/vendor
secrets.auto.tfvars
terraform.tfstate
terraform.tfstate.backup
requirements.txt
terraform/sandbox/requirements.txt
# Playwright
playwright/
# Pyenv
.python-version
# Nodenv
.node-version
# BackstopJS
backstop_data/bitmaps_test/
backstop_data/html_report/
backstop_data/engine_scripts/playwright/

2
.nvmrc
View File

@@ -1 +1 @@
22.3.0
16.15.1

View File

@@ -1,24 +0,0 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: debug-statements
- id: check-merge-conflict
- id: check-toml
- id: check-ast
- id: fix-byte-order-marker
- id: detect-aws-credentials
args: [--allow-missing-credentials]
- id: detect-private-key
- id: mixed-line-ending
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ['--baseline', '.ds.baseline']

View File

@@ -1,3 +0,0 @@
{
"singleQuote": true
}

View File

@@ -4,6 +4,3 @@
##
export NEW_RELIC_PROXY_HOST=$egress_proxy
export http_proxy=$egress_proxy
export https_proxy=$egress_proxy
export no_proxy="apps.internal"

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.9

View File

@@ -15,14 +15,9 @@ NVMSH := $(shell [ -f "$(HOME)/.nvm/nvm.sh" ] && echo "$(HOME)/.nvm/nvm.sh" || e
.PHONY: bootstrap
bootstrap: generate-version-file ## Set up everything to run the app
poetry self update
poetry self add poetry-dotenv-plugin
poetry lock --no-update
poetry install --sync --no-root
poetry install
poetry run playwright install --with-deps
poetry run pre-commit install
source $(NVMSH) --no-use && nvm install && npm install
source $(NVMSH) && npm ci --no-audit
source $(NVMSH) --no-use && nvm install && npm ci --no-audit
source $(NVMSH) && npm run build
.PHONY: watch-frontend
@@ -41,10 +36,6 @@ run-flask-bare: ## Run flask without invoking poetry so we can override ENV var
npm-audit: ## Check for vulnerabilities in NPM packages
source $(NVMSH) && npm run audit
.PHONY: npm-audit-fix
npm-audit-fix: ## Fix vulnerabilities that do not require attentino (according to npm)
source $(NVMSH) && npm audit fix
.PHONY: help
help:
@cat $(MAKEFILE_LIST) | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
@@ -76,19 +67,18 @@ too-complex:
.PHONY: py-test
py-test: export NEW_RELIC_ENVIRONMENT=test
py-test: ## Run python unit tests
poetry run coverage run -m pytest --maxfail=10 --ignore=tests/end_to_end tests/
poetry run coverage run --omit=*/notifications_utils/* -m pytest --maxfail=10 --ignore=tests/end_to_end tests/
poetry run coverage report --fail-under=96
poetry run coverage html -d .coverage_cache
.PHONY: dead-code
dead-code: ## 60% is our aspirational goal, but currently breaks the build
poetry run vulture ./app ./notifications_utils --min-confidence=100
dead-code:
poetry run vulture ./app --min-confidence=100
.PHONY: e2e-test
e2e-test: export NEW_RELIC_ENVIRONMENT=test
e2e-test: ## Run end-to-end integration tests; note that --browser webkit isn't currently working
DEBUG=pw:api,pw:browser poetry run pytest -vv --browser chromium --browser firefox tests/end_to_end
e2e-test: ## Run end-to-end integration tests
poetry run pytest -v --browser chromium --browser firefox --browser webkit tests/end_to_end
.PHONY: js-lint
js-lint: ## Run javascript linting scanners
@@ -102,11 +92,6 @@ js-test: ## Run javascript unit tests
fix-imports: ## Fix imports using isort
poetry run isort ./app ./tests
.PHONY: py-lock
py-lock: ## Syncs dependencies and updates lock file without performing recursive internal updates
poetry lock --no-update
poetry install --sync
.PHONY: freeze-requirements
freeze-requirements: ## create static requirements.txt
poetry export --without-hashes --format=requirements.txt > requirements.txt
@@ -116,7 +101,7 @@ pip-audit:
poetry requirements > requirements.txt
poetry requirements --dev > requirements_for_test.txt
poetry run pip-audit -r requirements.txt
poetry run pip-audit -r requirements_for_test.txt
-poetry run pip-audit -r requirements_for_test.txt
.PHONY: audit
audit: npm-audit pip-audit

518
README.md
View File

@@ -1,10 +1,6 @@
![notify-logo](https://github.com/GSA/notifications-admin/assets/4156602/31b74039-4d87-4f89-a35e-71e9f3152342)
# Notify UI
# Notify.gov Admin UI
This is the Notify front-end for government users and admins. To see it in
action, check out [the demo site](https://notify-demo.app.cloud.gov)
(contact team for credentials).
This is the Notify front-end for government users and admins. To see it in action, check out [the demo site](https://notify-demo.app.cloud.gov) (contact team for credentials).
Through the interface, users can:
@@ -13,440 +9,69 @@ Through the interface, users can:
- Send batch SMS by uploading a CSV
- View their history of notifications
The [Notify.gov API](https://github.com/GSA/notifications-api) provides the
UI's backend and is required for most things to function. Set that up first!
The [Notify API](https://github.com/GSA/notifications-api) provides the UI's backend and is required for most things to function. Set that up first!
Our other repositories are:
## Local setup
- [notifications-admin](https://github.com/GSA/notifications-admin)
- [us-notify-compliance](https://github.com/GSA/us-notify-compliance/)
- [notify-python-demo](https://github.com/GSA/notify-python-demo)
### Common steps
## Before You Start
1. Install pre-requisites for setup (on a Mac):
- Install XCode or at least the XCode Command Line Tools
- [Homebrew](https://brew.sh/) (follow instructions on page)
- [jq](https://stedolan.github.io/jq/): `brew install jq`
- [terraform](https://www.terraform.io/): `brew install terraform` or `brew install tfenv` and use `tfenv` to install `terraform ~> 1.4.0`
- [cf-cli@8](https://docs.cloudfoundry.org/cf-cli/install-go-cli.html): `brew install cloudfoundry/tap/cf-cli@8`
1. [Log into cloud.gov](https://cloud.gov/docs/getting-started/setup/#set-up-the-command-line): `cf login -a api.fr.cloud.gov --sso`
1. Ensure you have access to the `notify-local-dev` and `notify-staging` spaces in cloud.gov
1. Run the API setup steps
1. Run the development terraform with:
You will need the following items:
```
$ cd terraform/development
$ ./run.sh
```
- An active cloud.gov account with the correct permissions - speak with your
onboarding buddy for help with
[setting up an account](https://cloud.gov/sign-up/) (requires a `.mil`,
`.gov`, or `.fed.us` email address) and getting access to the
`notify-local-dev` and `notify-staging` spaces.
- Admin priviliges and SSH access on your machine; you may need to work with
your organization's IT support staff if you're not sure or don't currently
have this access.
1. If you want to send data to New Relic from your local develpment environment, set `NEW_RELIC_LICENSE_KEY` within `.env`
1. Follow the instructions for either `Direct installation` or `Docker installation` below
**In addition, you should set up the Notify.gov API first!**
### Direct installation
[Follow the instructions here to set up the Notify.gov API.](https://github.com/GSA/notifications-api#before-you-start)
1. Get the API running
The Notify.gov API is required in order for the Notify.gov Admin UI to run, and
it will also take care of many of the steps that are listed here. The sections
that are a repeat from the API setup are flagged with an **[API Step]** label
in front of them.
1. Install [poetry](https://python-poetry.org/docs/#installation)
## Local Environment Setup
1. Install [nvm (Node Version Manager)](https://github.com/nvm-sh/nvm#installing-and-updating)
This project is set up to work with
[nvm (Node Version Manager)](https://github.com/nvm-sh/nvm#installing-and-updating)
for managing and using Node.js (version 22.3.0) and the `npm` package manager.
1. Install Python and Node dependencies
These instructions will walk you through how to set your machine up with all of
the required tools for this project.
`make bootstrap`
If you run into certificate errors at the `playwright install` step, try doing this:
1. Run `brew --prefix` to see Homebrew's root directory
1. Create or modify the local `.env` file in the project and add this line:
`NODE_EXTRA_CA_CERTS=/CHANGE-TO-HOMEBREW-INSTALL-PATH/etc/ca-certificates/cert.pem`
Make sure to change `CHANGE-TO-HOMEBREW-INSTALL-PATH` to the path given by `brew --prefix` in the step above.
For example, if `brew --prefix` gave `/opt/homebrew` as output, then the line would look like this:
`NODE_EXTRA_CA_CERTS=/opt/homebrew/etc/ca-certificates/cert.pem`
1. Save the changes to the `.env` file
1. Run `make bootstrap` again
1. Run the Flask server
`make run-flask`
1. Go to http://localhost:6012
### Docker installation
If you are using VS Code, there are also instructions for
[running inside Docker](./docs/docker-remote-containers.md).
### [API Step] Project Pre-Requisite Setup
On MacOS, using [Homebrew](https://brew.sh/) for package management is highly
recommended. This helps avoid some known installation issues. Start by following
the installation instructions on the Homebrew homepage.
**Note:** You will also need Xcode or the Xcode Command Line Tools installed. The
quickest way to do this is is by installing the command line tools in the shell:
```sh
xcode-select -install
```
#### [API Step] Homebrew Setup
If this is your first time installing Homebrew on your machine, you may need to
add its binaries to your system's `$PATH` environment variable so that you can
use the `brew` command. Try running `brew help` to see if Homebrew is
recognized and runs properly. If that fails, then you'll need to add a
configuration line to wherever your `$PATH` environment variable is set.
Your system `$PATH` environment variable is likely set in one of these
locations:
For BASH shells:
- `~/.bashrc`
- `~/.bash_profile`
- `~/.profile`
For ZSH shells:
- `~/.zshrc`
- `~/.zprofile`
There may be different files that you need to modify for other shell
environments.
Which file you need to modify depends on whether or not you are running an
interactive shell or a login shell
(see [this Stack Overflow post](https://stackoverflow.com/questions/18186929/what-are-the-differences-between-a-login-shell-and-interactive-shell)
for an explanation of the differences). If you're still not sure, please ask
the team for help!
Once you determine which file you'll need to modify, add these lines before any
lines that add or modify the `$PATH` environment variable; near or at the top
of the file is appropriate:
```sh
# Homebrew setup
eval "$(/opt/homebrew/bin/brew shellenv)"
```
This will make sure Homebrew gets setup correctly. Once you make these changes,
either start a new shell session or source the file
(`source ~/.FILE-YOU-MODIFIED`) you modified to have your system recognize the
changes.
Verify that Homebrew is now working by trying to run `brew help` again.
### [API Step] System-Level Package Installation
There are several packages you will need to install for your system in order to
get the app running (and these are good to have in general for any software
development).
Start off with these packages since they're quick and don't require additional
configuration after installation to get working out of the box:
- [jq](https://stedolan.github.io/jq/) - for working with JSON in the command
line
- [git](https://git-scm.com/) - for version control management
- [tfenv](https://github.com/tfutils/tfenv) - for managing
[Terraform](https://www.terraform.io/) installations
- [cf-cli@8](https://docs.cloudfoundry.org/cf-cli/install-go-cli.html) - for
working with a Cloud Foundry platform (e.g., cloud.gov)
- [vim](https://www.vim.org/) - for editing files more easily in the command
line
- [wget](https://www.gnu.org/software/wget/) - for retrieving files in the
command line
You can install them by running the following:
```sh
brew install jq git tfenv cloudfoundry/tap/cf-cli@8 vim wget
```
#### [API Step] Terraform Installation
As a part of the installation above, you just installed `tfenv` to manage
Terraform installations. This is great, but you still need to install Terraform
itself, which can be done with this command:
```sh
tfenv install latest:^1.4.0
```
_NOTE: This project currently uses the latest `1.4.x release of Terraform._
#### [API Step] Python Installation
Now we're going to install a tool to help us manage Python versions and
virtual environments on our system. First, we'll install
[pyenv](https://github.com/pyenv/pyenv) and one of its plugins,
[pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv), with Homebrew:
```sh
brew install pyenv pyenv-virtualenv
```
When these finish installing, you'll need to make another adjustment in the
file that you adjusted for your `$PATH` environment variable and Homebrew's
setup. Open the file, and add these lines to it:
```
# pyenv setup
export PYENV_ROOT="$HOME/.pyenv"
command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
eval "$(pyenv virtualenv-init -)"
```
Once again, start a new shell session or source the file in your current shell
session to make the changes take effect.
Now we're ready to install the Python version we need with `pyenv`, like so:
```sh
pyenv install 3.12
```
This will install the latest version of Python 3.12.
_NOTE: This project currently runs on Python 3.12.x._
#### [API Step] Python Dependency Installation
Lastly, we need to install the tool we use to manage Python dependencies within
the project, which is [poetry](https://python-poetry.org/).
Visit the
[official installer instructions page](https://python-poetry.org/docs/#installing-with-the-official-installer)
and follow the steps to install Poetry directly with the script.
This will ensure `poetry` doesn't conflict with any project virtual environments
and can update itself properly.
#### Node.js and npm Installation
The project will manage most of the Node.js pieces for you, but you will need to
install
[nvm (Node Version Manager)](https://github.com/nvm-sh/nvm#installing-and-updating)
in order for things to work.
Follow the steps in the installation instructions to get `nvm` installed with
the install script they provide, and double check that the file that you
adjusted for your `$PATH` environment variable and Homebrew's setup is properly
updated with the `nvm` setup as well.
Open the file and check for the `nvm` setup lines; if they're not there, then
add the lines found in the
[nvm installation instructions](https://github.com/nvm-sh/nvm#install--update-script)
to to the file after the `pyenv` section you created, e.g.:
```
# nvm setup
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion
```
### First-Time Project Setup
Once all of pre-requisites for the project are installed and you have a
cloud.gov account, you can now set up the admin project and get things running
locally!
First, clone the repository in the directory of your choosing on your machine:
```sh
git clone git@github.com:GSA/notifications-admin.git
```
Now go into the project directory (`notifications-admin` by default), create a
virtual environment, and set the local Python version to point to the virtual
environment (assumes version Python `3.12.2` is what is installed on your
machine):
```sh
cd notifications-admin
pyenv virtualenv 3.12.2 notify-admin
pyenv local notify-admin
```
_If you're not sure which version of Python was installed with `pyenv`, you can check by running `pyenv versions` and it'll list everything available currently._
Now [log into cloud.gov](https://cloud.gov/docs/getting-started/setup/#set-up-the-command-line)
in the command line by using this command:
```sh
cf login -a api.fr.cloud.gov --sso
```
_REMINDER: Ensure you have access to the `notify-local-dev` and `notify-staging` spaces in cloud.gov_
Now run the development Terraform setup by navigating to the development
folder and running the script in it:
```sh
cd terraform/development
./run.sh
```
In addition to some infrastructure setup, this will also create a local `.env`
file for you in the project's root directory, which will include a handful of
project-specific environment variables.
#### Upgrading Python in existing projects
If you're upgrading an existing project to a newer version of Python, you can
follow these steps to get yourself up-to-date.
First, use `pyenv` to install the newer version of Python you'd like to use;
we'll use `3.12` in our example here since we recently upgraded to this version:
```sh
pyenv install 3.12
```
Next, delete the virtual environment you previously had set up. If you followed
the instructions above with the first-time set up, you can do this with `pyenv`:
```sh
pyenv virtualenv-delete notify-admin
```
Now, make sure you are in your project directory and recreate the same virtual
environment with the newer version of Python you just installed:
```sh
cd notifications-admin
pyenv virtualenv 3.12.2 notify-admin
pyenv local notify-admin
```
At this point, proceed with the rest of the instructions here in the README and
you'll be set with an upgraded version of Python.
_If you're not sure about the details of your current virtual environment, you can run `poetry env info` to get more information. If you've been using `pyenv` for everything, you can also see all available virtual environments with `pyenv virtualenvs`._
#### Updating the .env file for Login.gov
To configure the application for Login.gov, you will need to update the following environment variables in the .env file:
```
COMMIT_HASH=”--------”
```
Reach out to someone on the team to get the most recent Login.gov key.
```
LOGIN_PEM="INSERT_LOGIN_GOV_KEY_HERE"
```
#### Updating the .env file for E2E tests
With the newly created `.env` file in place, you'll need to make one more
adjustment in it for things to run properly for the E2E tests.
Open the `.env` file and look for the `NODE_EXTRA_CA_CERTS` environment variable
toward the top. You will need to modify its value to be the path to Homebrew's
CA certificate file.
Run `brew --prefix` to see Homebrew's root directory, and use that value to
update the `NODE_EXTRA_CA_CERTS` environment variable with the path. For
example, if `brew --prefix` gave `/opt/homebrew` as output, then the line would
look like this:
```
NODE_EXTRA_CA_CERTS=/opt/homebrew/etc/ca-certificates/cert.pem
```
Save this change to the `.env` file and you'll be squared away.
## Running the Project and Routine Maintenance
The first time you run the project you'll need to run the project setup from the
root project directory:
```sh
make bootstrap
```
This command is handled by the `Makefile` file in the root project directory, as
are a few others.
_NOTE: You'll want to occasionally run `make bootstrap` to keep your project up-to-date, especially when there are dependency updates._
Now you can run the web server and background workers for asynchronous jobs:
```sh
make run-flask
```
This will run the local development web server and make the admin site
available at http://localhost:6012; remember to make sure that the Notify.gov
API is running as well!
## Creating a 'First User' in the database
After you have completed all setup steps, you will be unable to log in, because there
will not be a user in the database to link to the login.gov account you are using. So
you will need to create that user in your database using the 'create-test-user' command.
Open two terminals pointing to the api project and then run these commands in the
respective terminals.
(Server 1)
env ALLOW_EXPIRED_API_TOKEN=1 make run-flask
(Server 2)
poetry run flask command create-admin-jwt | tail -n 1 | pbcopy
poetry run flask command create-test-user --admin=True;
Supply your name, email address, mobile number, and password when prompted. Make sure the email address
is the same one you are using in login.gov and make sure your phone number is in the format 5555555555.
If for any reason in the course of development it is necessary for your to delete your db
via the `dropdb` command, you will need to repeat these steps when you recreate your db.
## Git Hooks
We're using [`pre-commit`](https://pre-commit.com/) to manage hooks in order to
automate common tasks or easily-missed cleanup. It's installed as part of
`make bootstrap` and is limited to this project's virtualenv.
To run the hooks in advance of a `git` operation, use
`poetry run pre-commit run`. For running across the whole codebase (useful after
adding a new hook), use `poetry run pre-commit run --all-files`.
The configuration is stored in `.pre-commit-config.yaml`. In that config, there
are links to the repos from which the hooks are pulled, so hop through there if
ou want a detailed description of what each one is doing.
We do not maintain any hooks in this repository.
## Python Dependency Management
We're using [`Poetry`](https://python-poetry.org/) for managing our Python
dependencies and local virtual environments. When it comes to managing the
Python dependencies, there are a couple of things to bear in mind.
For situations where you manually manipulate the `pyproject.toml` file, you
should use the `make py-lock` command to sync the `poetry.lock` file. This will
ensure that you don't inadvertently bring in other transitive dependency updates
that have not been fully tested with the project yet.
If you're just trying to update a dependency to a newer (or the latest) version,
you should let Poetry take care of that for you by running the following:
```sh
poetry update <dependency> [<dependency>...]
```
You can specify more than one dependency together. With this command, Poetry
will do the following for you:
- Find the latest compatible version(s) of the specified dependency/dependencies
- Install the new versions
- Update and sync the `poetry.lock` file
In either situation, once you are finished and have verified the dependency
changes are working, please be sure to commit both the `pyproject.toml` and
`poetry.lock` files.
## Known Installation Issues
### Python Installation Errors
On M1 Macs, if you get a `fatal error: 'Python.h' file not found` message, try a
different method of installing Python. The recommended approach is to use
[`pyenv`](https://github.com/pyenv/pyenv), as noted above in the installation
instructions.
If you're using PyCharm for Python development, we've noticed some quirkiness
with the IDE and the interaction between Poetry and virtual environment
management that could cause a variety of problems to come up during project
setup and dependency management. Other tools, such as Visual Studio Code, have
proven to be a smoother experience for folks.
If you are using VS Code, there are also instructions for [running inside Docker](./docs/docker-remote-containers.md)
## To test the application
@@ -460,13 +85,11 @@ make test
npm run test-watch
```
To run a specific JavaScript test, you'll need to copy the full command from
`package.json`.
To run a specific JavaScript test, you'll need to copy the full command from `package.json`.
## Running a11y-scans locally
Unlike most of the tests and scans, pa11y-ci cannot currently be run from within
the VSCode dev container.
Unlike most of the tests and scans, pa11y-ci cannot currently be run from within the VSCode dev container.
1. `make run-flask` from within the devcontainer
2. Run `make a11y-scan` from your host computer.
@@ -479,41 +102,16 @@ the VSCode dev container.
## License && public domain
Work through
[commit `543be77`](https://github.com/GSA/notifications-admin/commit/543be77776b64fddb6ba70fbb015ecd81a372478)
is licensed by the UK government under the MIT license. Work after that commit
is in the worldwide public domain. See [LICENSE.md](./LICENSE.md) for more
information.
Work through [commit `543be77`](https://github.com/GSA/notifications-admin/commit/543be77776b64fddb6ba70fbb015ecd81a372478) is licensed by the UK government under the MIT license. Work after that commit is in the worldwide public domain. See [LICENSE.md](./LICENSE.md) for more information.
## Contributing
As stated in [CONTRIBUTING.md](CONTRIBUTING.md), all contributions to this
project will be released under the CC0 dedication. By submitting a pull request,
you are agreeing to comply with this waiver of copyright interest.
As stated in [CONTRIBUTING.md](CONTRIBUTING.md), all contributions to this project will be released under the CC0 dedication. By submitting a pull request, you are agreeing to comply with this waiver of copyright interest.
## About the TTS Public Benefits Studio
The Public Benefits Studio is a team inside of
[GSAs Technology Transformation Services](https://www.gsa.gov/about-us/organization/federal-acquisition-service/technology-transformation-services)
(TTS), home to innovative programs like [18F](https://18f.gsa.gov/) and
[Login.gov](https://login.gov). We collaborate with benefits programs to develop
shared technology tools and best practices that reduce the burden of navigating
government programs for low income individuals and families.
The Public Benefits Studio is a team inside of [GSAs Technology Transformation Services](https://www.gsa.gov/about-us/organization/federal-acquisition-service/technology-transformation-services) (TTS), home to innovative programs like [18F](https://18f.gsa.gov/) and [Login.gov](https://login.gov). We collaborate with benefits programs to develop shared technology tools and best practices that reduce the burden of navigating government programs for low income individuals and families.
Were a cross-functional team of technologists with specialized experience
working across public benefits programs like Medicaid, SNAP, and unemployment
insurance.
Were a cross-functional team of technologists with specialized experience working across public benefits programs like Medicaid, SNAP, and unemployment insurance.
For more information on what we're working on, the Notify tool, and how to get
involved with our team,
[see our flyer.](https://github.com/GSA/notifications-admin/blob/main/docs/notify-pilot-flyer.md)
## Updating secrets for the E2E tests
At some point, E2E tests will fail because the secrets held in VCAP_SERVICES have expired. To refresh
them, you will need to do the following:
1. Log in the normal way to access cloudfoundry command line options
2. In your terminal, run `chmod +x print_vcap.sh`
3. In your terminal, run `./print_vcap.sh`
4. Copy the value in your terminal and paste it into the VCAP_SERVICES secret in Github on the staging tier.
For more information on what we're working on, the Notify tool, and how to get involved with our team, [see our flyer.](https://github.com/GSA/notifications-admin/blob/main/docs/notify-pilot-flyer.md)

View File

@@ -1,2 +0,0 @@
Contact: mailto:notify-support@gsa.gov
Expires: 2035-10-15T23:59:59Z

View File

@@ -21,8 +21,15 @@ from flask_login import LoginManager, current_user
from flask_talisman import Talisman
from flask_wtf import CSRFProtect
from flask_wtf.csrf import CSRFError
from govuk_frontend_jinja.flask_ext import init_govuk_frontend
from itsdangerous import BadSignature
from notifications_python_client.errors import HTTPError
from notifications_utils import logging, request_helper
from notifications_utils.formatters import (
formatted_list,
get_lines_with_normalised_whitespace,
)
from notifications_utils.recipients import format_phone_number_human_readable
from werkzeug.exceptions import HTTPException as WerkzeugHTTPException
from werkzeug.exceptions import abort
from werkzeug.local import LocalProxy
@@ -30,9 +37,8 @@ from werkzeug.local import LocalProxy
from app import proxy_fix
from app.asset_fingerprinter import asset_fingerprinter
from app.config import configs
from app.extensions import redis_client
from app.extensions import redis_client, zendesk_client
from app.formatters import (
convert_markdown_template,
convert_to_boolean,
format_auth_type,
format_billions,
@@ -46,8 +52,7 @@ from app.formatters import (
format_datetime_human,
format_datetime_normal,
format_datetime_relative,
format_datetime_scheduled_notification,
format_datetime_table,
format_datetime_short,
format_day_of_week,
format_delta,
format_delta_days,
@@ -83,12 +88,12 @@ from app.navigation import (
HeaderNavigation,
MainNavigation,
OrgNavigation,
SecondaryNavigation,
)
from app.notify_client import InviteTokenError
from app.notify_client.api_key_api_client import api_key_api_client
from app.notify_client.billing_api_client import billing_api_client
from app.notify_client.complaint_api_client import complaint_api_client
from app.notify_client.email_branding_client import email_branding_client
from app.notify_client.events_api_client import events_api_client
from app.notify_client.inbound_number_client import inbound_number_client
from app.notify_client.invite_api_client import invite_api_client
@@ -100,25 +105,24 @@ from app.notify_client.performance_dashboard_api_client import (
performance_dashboard_api_client,
)
from app.notify_client.platform_stats_api_client import platform_stats_api_client
from app.notify_client.provider_client import provider_client
from app.notify_client.service_api_client import service_api_client
from app.notify_client.status_api_client import status_api_client
from app.notify_client.template_folder_api_client import template_folder_api_client
from app.notify_client.template_statistics_api_client import template_statistics_client
from app.notify_client.upload_api_client import upload_api_client
from app.notify_client.user_api_client import user_api_client
from app.url_converters import SimpleDateTypeConverter, TemplateTypeConverter
from app.utils.govuk_frontend_jinja.flask_ext import init_govuk_frontend
from notifications_utils import logging, request_helper
from notifications_utils.formatters import (
formatted_list,
get_lines_with_normalised_whitespace,
from app.url_converters import (
SimpleDateTypeConverter,
TemplateTypeConverter,
TicketTypeConverter,
)
from notifications_utils.recipients import format_phone_number_human_readable
login_manager = LoginManager()
csrf = CSRFProtect()
talisman = Talisman()
# The current service attached to the request stack.
current_service = LocalProxy(partial(getattr, request_ctx, "service"))
@@ -130,7 +134,6 @@ navigation = {
"main_navigation": MainNavigation(),
"header_navigation": HeaderNavigation(),
"org_navigation": OrgNavigation(),
"secondary_navigation": SecondaryNavigation(),
}
@@ -147,15 +150,8 @@ def _csp(config):
"'unsafe-eval'",
"https://js-agent.newrelic.com",
"https://gov-bam.nr-data.net",
"https://www.googletagmanager.com",
"https://www.google-analytics.com",
"https://dap.digitalgov.gov",
],
"connect-src": [
"'self'",
"https://gov-bam.nr-data.net",
"https://www.google-analytics.com",
],
"connect-src": ["'self'", "https://gov-bam.nr-data.net"],
"style-src": ["'self'", asset_domain],
"img-src": ["'self'", asset_domain, logo_domain],
}
@@ -184,6 +180,7 @@ def create_app(application):
api_key_api_client,
billing_api_client,
complaint_api_client,
email_branding_client,
events_api_client,
inbound_number_client,
invite_api_client,
@@ -193,6 +190,7 @@ def create_app(application):
organizations_client,
performance_dashboard_api_client,
platform_stats_api_client,
provider_client,
service_api_client,
status_api_client,
template_folder_api_client,
@@ -201,6 +199,7 @@ def create_app(application):
user_api_client,
# External API clients
redis_client,
zendesk_client,
):
client.init_app(application)
@@ -229,24 +228,6 @@ def create_app(application):
)
logging.init_app(application)
# Hopefully will help identify if there is a race condition causing the CSRF errors
# that we have occasionally seen in our environments.
for key in ("SECRET_KEY", "DANGEROUS_SALT"):
try:
value = application.config[key]
except KeyError:
application.logger.error(f"Env Var {key} doesn't exist.")
else:
try:
data_len = len(value.strip())
except (TypeError, AttributeError):
application.logger.error(f"Env Var {key} invalid type: {type(value)}")
else:
if data_len:
application.logger.info(f"Env Var {key} is a non-zero length.")
else:
application.logger.error(f"Env Var {key} is empty.")
login_manager.login_view = "main.sign_in"
login_manager.login_message_category = "default"
login_manager.session_protection = None
@@ -303,28 +284,13 @@ def init_app(application):
@application.context_processor
def _attach_current_global_daily_messages():
global_limit = 0
remaining_global_messages = 0
if current_app:
if request.view_args:
service_id = request.view_args.get(
"service_id", session.get("service_id")
)
else:
service_id = session.get("service_id")
if service_id:
global_limit = current_app.config["GLOBAL_SERVICE_MESSAGE_LIMIT"]
global_messages_count = (
service_api_client.get_global_notification_count(service_id)
)
remaining_global_messages = global_limit - global_messages_count.get(
"count"
)
return {
"global_message_limit": global_limit,
"daily_global_messages_remaining": remaining_global_messages,
}
if current_app:
global_limit = current_app.config["GLOBAL_SERVICE_MESSAGE_LIMIT"]
global_messages_count = service_api_client.get_global_notification_count()
remaining_global_messages = global_limit - global_messages_count
return {"daily_global_messages_remaining": remaining_global_messages}
@application.before_request
def record_start_time():
@@ -342,6 +308,7 @@ def init_app(application):
application.url_map.converters["uuid"].to_python = lambda self, value: value
application.url_map.converters["template_type"] = TemplateTypeConverter
application.url_map.converters["ticket_type"] = TicketTypeConverter
application.url_map.converters["simple_date"] = SimpleDateTypeConverter
@@ -364,23 +331,11 @@ def make_session_permanent():
def create_beta_url(url):
url_created = None
try:
url_created = urlparse(url)
url_list = list(url_created)
url_list[1] = "beta.notify.gov"
url_for_redirect = urlunparse(url_list)
return url_for_redirect
except ValueError:
# This might be happening due to IPv6, see issue # 1395.
# If we see "'RequestContext' object has no attribute 'service'" in the logs
# we can search around that timestamp and find this output, hopefully.
# It may be sufficient to just catch and log, and prevent the stack trace from being in the logs
# but we need to confirm the root cause first.
current_app.logger.error(
f"create_beta_url orig_url: {url} \
url_created = {str(url_created)} url_for_redirect {str(url_for_redirect)}"
)
url_created = urlparse(url)
url_list = list(url_created)
url_list[1] = "beta.notify.gov"
url_for_redirect = urlunparse(url_list)
return url_for_redirect
def redirect_notify_to_beta():
@@ -388,7 +343,6 @@ def redirect_notify_to_beta():
current_app.config["NOTIFY_ENVIRONMENT"] == "production"
and "beta.notify.gov" not in request.url
):
# TODO add debug here to trace what is going on with the URL for the 'RequestContext' error
url_to_beta = create_beta_url(request.url)
return redirect(url_to_beta, 302)
@@ -410,10 +364,6 @@ def load_service_before_request():
request_ctx.service = Service(
service_api_client.get_service(service_id)["data"]
)
stats = service_api_client.get_service_statistics(
service_id, limit_days=7
)
request_ctx.service.stats = stats
except HTTPError as exc:
# if service id isn't real, then 404 rather than 500ing later because we expect service to be set
if exc.status_code == 404:
@@ -581,8 +531,7 @@ def add_template_filters(application):
format_datetime,
format_datetime_24h,
format_datetime_normal,
format_datetime_scheduled_notification,
format_datetime_table,
format_datetime_short,
valid_phone_number,
linkable_name,
format_date,
@@ -620,7 +569,6 @@ def add_template_filters(application):
format_mobile_network,
format_yes_no,
square_metres_to_square_miles,
convert_markdown_template,
]:
application.add_template_filter(fn)

View File

@@ -17,12 +17,12 @@
href="/static/images/apple-touch-icon.png?a0f7e1b728a42016b247dc54ee40d055">
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/images/favicon-16x16.png">
<link rel="manifest" href="{{ asset_url('images/site.webmanifest') }}">
<link rel="manifest" href="/static/images/site.webmanifest">
<link rel="stylesheet" media="screen" href="/static/stylesheets/main.css?d077f86473501ab244de0b30600536ee" />
<link rel="stylesheet" media="print" href="/static/stylesheets/print.css?28010888ca5719dc83d7c2c80f6ed2b2" />
<meta property="og:image" content="/static/images/notify-og-image.png">
<meta property="og:image" content="/static/images/usa-opengraph-image.png">
</head>
<body class="usa-template__body">
@@ -68,8 +68,11 @@
<p class="usa-body">
Try again later.
</p>
<p>To report a problem you can email us at <a class="usa-link" href="mailto:notify-support@gsa.gov">notify-support@gsa.gov</a>.</p>
<p>You can expect a response within one business day.</p>
<p class="usa-body">
You can check our <a class="usa-link"
href="https://status.notifications.service.gov.uk/">system status</a> page to see if there are any known
issues.<br />To report a problem, email <a
href="mailto:gov-uk-notify-support@digital.cabinet-office.gov.uk">gov-uk-notify-support@digital.cabinet-office.gov.uk</a>
</div>
</div>
</main>
@@ -104,4 +107,4 @@
</body>
</html>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 994 B

After

Width:  |  Height:  |  Size: 283 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.2 KiB

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 13.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 14948) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="719px" height="720px" viewBox="0 0 129.996 130.238" enable-background="new 0 0 129.996 130.238"
xml:space="preserve">
<rect fill="#00538E" width="129.996" height="130.238"/>
<path fill="#FFFFFF" d="M78.611,96.076c0,9.138-7.175,12.953-15.451,12.688c-2.938-0.095-5.799-0.838-8.916-1.855l0.707-6.578
c2.638,1.086,6.057,2.263,8.92,2.219c3.74-0.058,7.477-1.794,7.477-6.124c0-3.335-2.67-4.976-5.252-6.283
c-5.709-2.896-12.275-4.907-12.275-12.998c0-7.65,6.084-11.64,13.625-11.64c2.741,0,6.014,0.477,9.299,1.641l-0.985,6.478
c-3.577-1.403-5.14-1.901-8.078-1.901c-3.253,0-6.597,1.143-6.597,4.896c0,2.933,3.01,4.456,5.248,5.573
C72.398,85.218,78.611,87.703,78.611,96.076"/>
<path fill="#FFFFFF" d="M44.561,106.8c-2.632,0.901-6.662,1.964-13.092,1.964c-13.195,0-22.027-8.529-22.027-21.805
c0-12.882,9.375-21.455,22.086-21.455c6.036,0,8.727,0.896,12.541,2.212l-0.574,7.216c-3.51-2.294-6.498-3.211-11.674-3.211
c-9.09,0-15.158,6.578-15.115,15.412c0.043,9.506,6.648,15.416,14.822,15.416c2.437,0,4.508-0.178,5.996-0.569V91.15h-7.861v-6.103
h14.898V106.8"/>
<polyline fill="#FFFFFF" points="102.041,64.587 112.063,86.918 105.08,86.918 102.041,79.497 98.996,86.918 92.014,86.918
102.041,64.587 "/>
<polyline fill="#FFFFFF" points="112.178,87.179 121.507,108.029 114.176,108.029 107.203,91.839 112.178,87.179 "/>
<polyline fill="#FFFFFF" points="91.9,87.183 96.873,91.841 93.199,100.24 102.027,95.611 107.109,99.525 89.727,108.031
82.57,108.031 91.9,87.183 "/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 292.1 39.47"><path fill="#fff" d="M7.44 6.89h17.2v22.06H7.44z"/><path fill="#112e51" d="M53.45 5.28h-6.16v27.31h17.87v-5.38H53.45V5.28zm30.76-.19c-8.35 0-14.67 5.89-14.67 13.85s6.32 13.93 14.67 13.93 14.67-6 14.67-13.93S92.56 5.09 84.21 5.09zm.08 22.39a8.49 8.49 0 0 1-8.43-8.54 8.4 8.4 0 0 1 8.43-8.47 8.29 8.29 0 0 1 8.27 8.47 8.36 8.36 0 0 1-8.27 8.54zm35.36-17.05a11.43 11.43 0 0 1 7.49 3l3.47-4.41a17.21 17.21 0 0 0-11-4c-8.51 0-14.9 6-14.9 13.85s6.29 13.99 14.58 13.99a20.21 20.21 0 0 0 11.08-3.55V17.57h-10.11v4.82H125V26a12.42 12.42 0 0 1-5.34 1.48 8.52 8.52 0 1 1 0-17zm19.61-5.15h6.17v27.31h-6.17zm34.53 17.56L160.52 5.28h-5.61v27.31h5.89V15.07l13.22 17.52h5.62V5.28h-5.85v17.56zm17.47 2.85a3.39 3.39 0 0 0-3.47 3.56 3.47 3.47 0 1 0 6.94 0 3.36 3.36 0 0 0-3.47-3.56zm23.15-15.26a11.43 11.43 0 0 1 7.49 3l3.47-4.41a17.21 17.21 0 0 0-11-4c-8.51 0-14.9 6-14.9 13.85s6.28 13.93 14.55 13.93a20.21 20.21 0 0 0 11.08-3.55V17.57H215v4.82h4.77V26a12.42 12.42 0 0 1-5.34 1.48 8.52 8.52 0 1 1 0-17zm32.23-5.34C238.29 5.09 232 11 232 18.94s6.32 13.93 14.67 13.93 14.67-6 14.67-13.93S255 5.09 246.64 5.09zm.08 22.39a8.49 8.49 0 0 1-8.43-8.54 8.4 8.4 0 0 1 8.43-8.47 8.29 8.29 0 0 1 8.28 8.47 8.36 8.36 0 0 1-8.28 8.54zm39.02-22.2-7.49 20.6-7.45-20.6h-6.63l10.73 27.31h6.36L292.1 5.28h-6.36z"/><path fill="#e21d3e" d="m11.38 25.34 1.45-8.28a4.39 4.39 0 0 1 2.71-7.86V0H4.34A4.34 4.34 0 0 0 0 4.34v21.28a2.62 2.62 0 0 0 .26 1.16c.94 1.9 4.65 8 15.27 12.69V25.7a24.3 24.3 0 0 1-4.15-.36z"/><path fill="#b51e23" d="M26.72 0H15.53v9.19A4.39 4.39 0 0 1 18.28 17l1.45 8.32a24.29 24.29 0 0 1-4.2.36v13.79C26.14 34.8 29.86 28.68 30.8 26.79a2.62 2.62 0 0 0 .26-1.16V4.34A4.34 4.34 0 0 0 26.72 0z"/></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,7 +0,0 @@
<svg width="187" height="230" viewBox="0 0 187 230" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M89.1324 230V203.125H57.936C60.0752 226.499 79.7735 230 89.1324 230Z" fill="#2491FF"/>
<path d="M89.1324 230V203.125H121.22C119.02 226.499 98.7587 230 89.1324 230Z" fill="#005EA2"/>
<path d="M71.4869 19.5185C71.4869 3.35574 82.3456 0 89.1324 0V194.166H0V174.861L22.1703 153.759V87.3122C22.1703 47.8033 53.8415 24.1578 71.4869 19.5185Z" fill="#2491FF"/>
<path d="M89.1324 0C95.9192 0 106.778 3.35574 106.778 19.5185C112.193 20.9422 118.928 24.1557 125.664 29.0681C118.405 36.2889 113.911 46.2873 113.911 57.3353C113.911 79.3543 131.761 97.2043 153.78 97.2043C154.557 97.2043 155.293 97.2481 156.058 97.2043L156.094 153.759L178.265 174.861V194.166H89.1324V0Z" fill="#005EA2"/>
<path d="M186.435 57.1535C186.435 74.9784 171.985 89.4283 154.16 89.4283C136.335 89.4283 121.885 74.9784 121.885 57.1535C121.885 39.3286 136.335 24.8786 154.16 24.8786C171.985 24.8786 186.435 39.3286 186.435 57.1535Z" fill="#D54309"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1,17 +0,0 @@
<svg width="1596" height="400" viewBox="0 0 1596 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M155.013 400V353.26H100.758C104.479 393.912 138.736 400 155.013 400Z" fill="#58B4FF"/>
<path d="M155.013 400V353.26H210.818C206.991 393.912 171.754 400 155.013 400Z" fill="#0076D6"/>
<path d="M124.325 33.9452C124.325 5.83608 143.21 0 155.013 0V337.68H0V304.105L38.5571 267.407V151.847C38.5571 83.1361 93.6374 42.0136 124.325 33.9452Z" fill="#58B4FF"/>
<path d="M155.013 0C166.816 0 185.701 5.83608 185.701 33.9452C195.118 36.4212 206.832 42.0098 218.547 50.5533C205.922 63.1111 198.106 80.4997 198.106 99.7136C198.106 138.008 229.15 169.051 267.444 169.051C268.795 169.051 270.074 169.127 271.406 169.051L271.469 267.407L310.026 304.105V337.68H155.013V0Z" fill="#0076D6"/>
<path d="M324.234 99.3973C324.234 130.397 299.104 155.528 268.104 155.528C237.104 155.528 211.974 130.397 211.974 99.3973C211.974 68.3975 237.104 43.2672 268.104 43.2672C299.104 43.2672 324.234 68.3975 324.234 99.3973Z" fill="#D54309"/>
<path d="M416.684 290.469C414.747 290.469 413.25 289.94 412.194 288.884C411.137 287.651 410.609 286.154 410.609 284.393V111.909C410.609 109.972 411.137 108.475 412.194 107.418C413.25 106.186 414.747 105.569 416.684 105.569H428.835C430.948 105.569 432.532 106.098 433.589 107.154C434.646 108.035 435.262 108.739 435.438 109.267L523.133 243.98V111.909C523.133 109.972 523.662 108.475 524.718 107.418C525.775 106.186 527.271 105.569 529.208 105.569H541.887C543.824 105.569 545.321 106.186 546.378 107.418C547.61 108.475 548.227 109.972 548.227 111.909V284.129C548.227 285.89 547.61 287.387 546.378 288.62C545.321 289.852 543.912 290.469 542.151 290.469H529.473C527.536 290.469 526.039 289.94 524.982 288.884C524.102 287.827 523.485 287.123 523.133 286.771L435.702 153.379V284.393C435.702 286.154 435.086 287.651 433.853 288.884C432.797 289.94 431.3 290.469 429.363 290.469H416.684Z" fill="#EDEFF0"/>
<path d="M636.275 293.11C622.892 293.11 611.71 290.557 602.729 285.45C593.748 280.343 586.88 273.299 582.126 264.319C577.371 255.162 574.73 244.772 574.201 233.15C574.025 230.156 573.937 226.37 573.937 221.792C573.937 217.037 574.025 213.251 574.201 210.434C574.73 198.635 577.371 188.246 582.126 179.265C587.056 170.284 594.012 163.24 602.993 158.133C611.974 153.027 623.068 150.473 636.275 150.473C649.482 150.473 660.576 153.027 669.557 158.133C678.537 163.24 685.405 170.284 690.16 179.265C695.09 188.246 697.82 198.635 698.348 210.434C698.524 213.251 698.612 217.037 698.612 221.792C698.612 226.37 698.524 230.156 698.348 233.15C697.82 244.772 695.178 255.162 690.424 264.319C685.669 273.299 678.802 280.343 669.821 285.45C660.84 290.557 649.658 293.11 636.275 293.11ZM636.275 272.771C647.193 272.771 655.909 269.337 662.425 262.47C668.94 255.426 672.462 245.212 672.99 231.829C673.167 229.188 673.255 225.842 673.255 221.792C673.255 217.742 673.167 214.396 672.99 211.754C672.462 198.371 668.94 188.246 662.425 181.378C655.909 174.334 647.193 170.812 636.275 170.812C625.357 170.812 616.552 174.334 609.861 181.378C603.345 188.246 599.911 198.371 599.559 211.754C599.383 214.396 599.295 217.742 599.295 221.792C599.295 225.842 599.383 229.188 599.559 231.829C599.911 245.212 603.345 255.426 609.861 262.47C616.552 269.337 625.357 272.771 636.275 272.771Z" fill="#EDEFF0"/>
<path d="M781.377 290.469C771.516 290.469 763.504 288.62 757.341 284.922C751.177 281.048 746.687 275.677 743.869 268.809C741.052 261.765 739.643 253.489 739.643 243.98V174.246H719.04C717.279 174.246 715.782 173.718 714.549 172.661C713.493 171.429 712.965 169.932 712.965 168.171V159.19C712.965 157.429 713.493 156.02 714.549 154.964C715.782 153.731 717.279 153.115 719.04 153.115H739.643V109.003C739.643 107.242 740.171 105.833 741.228 104.777C742.461 103.544 743.957 102.928 745.718 102.928H758.133C759.894 102.928 761.303 103.544 762.359 104.777C763.592 105.833 764.208 107.242 764.208 109.003V153.115H796.962C798.723 153.115 800.132 153.731 801.188 154.964C802.421 156.02 803.037 157.429 803.037 159.19V168.171C803.037 169.932 802.421 171.429 801.188 172.661C800.132 173.718 798.723 174.246 796.962 174.246H764.208V242.131C764.208 250.407 765.617 256.923 768.434 261.677C771.252 266.432 776.271 268.809 783.491 268.809H799.603C801.364 268.809 802.773 269.425 803.83 270.658C805.062 271.715 805.678 273.123 805.678 274.884V284.393C805.678 286.154 805.062 287.651 803.83 288.884C802.773 289.94 801.364 290.469 799.603 290.469H781.377Z" fill="#EDEFF0"/>
<path d="M834.371 290.469C832.61 290.469 831.113 289.94 829.88 288.884C828.824 287.651 828.296 286.154 828.296 284.393V159.19C828.296 157.429 828.824 156.02 829.88 154.964C831.113 153.731 832.61 153.115 834.371 153.115H847.05C848.811 153.115 850.219 153.731 851.276 154.964C852.333 156.02 852.861 157.429 852.861 159.19V284.393C852.861 286.154 852.333 287.651 851.276 288.884C850.219 289.94 848.811 290.469 847.05 290.469H834.371ZM832.258 127.493C830.497 127.493 829 126.965 827.767 125.908C826.711 124.675 826.182 123.179 826.182 121.418V107.154C826.182 105.393 826.711 103.984 827.767 102.928C829 101.695 830.497 101.079 832.258 101.079H848.899C850.66 101.079 852.068 101.695 853.125 102.928C854.358 103.984 854.974 105.393 854.974 107.154V121.418C854.974 123.179 854.358 124.675 853.125 125.908C852.068 126.965 850.66 127.493 848.899 127.493H832.258Z" fill="#EDEFF0"/>
<path d="M912.078 290.469C910.317 290.469 908.82 289.94 907.588 288.884C906.531 287.651 906.003 286.154 906.003 284.393V174.246H883.551C881.79 174.246 880.293 173.718 879.06 172.661C878.004 171.429 877.476 169.932 877.476 168.171V159.19C877.476 157.429 878.004 156.02 879.06 154.964C880.293 153.731 881.79 153.115 883.551 153.115H906.003V139.908C906.003 130.927 907.5 123.091 910.493 116.399C913.487 109.531 918.242 104.249 924.757 100.551C931.449 96.8525 940.341 95.0036 951.435 95.0036H967.284C969.045 95.0036 970.454 95.6199 971.51 96.8525C972.567 97.9091 973.095 99.3179 973.095 101.079V110.06C973.095 111.821 972.567 113.317 971.51 114.55C970.454 115.607 969.045 116.135 967.284 116.135H951.964C943.687 116.135 938.052 118.336 935.059 122.738C932.065 126.965 930.568 133.128 930.568 141.228V153.115H964.642C966.403 153.115 967.812 153.731 968.869 154.964C969.925 156.02 970.454 157.429 970.454 159.19V168.171C970.454 169.932 969.925 171.429 968.869 172.661C967.812 173.718 966.403 174.246 964.642 174.246H930.568V284.393C930.568 286.154 929.952 287.651 928.719 288.884C927.663 289.94 926.254 290.469 924.493 290.469H912.078Z" fill="#EDEFF0"/>
<path d="M1017.94 340.656C1016.53 340.656 1015.3 340.127 1014.24 339.071C1013.18 338.014 1012.65 336.782 1012.65 335.373C1012.65 334.668 1012.74 333.964 1012.92 333.26C1013.09 332.555 1013.45 331.675 1013.98 330.618L1034.31 282.28L983.863 163.152C982.982 161.039 982.542 159.542 982.542 158.662C982.542 157.077 983.071 155.756 984.127 154.7C985.184 153.643 986.504 153.115 988.089 153.115H1001.03C1002.79 153.115 1004.2 153.555 1005.26 154.435C1006.31 155.316 1007.02 156.373 1007.37 157.605L1047.52 253.753L1088.73 157.605C1089.26 156.373 1089.96 155.316 1090.84 154.435C1091.9 153.555 1093.39 153.115 1095.33 153.115H1107.75C1109.33 153.115 1110.65 153.643 1111.71 154.7C1112.76 155.756 1113.29 156.989 1113.29 158.398C1113.29 159.278 1112.85 160.863 1111.97 163.152L1036.96 336.165C1036.43 337.398 1035.63 338.454 1034.58 339.335C1033.7 340.215 1032.29 340.656 1030.35 340.656H1017.94Z" fill="#EDEFF0"/>
<path d="M1128.65 290.469C1126.89 290.469 1125.39 289.94 1124.16 288.884C1123.1 287.651 1122.57 286.154 1122.57 284.393V265.375C1122.57 263.614 1123.1 262.205 1124.16 261.149C1125.39 259.916 1126.89 259.3 1128.65 259.3H1147.67C1149.43 259.3 1150.84 259.916 1151.89 261.149C1153.13 262.205 1153.74 263.614 1153.74 265.375V284.393C1153.74 286.154 1153.13 287.651 1151.89 288.884C1150.84 289.94 1149.43 290.469 1147.67 290.469H1128.65Z" fill="#EDEFF0"/>
<path d="M1244.66 348.58C1232.68 348.58 1222.82 346.995 1215.07 343.825C1207.5 340.656 1201.51 336.782 1197.11 332.203C1192.89 327.801 1189.8 323.31 1187.87 318.732C1186.11 314.329 1185.14 310.808 1184.96 308.166C1184.79 306.405 1185.31 304.908 1186.55 303.676C1187.96 302.443 1189.45 301.827 1191.04 301.827H1203.72C1205.3 301.827 1206.62 302.179 1207.68 302.883C1208.73 303.588 1209.61 305.084 1210.32 307.374C1211.38 310.015 1212.96 312.921 1215.07 316.09C1217.19 319.436 1220.44 322.254 1224.85 324.543C1229.25 327.008 1235.41 328.241 1243.34 328.241C1251.61 328.241 1258.57 327.096 1264.2 324.807C1269.84 322.694 1274.07 318.82 1276.88 313.185C1279.88 307.726 1281.37 300.154 1281.37 290.469V272.507C1277.15 277.966 1271.6 282.544 1264.73 286.242C1257.86 289.764 1249.24 291.525 1238.85 291.525C1228.99 291.525 1220.53 289.852 1213.49 286.507C1206.45 282.985 1200.63 278.318 1196.06 272.507C1191.65 266.52 1188.31 259.74 1186.02 252.168C1183.91 244.596 1182.67 236.672 1182.32 228.395C1182.14 223.465 1182.14 218.622 1182.32 213.867C1182.67 205.591 1183.91 197.667 1186.02 190.095C1188.31 182.523 1191.65 175.743 1196.06 169.756C1200.63 163.769 1206.45 159.102 1213.49 155.756C1220.53 152.234 1228.99 150.473 1238.85 150.473C1249.41 150.473 1258.13 152.498 1265 156.549C1272.04 160.423 1277.76 165.177 1282.17 170.812V159.454C1282.17 157.693 1282.69 156.196 1283.75 154.964C1284.98 153.731 1286.48 153.115 1288.24 153.115H1300.39C1302.15 153.115 1303.65 153.731 1304.88 154.964C1306.11 156.196 1306.73 157.693 1306.73 159.454V287.563C1306.73 299.361 1304.71 309.839 1300.66 318.996C1296.78 328.153 1290.27 335.373 1281.11 340.656C1272.13 345.938 1259.98 348.58 1244.66 348.58ZM1244.13 270.394C1252.93 270.394 1259.98 268.369 1265.26 264.319C1270.72 260.268 1274.68 255.162 1277.15 248.998C1279.79 242.835 1281.2 236.584 1281.37 230.244C1281.55 227.779 1281.64 224.785 1281.64 221.263C1281.64 217.565 1281.55 214.484 1281.37 212.018C1281.2 205.679 1279.79 199.428 1277.15 193.264C1274.68 187.101 1270.72 181.994 1265.26 177.944C1259.98 173.894 1252.93 171.869 1244.13 171.869C1235.32 171.869 1228.28 173.894 1223 177.944C1217.72 181.818 1213.93 187.013 1211.64 193.529C1209.35 199.868 1208.03 206.736 1207.68 214.132C1207.5 218.71 1207.5 223.377 1207.68 228.131C1208.03 235.527 1209.35 242.483 1211.64 248.998C1213.93 255.338 1217.72 260.533 1223 264.583C1228.28 268.457 1235.32 270.394 1244.13 270.394Z" fill="#EDEFF0"/>
<path d="M1394.34 293.11C1380.96 293.11 1369.78 290.557 1360.8 285.45C1351.81 280.343 1344.95 273.299 1340.19 264.319C1335.44 255.162 1332.8 244.772 1332.27 233.15C1332.09 230.156 1332 226.37 1332 221.792C1332 217.037 1332.09 213.251 1332.27 210.434C1332.8 198.635 1335.44 188.246 1340.19 179.265C1345.12 170.284 1352.08 163.24 1361.06 158.133C1370.04 153.027 1381.13 150.473 1394.34 150.473C1407.55 150.473 1418.64 153.027 1427.62 158.133C1436.6 163.24 1443.47 170.284 1448.23 179.265C1453.16 188.246 1455.89 198.635 1456.42 210.434C1456.59 213.251 1456.68 217.037 1456.68 221.792C1456.68 226.37 1456.59 230.156 1456.42 233.15C1455.89 244.772 1453.25 255.162 1448.49 264.319C1443.74 273.299 1436.87 280.343 1427.89 285.45C1418.91 290.557 1407.72 293.11 1394.34 293.11ZM1394.34 272.771C1405.26 272.771 1413.98 269.337 1420.49 262.47C1427.01 255.426 1430.53 245.212 1431.06 231.829C1431.23 229.188 1431.32 225.842 1431.32 221.792C1431.32 217.742 1431.23 214.396 1431.06 211.754C1430.53 198.371 1427.01 188.246 1420.49 181.378C1413.98 174.334 1405.26 170.812 1394.34 170.812C1383.42 170.812 1374.62 174.334 1367.93 181.378C1361.41 188.246 1357.98 198.371 1357.63 211.754C1357.45 214.396 1357.36 217.742 1357.36 221.792C1357.36 225.842 1357.45 229.188 1357.63 231.829C1357.98 245.212 1361.41 255.426 1367.93 262.47C1374.62 269.337 1383.42 272.771 1394.34 272.771Z" fill="#EDEFF0"/>
<path d="M1524.74 290.469C1522.1 290.469 1519.98 289.852 1518.4 288.62C1516.99 287.387 1515.85 285.802 1514.97 283.865L1466.1 161.303C1465.75 160.423 1465.57 159.542 1465.57 158.662C1465.57 157.077 1466.1 155.756 1467.16 154.7C1468.21 153.643 1469.53 153.115 1471.12 153.115H1483.8C1485.73 153.115 1487.23 153.643 1488.29 154.7C1489.34 155.756 1489.96 156.725 1490.14 157.605L1530.55 261.677L1570.7 157.605C1571.05 156.725 1571.67 155.756 1572.55 154.7C1573.61 153.643 1575.1 153.115 1577.04 153.115H1589.98C1591.39 153.115 1592.62 153.643 1593.68 154.7C1594.91 155.756 1595.53 157.077 1595.53 158.662C1595.53 159.542 1595.35 160.423 1595 161.303L1545.87 283.865C1545.17 285.802 1544.02 287.387 1542.44 288.62C1541.03 289.852 1538.91 290.469 1536.1 290.469H1524.74Z" fill="#EDEFF0"/>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -1,17 +0,0 @@
<svg width="1596" height="400" viewBox="0 0 1596 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M155.013 400V353.26H100.759C104.479 393.912 138.737 400 155.013 400Z" fill="#2491FF"/>
<path d="M155.013 400V353.26H210.818C206.992 393.912 171.755 400 155.013 400Z" fill="#005EA2"/>
<path d="M124.326 33.9452C124.326 5.83608 143.21 0 155.014 0V337.68H0V304.105L38.5573 267.407V151.847C38.5573 83.1361 93.6378 42.0136 124.326 33.9452Z" fill="#2491FF"/>
<path d="M155.014 0C166.817 0 185.702 5.83608 185.702 33.9452C195.119 36.4212 206.833 42.0098 218.548 50.5533C205.923 63.1111 198.107 80.4997 198.107 99.7136C198.107 138.008 229.151 169.051 267.445 169.051C268.796 169.051 270.075 169.127 271.407 169.051L271.47 267.407L310.027 304.105V337.68H155.014V0Z" fill="#005EA2"/>
<path d="M324.236 99.3973C324.236 130.397 299.105 155.528 268.105 155.528C237.105 155.528 211.975 130.397 211.975 99.3973C211.975 68.3975 237.105 43.2672 268.105 43.2672C299.105 43.2672 324.236 68.3975 324.236 99.3973Z" fill="#D54309"/>
<path d="M416.686 290.469C414.749 290.469 413.252 289.94 412.195 288.884C411.139 287.651 410.61 286.154 410.61 284.393V111.909C410.61 109.972 411.139 108.475 412.195 107.418C413.252 106.186 414.749 105.569 416.686 105.569H428.836C430.95 105.569 432.534 106.098 433.591 107.154C434.647 108.035 435.264 108.739 435.44 109.267L523.135 243.98V111.909C523.135 109.972 523.664 108.475 524.72 107.418C525.777 106.186 527.274 105.569 529.211 105.569H541.89C543.827 105.569 545.323 106.186 546.38 107.418C547.613 108.475 548.229 109.972 548.229 111.909V284.129C548.229 285.89 547.613 287.387 546.38 288.62C545.323 289.852 543.915 290.469 542.154 290.469H529.475C527.538 290.469 526.041 289.94 524.984 288.884C524.104 287.827 523.488 287.123 523.135 286.771L435.704 153.379V284.393C435.704 286.154 435.088 287.651 433.855 288.884C432.798 289.94 431.302 290.469 429.365 290.469H416.686Z" fill="#2D2E2F"/>
<path d="M636.277 293.11C622.894 293.11 611.712 290.557 602.731 285.45C593.75 280.343 586.883 273.299 582.128 264.319C577.373 255.162 574.732 244.772 574.204 233.15C574.028 230.156 573.94 226.37 573.94 221.792C573.94 217.037 574.028 213.251 574.204 210.434C574.732 198.635 577.373 188.246 582.128 179.265C587.059 170.284 594.014 163.24 602.995 158.133C611.976 153.027 623.07 150.473 636.277 150.473C649.485 150.473 660.579 153.027 669.559 158.133C678.54 163.24 685.408 170.284 690.163 179.265C695.093 188.246 697.823 198.635 698.351 210.434C698.527 213.251 698.615 217.037 698.615 221.792C698.615 226.37 698.527 230.156 698.351 233.15C697.823 244.772 695.181 255.162 690.427 264.319C685.672 273.299 678.804 280.343 669.824 285.45C660.843 290.557 649.661 293.11 636.277 293.11ZM636.277 272.771C647.195 272.771 655.912 269.337 662.428 262.47C668.943 255.426 672.465 245.212 672.993 231.829C673.169 229.188 673.257 225.842 673.257 221.792C673.257 217.742 673.169 214.396 672.993 211.754C672.465 198.371 668.943 188.246 662.428 181.378C655.912 174.334 647.195 170.812 636.277 170.812C625.359 170.812 616.555 174.334 609.863 181.378C603.348 188.246 599.914 198.371 599.562 211.754C599.385 214.396 599.297 217.742 599.297 221.792C599.297 225.842 599.385 229.188 599.562 231.829C599.914 245.212 603.348 255.426 609.863 262.47C616.555 269.337 625.359 272.771 636.277 272.771Z" fill="#2D2E2F"/>
<path d="M781.381 290.469C771.519 290.469 763.507 288.62 757.344 284.922C751.18 281.048 746.69 275.677 743.872 268.809C741.055 261.765 739.646 253.489 739.646 243.98V174.246H719.043C717.282 174.246 715.785 173.718 714.552 172.661C713.496 171.429 712.968 169.932 712.968 168.171V159.19C712.968 157.429 713.496 156.02 714.552 154.964C715.785 153.731 717.282 153.115 719.043 153.115H739.646V109.003C739.646 107.242 740.174 105.833 741.231 104.777C742.464 103.544 743.96 102.928 745.721 102.928H758.136C759.897 102.928 761.306 103.544 762.362 104.777C763.595 105.833 764.211 107.242 764.211 109.003V153.115H796.965C798.726 153.115 800.135 153.731 801.191 154.964C802.424 156.02 803.04 157.429 803.04 159.19V168.171C803.04 169.932 802.424 171.429 801.191 172.661C800.135 173.718 798.726 174.246 796.965 174.246H764.211V242.131C764.211 250.407 765.62 256.923 768.438 261.677C771.255 266.432 776.274 268.809 783.494 268.809H799.607C801.368 268.809 802.776 269.425 803.833 270.658C805.066 271.715 805.682 273.123 805.682 274.884V284.393C805.682 286.154 805.066 287.651 803.833 288.884C802.776 289.94 801.368 290.469 799.607 290.469H781.381Z" fill="#2D2E2F"/>
<path d="M834.374 290.469C832.613 290.469 831.117 289.94 829.884 288.884C828.827 287.651 828.299 286.154 828.299 284.393V159.19C828.299 157.429 828.827 156.02 829.884 154.964C831.117 153.731 832.613 153.115 834.374 153.115H847.053C848.814 153.115 850.223 153.731 851.28 154.964C852.336 156.02 852.864 157.429 852.864 159.19V284.393C852.864 286.154 852.336 287.651 851.28 288.884C850.223 289.94 848.814 290.469 847.053 290.469H834.374ZM832.261 127.493C830.5 127.493 829.004 126.965 827.771 125.908C826.714 124.675 826.186 123.179 826.186 121.418V107.154C826.186 105.393 826.714 103.984 827.771 102.928C829.004 101.695 830.5 101.079 832.261 101.079H848.902C850.663 101.079 852.072 101.695 853.129 102.928C854.361 103.984 854.978 105.393 854.978 107.154V121.418C854.978 123.179 854.361 124.675 853.129 125.908C852.072 126.965 850.663 127.493 848.902 127.493H832.261Z" fill="#2D2E2F"/>
<path d="M912.082 290.469C910.321 290.469 908.824 289.94 907.592 288.884C906.535 287.651 906.007 286.154 906.007 284.393V174.246H883.555C881.794 174.246 880.297 173.718 879.064 172.661C878.008 171.429 877.479 169.932 877.479 168.171V159.19C877.479 157.429 878.008 156.02 879.064 154.964C880.297 153.731 881.794 153.115 883.555 153.115H906.007V139.908C906.007 130.927 907.504 123.091 910.497 116.399C913.491 109.531 918.245 104.249 924.761 100.551C931.453 96.8525 940.345 95.0035 951.439 95.0035H967.288C969.049 95.0035 970.458 95.6199 971.514 96.8525C972.571 97.9091 973.099 99.3179 973.099 101.079V110.06C973.099 111.821 972.571 113.317 971.514 114.55C970.458 115.607 969.049 116.135 967.288 116.135H951.968C943.691 116.135 938.056 118.336 935.062 122.738C932.069 126.965 930.572 133.128 930.572 141.228V153.115H964.646C966.407 153.115 967.816 153.731 968.873 154.964C969.929 156.02 970.458 157.429 970.458 159.19V168.171C970.458 169.932 969.929 171.429 968.873 172.661C967.816 173.718 966.407 174.246 964.646 174.246H930.572V284.393C930.572 286.154 929.956 287.651 928.723 288.884C927.667 289.94 926.258 290.469 924.497 290.469H912.082Z" fill="#2D2E2F"/>
<path d="M1017.94 340.656C1016.53 340.656 1015.3 340.127 1014.24 339.071C1013.19 338.014 1012.66 336.782 1012.66 335.373C1012.66 334.668 1012.75 333.964 1012.92 333.26C1013.1 332.555 1013.45 331.675 1013.98 330.618L1034.32 282.28L983.867 163.152C982.987 161.039 982.546 159.542 982.546 158.662C982.546 157.077 983.075 155.756 984.131 154.7C985.188 153.643 986.509 153.115 988.093 153.115H1001.04C1002.8 153.115 1004.21 153.555 1005.26 154.435C1006.32 155.316 1007.02 156.373 1007.38 157.605L1047.53 253.753L1088.73 157.605C1089.26 156.373 1089.96 155.316 1090.84 154.435C1091.9 153.555 1093.4 153.115 1095.34 153.115H1107.75C1109.33 153.115 1110.66 153.643 1111.71 154.7C1112.77 155.756 1113.3 156.989 1113.3 158.398C1113.3 159.278 1112.86 160.863 1111.98 163.152L1036.96 336.165C1036.43 337.398 1035.64 338.454 1034.58 339.335C1033.7 340.215 1032.29 340.656 1030.36 340.656H1017.94Z" fill="#2D2E2F"/>
<path d="M1128.65 290.469C1126.89 290.469 1125.4 289.94 1124.16 288.884C1123.11 287.651 1122.58 286.154 1122.58 284.393V265.375C1122.58 263.614 1123.11 262.205 1124.16 261.149C1125.4 259.916 1126.89 259.3 1128.65 259.3H1147.67C1149.43 259.3 1150.84 259.916 1151.9 261.149C1153.13 262.205 1153.75 263.614 1153.75 265.375V284.393C1153.75 286.154 1153.13 287.651 1151.9 288.884C1150.84 289.94 1149.43 290.469 1147.67 290.469H1128.65Z" fill="#2D2E2F"/>
<path d="M1244.66 348.58C1232.69 348.58 1222.83 346.995 1215.08 343.825C1207.51 340.656 1201.52 336.782 1197.12 332.203C1192.89 327.801 1189.81 323.31 1187.87 318.732C1186.11 314.329 1185.14 310.808 1184.97 308.166C1184.79 306.405 1185.32 304.908 1186.55 303.676C1187.96 302.443 1189.46 301.827 1191.04 301.827H1203.72C1205.31 301.827 1206.63 302.179 1207.68 302.883C1208.74 303.588 1209.62 305.084 1210.32 307.374C1211.38 310.015 1212.97 312.921 1215.08 316.09C1217.19 319.436 1220.45 322.254 1224.85 324.543C1229.25 327.008 1235.42 328.241 1243.34 328.241C1251.62 328.241 1258.57 327.096 1264.21 324.807C1269.84 322.694 1274.07 318.82 1276.89 313.185C1279.88 307.726 1281.38 300.154 1281.38 290.469V272.507C1277.15 277.966 1271.61 282.544 1264.74 286.242C1257.87 289.764 1249.24 291.525 1238.85 291.525C1228.99 291.525 1220.54 289.852 1213.49 286.507C1206.45 282.985 1200.64 278.318 1196.06 272.507C1191.66 266.52 1188.31 259.74 1186.02 252.168C1183.91 244.596 1182.68 236.672 1182.33 228.395C1182.15 223.465 1182.15 218.622 1182.33 213.867C1182.68 205.591 1183.91 197.667 1186.02 190.095C1188.31 182.523 1191.66 175.743 1196.06 169.756C1200.64 163.769 1206.45 159.102 1213.49 155.756C1220.54 152.234 1228.99 150.473 1238.85 150.473C1249.42 150.473 1258.13 152.498 1265 156.549C1272.05 160.423 1277.77 165.177 1282.17 170.812V159.454C1282.17 157.693 1282.7 156.196 1283.76 154.964C1284.99 153.731 1286.49 153.115 1288.25 153.115H1300.4C1302.16 153.115 1303.65 153.731 1304.89 154.964C1306.12 156.196 1306.74 157.693 1306.74 159.454V287.563C1306.74 299.361 1304.71 309.839 1300.66 318.996C1296.79 328.153 1290.27 335.373 1281.11 340.656C1272.13 345.938 1259.98 348.58 1244.66 348.58ZM1244.13 270.394C1252.94 270.394 1259.98 268.369 1265.27 264.319C1270.73 260.268 1274.69 255.162 1277.15 248.998C1279.79 242.835 1281.2 236.584 1281.38 230.244C1281.55 227.779 1281.64 224.785 1281.64 221.263C1281.64 217.565 1281.55 214.484 1281.38 212.018C1281.2 205.679 1279.79 199.428 1277.15 193.264C1274.69 187.101 1270.73 181.994 1265.27 177.944C1259.98 173.894 1252.94 171.869 1244.13 171.869C1235.33 171.869 1228.29 173.894 1223 177.944C1217.72 181.818 1213.93 187.013 1211.65 193.529C1209.36 199.868 1208.04 206.736 1207.68 214.132C1207.51 218.71 1207.51 223.377 1207.68 228.131C1208.04 235.527 1209.36 242.483 1211.65 248.998C1213.93 255.338 1217.72 260.533 1223 264.583C1228.29 268.457 1235.33 270.394 1244.13 270.394Z" fill="#2D2E2F"/>
<path d="M1394.35 293.11C1380.96 293.11 1369.78 290.557 1360.8 285.45C1351.82 280.343 1344.95 273.299 1340.2 264.319C1335.44 255.162 1332.8 244.772 1332.27 233.15C1332.1 230.156 1332.01 226.37 1332.01 221.792C1332.01 217.037 1332.1 213.251 1332.27 210.434C1332.8 198.635 1335.44 188.246 1340.2 179.265C1345.13 170.284 1352.08 163.24 1361.07 158.133C1370.05 153.027 1381.14 150.473 1394.35 150.473C1407.55 150.473 1418.65 153.027 1427.63 158.133C1436.61 163.24 1443.48 170.284 1448.23 179.265C1453.16 188.246 1455.89 198.635 1456.42 210.434C1456.6 213.251 1456.69 217.037 1456.69 221.792C1456.69 226.37 1456.6 230.156 1456.42 233.15C1455.89 244.772 1453.25 255.162 1448.5 264.319C1443.74 273.299 1436.87 280.343 1427.89 285.45C1418.91 290.557 1407.73 293.11 1394.35 293.11ZM1394.35 272.771C1405.27 272.771 1413.98 269.337 1420.5 262.47C1427.01 255.426 1430.53 245.212 1431.06 231.829C1431.24 229.188 1431.33 225.842 1431.33 221.792C1431.33 217.742 1431.24 214.396 1431.06 211.754C1430.53 198.371 1427.01 188.246 1420.5 181.378C1413.98 174.334 1405.27 170.812 1394.35 170.812C1383.43 170.812 1374.62 174.334 1367.93 181.378C1361.42 188.246 1357.98 198.371 1357.63 211.754C1357.46 214.396 1357.37 217.742 1357.37 221.792C1357.37 225.842 1357.46 229.188 1357.63 231.829C1357.98 245.212 1361.42 255.426 1367.93 262.47C1374.62 269.337 1383.43 272.771 1394.35 272.771Z" fill="#2D2E2F"/>
<path d="M1524.75 290.469C1522.1 290.469 1519.99 289.852 1518.41 288.62C1517 287.387 1515.85 285.802 1514.97 283.865L1466.11 161.303C1465.75 160.423 1465.58 159.542 1465.58 158.662C1465.58 157.077 1466.11 155.756 1467.16 154.7C1468.22 153.643 1469.54 153.115 1471.12 153.115H1483.8C1485.74 153.115 1487.24 153.643 1488.29 154.7C1489.35 155.756 1489.97 156.725 1490.14 157.605L1530.56 261.677L1570.71 157.605C1571.06 156.725 1571.67 155.756 1572.56 154.7C1573.61 153.643 1575.11 153.115 1577.05 153.115H1589.99C1591.4 153.115 1592.63 153.643 1593.69 154.7C1594.92 155.756 1595.54 157.077 1595.54 158.662C1595.54 159.542 1595.36 160.423 1595.01 161.303L1545.88 283.865C1545.17 285.802 1544.03 287.387 1542.44 288.62C1541.03 289.852 1538.92 290.469 1536.1 290.469H1524.75Z" fill="#2D2E2F"/>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -1,3 +0,0 @@
<svg viewBox="0 0 563 670" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M553.831 334.751V335.974L555.03 335.731C555.104 335.716 555.181 335.708 555.262 335.708H557.846C558.483 335.708 559 336.224 559 336.862V418.708C559 419.345 558.483 419.862 557.846 419.862H555.262C555.181 419.862 555.104 419.853 555.03 419.838L553.831 419.595V420.818V955.446C553.831 970.12 547.528 991.015 535.312 1008.3C522.978 1025.76 503.794 1040.6 477.723 1040.6H82.2769C56.206 1040.6 37.022 1025.76 24.6885 1008.3C12.472 991.015 6.16923 970.12 6.16923 955.446V420.818V419.595L4.97026 419.838C4.89621 419.853 4.8188 419.862 4.73846 419.862H2.15385C1.51659 419.862 1 419.345 1 418.708V358.4C1 357.763 1.51659 357.246 2.15385 357.246H4.73846C4.8188 357.246 4.89621 357.254 4.97026 357.269L6.16923 357.513V356.289V347.588V346.364L4.97026 346.608C4.89621 346.623 4.8188 346.631 4.73846 346.631H2.15385C1.51659 346.631 1 346.114 1 345.477V285.169C1 284.532 1.51659 284.015 2.15385 284.015H4.73846C4.8188 284.015 4.89621 284.023 4.97026 284.038L6.16923 284.282V283.058V261.434V260.21L4.97026 260.454C4.89621 260.469 4.8188 260.477 4.73846 260.477H2.15385C1.51659 260.477 1 259.96 1 259.323V220.554C1 219.917 1.51659 219.4 2.15385 219.4H4.73846C4.8188 219.4 4.89624 219.408 4.97036 219.423L6.16923 219.666V218.443V86.1538C6.16923 71.4804 12.472 50.5848 24.6885 33.2974C37.022 15.8443 56.206 1 82.2769 1H477.723C503.794 1 522.978 15.8443 535.312 33.2974C547.528 50.5848 553.831 71.4804 553.831 86.1538V334.751ZM400.615 16.2308H399.615V17.2308V43.0769C399.615 49.6619 394.277 55 387.692 55H172.308C165.723 55 160.385 49.6619 160.385 43.0769V17.2308V16.2308H159.385H82.2769C62.6883 16.2308 47.6257 27.2304 37.1269 42.0872C26.5125 57.1076 21.4 74.9813 21.4 86.1538V955.446C21.4 966.619 26.5125 984.492 37.1269 999.513C47.6257 1014.37 62.6883 1025.37 82.2769 1025.37H477.723C497.312 1025.37 512.374 1014.37 522.873 999.513C533.487 984.492 538.6 966.619 538.6 955.446V86.1538C538.6 74.9813 533.488 57.1076 522.873 42.0872C512.374 27.2304 497.312 16.2308 477.723 16.2308H400.615Z" stroke="black" stroke-width="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -1,24 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="260.000000pt" height="260.000000pt" viewBox="0 0 260.000000 260.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,260.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1177 2586 c-67 -29 -117 -107 -117 -183 0 -12 -15 -24 -48 -37 -175
-69 -342 -229 -426 -406 -74 -156 -77 -183 -78 -657 l-1 -423 -123 -116 -124
-117 0 -109 0 -108 996 2 997 3 -1 105 -1 105 -123 117 -123 118 -1 310 c0
170 -3 313 -7 316 -4 4 -26 8 -50 9 -105 6 -216 59 -292 140 -152 161 -161
417 -22 583 35 42 31 48 -71 97 -42 20 -84 41 -92 45 -8 4 -18 28 -22 54 -14
100 -80 158 -182 162 -34 1 -74 -4 -89 -10z"/>
<path d="M1854 2297 c-64 -28 -95 -48 -134 -89 -65 -68 -99 -163 -94 -265 11
-271 317 -431 547 -286 163 103 215 311 122 482 -35 64 -91 114 -165 149 -77
37 -202 41 -276 9z"/>
<path d="M910 282 c0 -32 32 -116 59 -154 114 -161 427 -170 566 -16 42 47 75
119 75 165 l0 23 -350 0 c-332 0 -350 -1 -350 -18z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,19 +1 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-256x256.png",
"sizes": "256x256",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -1 +0,0 @@
<svg id="Capa_1" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><g><g><path d="m291.532 66.273c4.142 0 7.5-3.357 7.5-7.5v-.057c0-4.143-3.358-7.472-7.5-7.472s-7.5 3.386-7.5 7.528 3.358 7.501 7.5 7.501z"/><path d="m254.455 66.273c4.142 0 7.5-3.357 7.5-7.5v-.057c0-4.143-3.358-7.472-7.5-7.472s-7.5 3.386-7.5 7.528 3.358 7.501 7.5 7.501z"/><path d="m217.378 66.273c4.142 0 7.5-3.357 7.5-7.5v-.057c0-4.143-3.358-7.472-7.5-7.472s-7.5 3.386-7.5 7.528 3.358 7.501 7.5 7.501z"/><path d="m123.925 51.273-76.763-.057c-.002 0-.003 0-.005 0-4.14 0-7.497 3.354-7.5 7.494-.003 4.143 3.353 7.503 7.495 7.506l76.763.057h.005c4.14 0 7.497-3.354 7.5-7.494.002-4.142-3.353-7.503-7.495-7.506z"/><path d="m287.507 124.863h-111.062c-6.933 0-12.574 5.641-12.574 12.574v33.479c0 6.948 5.641 12.602 12.574 12.602h111.062c6.933 0 12.574-5.653 12.574-12.602v-33.479c0-6.933-5.641-12.574-12.574-12.574zm-2.426 43.655h-106.209v-28.654h106.209z"/><path d="m292.581 210.217h-121.209c-4.142 0-7.5 3.357-7.5 7.5s3.358 7.5 7.5 7.5h121.209c4.142 0 7.5-3.357 7.5-7.5s-3.358-7.5-7.5-7.5z"/><path d="m189.882 245.736h-18.51c-4.142 0-7.5 3.357-7.5 7.5s3.358 7.5 7.5 7.5h18.51c4.142 0 7.5-3.357 7.5-7.5s-3.358-7.5-7.5-7.5z"/><path d="m292.581 245.736h-62.05c-4.142 0-7.5 3.357-7.5 7.5s3.358 7.5 7.5 7.5h62.05c4.142 0 7.5-3.357 7.5-7.5s-3.358-7.5-7.5-7.5z"/><path d="m444.802 450.744h-.255c-4.142 0-7.5 3.357-7.5 7.5s3.358 7.5 7.5 7.5h.255c4.142 0 7.5-3.357 7.5-7.5s-3.358-7.5-7.5-7.5z"/><path d="m490.667 301.75h-59.114v-129.049c0-11.669-9.494-21.163-21.163-21.163h-71.7v-114.592c0-11.404-9.278-20.682-20.682-20.682h-297.327c-11.404.001-20.681 9.278-20.681 20.682v241.718c0 11.419 9.277 20.709 20.681 20.709h33.801v97.131c0 11.669 9.494 21.163 21.163 21.163h104.223l-8.866 29.646h-14.994c-13.342 0-24.196 10.867-24.196 24.226v11.707c0 6.887 5.603 12.489 12.489 12.489h197.434c6.887 0 12.489-5.603 12.489-12.489v-11.707c0-13.358-10.867-24.226-24.225-24.226h-14.991l-8.847-29.646h71.443v56.735c0 11.763 9.557 21.333 21.305 21.333h91.758c11.763 0 21.333-9.57 21.333-21.333v-151.346c0-11.748-9.57-21.306-21.333-21.306zm-475.667-23.086v-177.438h137.039c4.142 0 7.5-3.357 7.5-7.5s-3.358-7.5-7.5-7.5h-137.039v-49.28c0-3.133 2.548-5.682 5.681-5.682h297.327c3.133 0 5.682 2.549 5.682 5.682v49.279h-137.011c-4.142 0-7.5 3.357-7.5 7.5s3.358 7.5 7.5 7.5h137.011v177.438c0 3.147-2.549 5.709-5.682 5.709h-297.327c-3.133.001-5.681-2.56-5.681-5.708zm303.008 20.709c11.404 0 20.682-9.29 20.682-20.709v-112.126h71.7c3.398 0 6.163 2.765 6.163 6.163v129.049h-17.644c-11.748 0-21.305 9.558-21.305 21.306v41.683h-308.122v-65.365h248.526zm21.215 172.166v9.196h-192.411v-9.196c0-5.087 4.125-9.226 9.196-9.226h173.991c5.086 0 9.224 4.139 9.224 9.226zm-79.014-68.872c-4.142 0-7.5 3.357-7.5 7.5s3.358 7.5 7.5 7.5h30.298l8.847 29.646h-112.696l8.866-29.646h30.188c4.142 0 7.5-3.357 7.5-7.5s-3.358-7.5-7.5-7.5h-150.067c-3.398 0-6.163-2.765-6.163-6.163v-16.766h308.123v22.929zm236.791 71.735c0 3.492-2.841 6.333-6.333 6.333h-91.758c-3.477 0-6.305-2.841-6.305-6.333v-151.346c0-3.477 2.828-6.306 6.305-6.306h91.758c3.492 0 6.333 2.829 6.333 6.306z"/><path d="m127.661 124.863h-76.451c-6.949 0-12.603 5.641-12.603 12.574v110.696c0 6.949 5.653 12.603 12.603 12.603h76.451c6.949 0 12.602-5.653 12.602-12.603v-110.695c0-6.934-5.653-12.575-12.602-12.575zm-2.398 120.873h-71.655v-105.873h71.655z"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -1,313 +0,0 @@
(function (window) {
if (document.getElementById('activityChartContainer')) {
const COLORS = {
delivered: '#0076d6',
failed: '#fa9441',
text: '#666'
};
const FONT_SIZE = 16;
const FONT_WEIGHT = 'bold';
const MAX_Y = 120;
const createChart = function(containerId, labels, deliveredData, failedData) {
const container = d3.select(containerId);
container.selectAll('*').remove(); // Clear any existing content
const margin = { top: 60, right: 20, bottom: 40, left: 20 }; // Adjusted top margin for legend
const width = container.node().getBoundingClientRect().width - margin.left - margin.right;
const height = 400 - margin.top - margin.bottom;
const svg = container.append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
let tooltip = d3.select('#tooltip');
if (tooltip.empty()) {
tooltip = d3.select('body').append('div')
.attr('id', 'tooltip')
.style('display', 'none');
}
// Calculate total messages
const totalMessages = d3.sum(deliveredData) + d3.sum(failedData);
// Create legend only if there are messages
const legendContainer = d3.select('.chart-legend');
legendContainer.selectAll('*').remove(); // Clear any existing legend
if (totalMessages > 0) {
// Show legend if there are messages
const legendData = [
{ label: 'Delivered', color: COLORS.delivered },
{ label: 'Failed', color: COLORS.failed }
];
const legendItem = legendContainer.selectAll('.legend-item')
.data(legendData)
.enter()
.append('div')
.attr('class', 'legend-item');
legendItem.append('div')
.attr('class', 'legend-rect')
.style('background-color', d => d.color)
.style('display', 'inline-block')
.style('margin-right', '5px');
legendItem.append('span')
.attr('class', 'legend-label')
.text(d => d.label);
// Ensure the legend is shown
legendContainer.style('display', 'flex');
} else {
// Hide the legend if there are no messages
legendContainer.style('display', 'none');
}
const x = d3.scaleBand()
.domain(labels)
.range([0, width])
.padding(0.1);
// Adjust the y-axis domain to add some space above the tallest bar
const maxY = d3.max(deliveredData.map((d, i) => d + (failedData[i] || 0)));
const y = d3.scaleSqrt()
.domain([0, maxY + 2]) // Add 2 units of space at the top
.nice()
.range([height, 0]);
svg.append('g')
.attr('class', 'x axis')
.attr('transform', `translate(0,${height})`)
.call(d3.axisBottom(x));
// Generate the y-axis with whole numbers
const yAxis = d3.axisLeft(y)
.ticks(Math.min(maxY + 2, 10)) // Generate up to 10 ticks based on the data
.tickFormat(d3.format('d')); // Ensure whole numbers on the y-axis
svg.append('g')
.attr('class', 'y axis')
.call(yAxis);
// Data for stacking
const stackData = labels.map((label, i) => ({
label: label,
delivered: deliveredData[i],
failed: failedData[i] || 0 // Ensure there's a value for failed, even if it's 0
}));
// Stack the data
const stack = d3.stack()
.keys(['delivered', 'failed'])
.order(d3.stackOrderNone)
.offset(d3.stackOffsetNone);
const series = stack(stackData);
// Color scale
const color = d3.scaleOrdinal()
.domain(['delivered', 'failed'])
.range([COLORS.delivered, COLORS.failed]);
// Create bars with animation
const barGroups = svg.selectAll('.bar-group')
.data(series)
.enter()
.append('g')
.attr('class', 'bar-group')
.attr('fill', d => color(d.key));
barGroups.selectAll('rect')
.data(d => d)
.enter()
.append('rect')
.attr('x', d => x(d.data.label))
.attr('y', height)
.attr('height', 0)
.attr('width', x.bandwidth())
.on('mouseover', function(event, d) {
const key = d3.select(this.parentNode).datum().key;
const capitalizedKey = key.charAt(0).toUpperCase() + key.slice(1);
tooltip.style('display', 'block')
.html(`${d.data.label}<br>${capitalizedKey}: ${d.data[key]}`);
})
.on('mousemove', function(event) {
tooltip.style('left', `${event.pageX + 10}px`)
.style('top', `${event.pageY - 20}px`);
})
.on('mouseout', function() {
tooltip.style('display', 'none');
})
.transition()
.duration(1000)
.attr('y', d => y(d[1]))
.attr('height', d => y(d[0]) - y(d[1]));
};
// Function to create an accessible table
const createTable = function(tableId, chartType, labels, deliveredData, failedData) {
const table = document.getElementById(tableId);
table.innerHTML = ""; // Clear previous data
const captionText = document.querySelector(`#${chartType} .chart-subtitle`).textContent;
const caption = document.createElement('caption');
caption.textContent = captionText;
const thead = document.createElement('thead');
const tbody = document.createElement('tbody');
// Create table header
const headerRow = document.createElement('tr');
const headers = ['Day', 'Delivered', 'Failed'];
headers.forEach(headerText => {
const th = document.createElement('th');
th.textContent = headerText;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
// Create table body
labels.forEach((label, index) => {
const row = document.createElement('tr');
const cellDay = document.createElement('td');
cellDay.textContent = label;
row.appendChild(cellDay);
const cellDelivered = document.createElement('td');
cellDelivered.textContent = deliveredData[index];
row.appendChild(cellDelivered);
const cellFailed = document.createElement('td');
cellFailed.textContent = failedData[index];
row.appendChild(cellFailed);
tbody.appendChild(row);
});
table.appendChild(caption);
table.appendChild(thead);
table.append(tbody);
};
const fetchData = function(type) {
var ctx = document.getElementById('weeklyChart');
if (!ctx) {
return;
}
var url = type === 'service' ? `/daily_stats.json` : `/daily_stats_by_user.json`;
return fetch(url)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
labels = [];
deliveredData = [];
failedData = [];
let totalMessages = 0;
for (var dateString in data) {
if (data.hasOwnProperty(dateString)) {
const dateParts = dateString.split('-');
const formattedDate = `${dateParts[1]}/${dateParts[2]}/${dateParts[0].slice(2)}`;
labels.push(formattedDate);
deliveredData.push(data[dateString].sms.delivered);
failedData.push(data[dateString].sms.failure);
// Calculate the total number of messages
totalMessages += data[dateString].sms.delivered + data[dateString].sms.failure;
}
}
// Check if there are no messages sent
const subTitle = document.querySelector(`#activityChartContainer .chart-subtitle`);
if (totalMessages === 0) {
// Remove existing chart and render the alert message
d3.select('#weeklyChart').selectAll('*').remove();
d3.select('#weeklyChart')
.append('div')
.html(`
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body">
<p class="usa-alert__text">
No messages sent in the last 7 days
</p>
</div>
</div>
`);
// Hide the subtitle
if (subTitle) {
subTitle.style.display = 'none';
}
} else {
// If there are messages, create the chart and table
createChart('#weeklyChart', labels, deliveredData, failedData);
createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData);
}
return data;
})
.catch(error => console.error('Error fetching daily stats:', error));
};
const handleDropdownChange = function(event) {
const selectedValue = event.target.value;
const subTitle = document.querySelector(`#activityChartContainer .chart-subtitle`);
const selectElement = document.getElementById('options');
const selectedText = selectElement.options[selectElement.selectedIndex].text;
subTitle.textContent = `${selectedText} - last 7 days`;
fetchData(selectedValue);
// Update ARIA live region
const liveRegion = document.getElementById('aria-live-account');
liveRegion.textContent = `Data updated for ${selectedText} - last 7 days`;
// Switch tables based on dropdown selection
const selectedTable = selectedValue === "individual" ? "table1" : "table2";
const tables = document.querySelectorAll('.table-overflow-x-auto');
tables.forEach(function(table) {
table.classList.add('hidden'); // Hide all tables by adding the hidden class
table.classList.remove('visible'); // Ensure they are not visible
});
const tableToShow = document.getElementById(selectedTable);
tableToShow.classList.remove('hidden'); // Remove hidden class
tableToShow.classList.add('visible'); // Add visible class
};
document.addEventListener('DOMContentLoaded', function() {
// Initialize activityChart chart and table with service data by default
fetchData('service');
// Add event listener to the dropdown
const dropdown = document.getElementById('options');
dropdown.addEventListener('change', handleDropdownChange);
});
// Resize chart on window resize
window.addEventListener('resize', function() {
if (labels.length > 0 && deliveredData.length > 0 && failedData.length > 0) {
createChart('#weeklyChart', labels, deliveredData, failedData);
createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData);
}
});
// Export functions for testing
window.createChart = createChart;
window.createTable = createTable;
window.handleDropdownChange = handleDropdownChange;
window.fetchData = fetchData;
}
})(window);

View File

@@ -65,7 +65,7 @@
return $(`<div class="selection-footer${stickyClass}">
<button
class="govuk-button govuk-button--secondary selection-footer__button usa-button usa-button--outline"
class="govuk-button govuk-button--secondary selection-footer__button"
aria-expanded="${expanded ? 'true' : 'false'}"
aria-controls="${this.fieldsetId}">
${buttonContent}

View File

@@ -0,0 +1,15 @@
(function (window) {
"use strict";
function hasConsentFor (cookieCategory, consentCookie) {
if (consentCookie === undefined) { consentCookie = window.GOVUK.getConsentCookie(); }
if (consentCookie === null) { return false; }
if (!(cookieCategory in consentCookie)) { return false; }
return consentCookie[cookieCategory];
}
window.GOVUK.hasConsentFor = hasConsentFor;
})(window);

View File

@@ -0,0 +1,104 @@
window.GOVUK = window.GOVUK || {};
window.GOVUK.Modules = window.GOVUK.Modules || {};
(function (Modules) {
function CookieBanner () { }
CookieBanner.clearOldCookies = function (consent) {
var gaCookies = ['_ga', '_gid'];
// clear old cookie set by our previous JS, set on the www domain
if (window.GOVUK.cookie('seen_cookie_message')) {
document.cookie = 'seen_cookie_message=;expires=' + new Date().toGMTString() + ';path=/';
}
if (consent === null) {
for (var i = 0; i < gaCookies.length; i++) {
if (window.GOVUK.cookie(gaCookies[i])) {
// GA cookies are set on the base domain so need the www stripping
var cookieString = gaCookies[i] + '=;expires=' + new Date().toGMTString() + ';domain=' + window.location.hostname.replace(/^www\./, '.') + ';path=/';
document.cookie = cookieString;
}
}
}
};
CookieBanner.prototype.start = function ($module) {
this.$module = $module[0];
this.$module.hideCookieMessage = this.hideCookieMessage.bind(this);
this.$module.showConfirmationMessage = this.showConfirmationMessage.bind(this);
this.$module.setCookieConsent = this.setCookieConsent.bind(this);
this.$module.cookieBanner = document.querySelector('.notify-cookie-banner');
this.$module.cookieBannerConfirmationMessage = this.$module.querySelector('.notify-cookie-banner__confirmation');
this.setupCookieMessage();
};
CookieBanner.prototype.setupCookieMessage = function () {
this.$hideLink = this.$module.querySelector('button[data-hide-cookie-banner]');
if (this.$hideLink) {
this.$hideLink.addEventListener('click', this.$module.hideCookieMessage);
}
this.$acceptCookiesLink = this.$module.querySelector('button[data-accept-cookies=true]');
if (this.$acceptCookiesLink) {
this.$acceptCookiesLink.addEventListener('click', () => this.$module.setCookieConsent(true));
}
this.$rejectCookiesLink = this.$module.querySelector('button[data-accept-cookies=false]');
if (this.$rejectCookiesLink) {
this.$rejectCookiesLink.addEventListener('click', () => this.$module.setCookieConsent(false));
}
this.showCookieMessage();
};
CookieBanner.prototype.showCookieMessage = function () {
// Show the cookie banner if not in the cookie settings page
if (!this.isInCookiesPage()) {
var hasCookiesPolicy = window.GOVUK.cookie('cookies_policy');
if (this.$module && !hasCookiesPolicy) {
this.$module.style.display = 'block';
}
}
};
CookieBanner.prototype.hideCookieMessage = function (event) {
if (this.$module) {
this.$module.style.display = 'none';
}
if (event.target) {
event.preventDefault();
}
};
CookieBanner.prototype.setCookieConsent = function (analyticsConsent) {
window.GOVUK.setConsentCookie({ 'analytics': analyticsConsent });
this.$module.showConfirmationMessage(analyticsConsent);
this.$module.cookieBannerConfirmationMessage.focus();
if (analyticsConsent) { window.GOVUK.initAnalytics(); }
};
CookieBanner.prototype.showConfirmationMessage = function (analyticsConsent) {
var messagePrefix = analyticsConsent ? 'Youve accepted analytics cookies.' : 'You told us not to use analytics cookies.';
this.$cookieBannerMainContent = document.querySelector('.notify-cookie-banner__wrapper');
this.$cookieBannerConfirmationMessage = document.querySelector('.notify-cookie-banner__confirmation-message');
this.$cookieBannerConfirmationMessage.insertAdjacentText('afterbegin', messagePrefix);
this.$cookieBannerMainContent.style.display = 'none';
this.$module.cookieBannerConfirmationMessage.style.display = 'block';
};
CookieBanner.prototype.isInCookiesPage = function () {
return window.location.pathname === '/cookies';
};
Modules.CookieBanner = CookieBanner;
})(window.GOVUK.Modules);

View File

@@ -1,10 +0,0 @@
(function (window) {
"use strict";
// Show the current year
const currentYearElement = document.getElementById("current-year");
if (currentYearElement) {
currentYearElement.textContent = new Date().getFullYear();
}
})(window);

View File

@@ -0,0 +1,163 @@
// used by the cookie banner component
(function (root) {
'use strict';
window.GOVUK = window.GOVUK || {};
var DEFAULT_COOKIE_CONSENT = {
'analytics': false
};
var COOKIE_CATEGORIES = {
'_ga': 'analytics',
'_gid': 'analytics'
};
/*
Cookie methods
==============
Usage:
Setting a cookie:
GOVUK.cookie('hobnob', 'tasty', { days: 30 });
Reading a cookie:
GOVUK.cookie('hobnob');
Deleting a cookie:
GOVUK.cookie('hobnob', null);
*/
window.GOVUK.cookie = function (name, value, options) {
if (typeof value !== 'undefined') {
if (value === false || value === null) {
return window.GOVUK.setCookie(name, '', { days: -1 });
} else {
// Default expiry date of 30 days
if (typeof options === 'undefined') {
options = { days: 30 };
}
return window.GOVUK.setCookie(name, value, options);
}
} else {
return window.GOVUK.getCookie(name);
}
};
window.GOVUK.getConsentCookie = function () {
var consentCookie = window.GOVUK.cookie('cookies_policy');
var consentCookieObj;
if (consentCookie) {
try {
consentCookieObj = JSON.parse(consentCookie);
} catch (err) {
return null;
}
if (typeof consentCookieObj !== 'object' && consentCookieObj !== null) {
consentCookieObj = JSON.parse(consentCookieObj);
}
} else {
return null;
}
return consentCookieObj;
};
window.GOVUK.setConsentCookie = function (options) {
var cookieConsent = window.GOVUK.getConsentCookie();
if (!cookieConsent) {
cookieConsent = JSON.parse(JSON.stringify(DEFAULT_COOKIE_CONSENT));
}
for (var cookieType in options) {
cookieConsent[cookieType] = options[cookieType];
// Delete cookies of that type if consent being set to false
if (!options[cookieType]) {
for (var cookie in COOKIE_CATEGORIES) {
if (COOKIE_CATEGORIES[cookie] === cookieType) {
window.GOVUK.cookie(cookie, null);
if (window.GOVUK.cookie(cookie)) {
document.cookie = cookie + '=;expires=' + new Date() + ';domain=' + window.location.hostname.replace(/^www\./, '.') + ';path=/';
}
}
}
}
}
window.GOVUK.setCookie('cookies_policy', JSON.stringify(cookieConsent), { days: 365 });
};
window.GOVUK.checkConsentCookieCategory = function (cookieName, cookieCategory) {
var currentConsentCookie = window.GOVUK.getConsentCookie();
// If the consent cookie doesn't exist, but the cookie is in our known list, return true
if (!currentConsentCookie && COOKIE_CATEGORIES[cookieName]) {
return true;
}
currentConsentCookie = window.GOVUK.getConsentCookie();
// Sometimes currentConsentCookie is malformed in some of the tests, so we need to handle these
try {
return currentConsentCookie[cookieCategory];
} catch (e) {
console.error(e);
return false;
}
};
window.GOVUK.checkConsentCookie = function (cookieName, cookieValue) {
// If we're setting the consent cookie OR deleting a cookie, allow by default
if (cookieName === 'cookies_policy' || (cookieValue === null || cookieValue === false)) {
return true;
}
if (COOKIE_CATEGORIES[cookieName]) {
var cookieCategory = COOKIE_CATEGORIES[cookieName];
return window.GOVUK.checkConsentCookieCategory(cookieName, cookieCategory);
} else {
// Deny the cookie if it is not known to us
return false;
}
};
window.GOVUK.setCookie = function (name, value, options) {
if (window.GOVUK.checkConsentCookie(name, value)) {
if (typeof options === 'undefined') {
options = {};
}
var cookieString = name + '=' + value + '; path=/';
if (options.days) {
var date = new Date();
date.setTime(date.getTime() + (options.days * 24 * 60 * 60 * 1000));
cookieString = cookieString + '; expires=' + date.toGMTString();
}
if (document.location.protocol === 'https:') {
cookieString = cookieString + '; Secure';
}
document.cookie = cookieString;
}
};
window.GOVUK.getCookie = function (name) {
var nameEQ = name + '=';
var cookies = document.cookie.split(';');
for (var i = 0, len = cookies.length; i < len; i++) {
var cookie = cookies[i];
while (cookie.charAt(0) === ' ') {
cookie = cookie.substring(1, cookie.length);
}
if (cookie.indexOf(nameEQ) === 0) {
return decodeURIComponent(cookie.substring(nameEQ.length));
}
}
return null;
};
}(window));

View File

@@ -1,41 +0,0 @@
(function (window) {
// Set the target date (10 days before March 15th, 2024)
const targetDate = new Date("April 16, 2024 00:00:00").getTime();
// Set a const for the container to write a conditional statement and only run if it exists
const countdownContainer = document.getElementById("countdown-container");
// Function to update the countdown display
function updateCountdown() {
if (countdownContainer) {
const now = new Date().getTime();
const difference = targetDate - now;
// Time calculations for days only
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
// Visibility logic
if (days < 0 || days > 10) {
// Hide if more than 10 days away OR if already past the date
countdownContainer.style.display = "none";
} else {
// Show if 10 days or less remaining
countdownContainer.style.display = "block";
document.getElementById("countdown").innerHTML = days + " days ";
}
}
}
// Expose the updateCountdown function to the outside world
window.updateCountdown = updateCountdown;
// Initial display update
updateCountdown();
// Update the countdown every second (inside the IIFE)
setInterval(updateCountdown, 1000);
})(window);

View File

@@ -1,5 +1,12 @@
window.GOVUK.Frontend.initAll();
var consentData = window.GOVUK.getConsentCookie();
window.GOVUK.Modules.CookieBanner.clearOldCookies(consentData);
if (window.GOVUK.hasConsentFor('analytics', consentData)) {
window.GOVUK.initAnalytics();
}
$(() => $("time.timeago").timeago());
var showHideContent = new GOVUK.ShowHideContent();

View File

@@ -0,0 +1,36 @@
(function (global) {
'use strict';
$ = global.jQuery;
let branding_style = $('.usa-radio input[name="branding_style"]:checked');
if (!branding_style.length) { return; }
branding_style = branding_style.val();
const $paneWrapper = $('<div class="govuk-grid-column-full"></div>');
const $form = $('form');
const previewType = $form.data('previewType');
const $previewPane = $(`<iframe src="/_${previewType}?${buildQueryString(['branding_style', branding_style])}" class="branding-preview" scrolling="no"></iframe>`);
function buildQueryString () {
return $.map(arguments, (val, idx) => encodeURI(val[0]) + '=' + encodeURI(val[1])).join('&');
}
function setPreviewPane (e) {
const $target = $(e.target);
if ($target.attr('name') == 'branding_style') {
branding_style = $target.val();
}
$previewPane.attr('src', `/_${previewType}?${buildQueryString(['branding_style', branding_style])}`);
}
$paneWrapper.append($previewPane);
$form.find('.govuk-grid-row').eq(0).prepend($paneWrapper);
$form.attr('action', location.pathname.replace(new RegExp(`set-${previewType}-branding$`), `preview-${previewType}-branding`));
$form.find('button[type="submit"]').text('Save');
$('fieldset').on('change', 'input[name="branding_style"]', setPreviewPane);
})(window);

View File

@@ -1,150 +0,0 @@
(function (window) {
function createTotalMessagesChart() {
var chartContainer = document.getElementById('totalMessageChartContainer');
if (!chartContainer) return;
var chartTitle = document.getElementById('chartTitle').textContent;
// Access data attributes from the HTML
var sms_sent = parseInt(chartContainer.getAttribute('data-sms-sent'));
var sms_remaining_messages = parseInt(chartContainer.getAttribute('data-sms-allowance-remaining'));
var totalMessages = sms_sent + sms_remaining_messages;
// Update the message below the chart
document.getElementById('message').innerText = `${sms_sent.toLocaleString()} sent / ${sms_remaining_messages.toLocaleString()} remaining`;
// Calculate minimum width for "Messages Sent" as 1% of the total chart width
var minSentPercentage = (sms_sent === 0) ? 0 : 0.02;
var minSentValue = totalMessages * minSentPercentage;
var displaySent = Math.max(sms_sent, minSentValue);
var displayRemaining = totalMessages - displaySent;
var svg = d3.select("#totalMessageChart");
var width = chartContainer.clientWidth;
var height = 48;
// Ensure the width is set correctly
if (width === 0) {
console.error('Chart container width is 0, cannot set SVG width.');
return;
}
svg.attr("width", width).attr("height", height);
var x = d3.scaleLinear()
.domain([0, totalMessages])
.range([0, width]);
// Create tooltip dynamically
var tooltip = d3.select("body").append("div")
.attr("id", "tooltip");
// Create the initial bars
var sentBar = svg.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("height", height)
.attr("fill", '#0076d6')
.attr("width", 0) // Start with width 0 for animation
.on('mouseover', function(event) {
tooltip.style('display', 'block')
.html(`Messages Sent: ${sms_sent.toLocaleString()}`);
})
.on('mousemove', function(event) {
tooltip.style('left', `${event.pageX + 10}px`)
.style('top', `${event.pageY - 20}px`);
})
.on('mouseout', function() {
tooltip.style('display', 'none');
});
var remainingBar = svg.append("rect")
.attr("x", 0) // Initially set to 0, will be updated during animation
.attr("y", 0)
.attr("height", height)
.attr("fill", '#C7CACE')
.attr("width", 0) // Start with width 0 for animation
.on('mouseover', function(event) {
tooltip.style('display', 'block')
.html(`Remaining: ${sms_remaining_messages.toLocaleString()}`);
})
.on('mousemove', function(event) {
tooltip.style('left', `${event.pageX + 10}px`)
.style('top', `${event.pageY - 20}px`);
})
.on('mouseout', function() {
tooltip.style('display', 'none');
});
// Animate the bars together as a single cohesive line
svg.transition()
.duration(1000) // Total animation duration
.attr("width", width)
.tween("resize", function() {
var interpolator = d3.interpolate(0, width);
return function(t) {
var newWidth = interpolator(t);
var sentWidth = x(displaySent) / width * newWidth;
var remainingWidth = x(displayRemaining) / width * newWidth;
sentBar.attr("width", sentWidth);
remainingBar.attr("x", sentWidth).attr("width", remainingWidth);
};
});
// Create and populate the accessible table
var tableContainer = document.getElementById('totalMessageTable');
var table = document.createElement('table');
table.className = 'usa-sr-only usa-table';
var caption = document.createElement('caption');
caption.textContent = chartTitle;
table.appendChild(caption);
var thead = document.createElement('thead'); // Ensure thead is created
var theadRow = document.createElement('tr');
var thMessagesSent = document.createElement('th');
thMessagesSent.textContent = 'Messages Sent'; // First column header
var thRemaining = document.createElement('th');
thRemaining.textContent = 'Remaining'; // Second column header
theadRow.appendChild(thMessagesSent);
theadRow.appendChild(thRemaining);
thead.appendChild(theadRow); // Append theadRow to the thead
table.appendChild(thead);
var tbody = document.createElement('tbody');
var tbodyRow = document.createElement('tr');
var tdMessagesSent = document.createElement('td');
tdMessagesSent.textContent = sms_sent.toLocaleString(); // Value for Messages Sent
var tdRemaining = document.createElement('td');
tdRemaining.textContent = sms_remaining_messages.toLocaleString(); // Value for Remaining
tbodyRow.appendChild(tdMessagesSent);
tbodyRow.appendChild(tdRemaining);
tbody.appendChild(tbodyRow);
table.appendChild(tbody);
tableContainer.appendChild(table);
table.appendChild(tbody);
tableContainer.appendChild(table);
// Ensure the chart resizes correctly on window resize
window.addEventListener('resize', function () {
width = chartContainer.clientWidth;
x.range([0, width]);
svg.attr("width", width);
sentBar.attr("width", x(displaySent));
remainingBar.attr("x", x(displaySent)).attr("width", x(displayRemaining));
});
}
// Initialize total messages chart if the container exists
document.addEventListener('DOMContentLoaded', function() {
createTotalMessagesChart();
});
// Export function for testing
window.createTotalMessagesChart = createTotalMessagesChart;
})(window);

View File

@@ -1,4 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
var timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
document.cookie = `timezone=${timeZone}; path=/`;
})

Binary file not shown.

View File

@@ -1,159 +0,0 @@
@use "uswds-core" as *;
$delivered: color('blue-50v');
$pending: color('green-cool-40v');
$failed: color('gray-cool-20');
.chart-container {
display: flex;
&.usage {
height: units(4);
}
svg {
overflow: visible;
}
}
#totalMessageChartContainer {
max-width: 600px;
}
.bar {
border-radius: units(0.5);
&.delivered, &.usage {
background-color: $delivered;
margin-right: 1px;
}
&.pending{
background-color: $pending;
margin-right: 1px;
}
&.failed, &.remaining {
background-color: $failed;
}
}
.legend {
display: flex;
margin: units(1) 0;
.legend-item {
display: flex;
align-items: flex-start;
margin-right: units(2);
}
.legend-color {
width: units(3);
height: units(3);
margin-right: 0;
padding: 0;
border-radius: 2px;
background-color: $delivered;
&.pending {
background-color: $pending;
}
&.failed, &.remaining {
background-color: $failed;
}
}
.legend-value {
margin: 0 units(1);
}
}
.usa-tooltip {
line-height: 1;
.usa-tooltip__body {
width: units(mobile);
font-size: size("body", 2);
height: auto;
white-space: wrap;
line-height: units(1);
}
}
.progress-bar {
width: 300px;
height: 20px;
background-color: #eee;
border-radius: 5px;
margin-bottom: 10px;
}
.progress-bar-inner {
height: 100%;
background-color: #007bff;
border-radius: inherit;
}
.chart {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: units(1) units(1) 0;
}
.chart-subtitle {
font-size: size("body", 6);
font-weight: bold;
text-align: left;
width: 100%;
padding: 0;
margin: 0;
}
.axis text {
font-size: size("body", 1);
}
.axis line,
.axis path {
shape-rendering: crispEdges;
stroke: #000;
fill: none;
}
.bar {
fill-opacity: 0.8;
}
.chart-container {
width: 100%;
position: relative;
}
.chart-legend {
display: flex;
align-items: center;
}
.legend-item {
display: flex;
align-items: center;
margin-right: units(2);
.legend-rect {
width: units(2);
height: units(2);
margin-right: units(1);
}
}
#tooltip {
position: absolute;
display: none;
background: color('ink');
color: #FFF;
border: 1px solid #ccc;
padding: units(1);
border-radius: units(1);
pointer-events: none;
z-index: 100;
font-size: size("body", 3);
line-height: 1.3;
}

View File

@@ -48,13 +48,13 @@
}
}
.sms-message-sender, .sms-message-file-name, .sms-message-scheduler, .sms-message-template, .sms-message-sender {
margin:0.25rem 0 0;
.sms-message-sender {
margin: units(1) 0 0;
}
.sms-message-recipient {
color: color('gray-cool-90');
margin: units(1) 0 units(1);
margin: 0 0 units(1);
}
.sms-message-status {
@@ -62,18 +62,6 @@
margin: -20px units(1) 20px units(1);
}
h2.sms-message-header {
margin-bottom: 0.5rem;
}
.usa-prose >*+ h2.message-header {
margin-top: 1em;
}
h2.recipient-list {
margin-bottom: 0.5rem;
}
.sms-message-status-outbound {
text-align: right;
}
@@ -143,7 +131,7 @@ h2.recipient-list {
&-label,
&-button-label {
font-weight: bold;
font-size: 19px;
font-size: 19px;
display: block;
margin: 0 0 10px 0;
}
@@ -344,3 +332,11 @@ h2.recipient-list {
}
}
}
.js-focus-style {
outline: 3px solid color("blue-40v");
box-shadow: 0 0 0 7px color("blue-40v");
*:focus {
outline: none;
}
}

View File

@@ -22,45 +22,17 @@ i.e.
@use "uswds-core" as *;
iframe:focus, [href]:focus, [tabindex]:focus, [contentEditable=true]:focus, button:not([disabled]):focus {
outline: units(2px) solid color('blue-40v');
outline-offset: 0.3rem;
}
.usa-header--extended {
.usa-logo {
font-family: family("sans");
margin: units(3) 0;
@include at-media-max('desktop') {
margin: units(4) 0 units(4) units(2);
}
a {
display: flex;
img {
height: 35px;
@include at-media('desktop') {
height: 60px;
}
}
}
.usa-header--extended .usa-logo {
font-family: family("sans");
margin: units(4) 0;
@include at-media-max('mobile-lg') {
margin: units(4) 0 units(4) units(2);
}
.usa-nav__secondary {
.usa-nav__link {
padding: 0;
&.usa-current {
text-decoration: underline;
}
img {
@include at-media($theme-header-min-width) {
width: 80px;
height: 70px;
}
@include at-media-max('desktop') {
padding: 0 units(2);
ul li {
padding-bottom: units(1);
}
}
}
.usa-nav-container {
max-width: 100%;
padding: 0;
}
}
@@ -70,23 +42,21 @@ iframe:focus, [href]:focus, [tabindex]:focus, [contentEditable=true]:focus, butt
.usa-nav__primary {
&> .usa-nav__primary-item > a {
font-size: size("body", 5);
font-weight: 400;
cursor: pointer;
font-size: size("body", 4);
}
&> .usa-nav__primary-item:last-child {
margin-left: auto;
@include u-margin-right(-4);
}
}
.usa-nav__primary
h1 {
font-weight: bold !important;
}
.usa-section--dark h1 {
color: color('blue-30v');
@include at-media-max('desktop') {
font-size: size("body", 12);
}
}
p,
.list li {
font-family: family("sans");
@@ -144,69 +114,12 @@ td.table-empty-message {
text-decoration: none !important;
}
.button-flex-header {
flex-direction: column;
align-items: flex-start;
margin-bottom: units(2);
@include at-media(desktop) {
flex-direction: row;
display: flex;
align-items: center;
justify-content: space-between;
}
}
.usa-button img {
margin-left: .5rem;
height: 1rem;
}
.usa-button.login-button.login-button--primary,.login-button.login-button--primary:hover{
color:#112e51;background-color:#fff;
border:1px solid #767676;
display: inline-flex;
justify-content: center;
}
.user-list-edit-link:active:before,
.user-list-edit-link:focus:before {
box-shadow: none;
border: 0;
}
.user-list {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
padding: 1rem;
}
.user-list-item {
@include at-media(desktop) {
width: calc(50% - units(1));
margin-bottom: 1rem;
box-sizing: border-box;
}
.user-list-item-heading .live-search-relevant {
display: block;
word-wrap: break-word;
}
border: 1px solid color('gray-cool-10');
padding: units(2);
.tick-cross-list-permissions {
margin: units(1) 0;
padding-left: units(2);
}
.hint {
display: block;
font-size: size("body", "sm");
font-weight: normal;
}
}
.template-list-item-without-ancestors .template-list-folder:active,
.template-list-item-without-ancestors .template-list-folder:active::before,
.template-list-item-without-ancestors .template-list-folder:focus,
@@ -219,15 +132,11 @@ td.table-empty-message {
box-shadow: none;
}
.template-list-item-hidden-by-default {
display: none
}
.folder-heading a.folder-heading-folder,
.template-list-folder {
background: url(../img/material-icons/folder.svg) no-repeat;
padding-left: units(4);
display: inline-block;
display: inline-block;
background-position: 0;
}
@@ -257,7 +166,7 @@ td.table-empty-message {
.template-list-template {
background: url(../img/material-icons/description.svg) no-repeat;
padding-left: units(4);
display: inline-flex;
display: inline-flex;
}
.js-enabled .live-search {
@@ -276,12 +185,6 @@ td.table-empty-message {
display: block;
}
.usa-table {
th {
border-bottom: 0 !important;
}
}
.js-stick-at-bottom-when-scrolling {
display: flex;
align-items: flex-end;
@@ -311,7 +214,7 @@ td.table-empty-message {
}
.usa-hero {
background-image: none;
background-image: none;
}
.usa-prose {
@@ -329,51 +232,47 @@ td.table-empty-message {
}
.navigation-service.usa-breadcrumb {
bottom: 0;
-bottom: 0;
}
.table-overflow-x-auto {
&.hidden {
display: none;
}
}
@media (max-width: units('desktop-lg')) {
.table-overflow-x-auto {
overflow-x: auto;
table {
min-width: 768px;
}
}
}
// Dashboard
.dashboard {
.big-number-with-status {
a {
background: color("blue-60v");
padding: units(2);
margin-bottom: units(1);
text-decoration: none;
&:hover{
background: color("blue-warm-70v");
}
}
span {
color: white;
}
.big-number-smaller {
display: flex;
display: flex;
flex-direction: column;
.big-number-number {
font-size: units(5);
line-height: units(5);
line-height: units(6);
}
.big-number-label {
font-size: units(2.5);
font-size: units(3);
}
}
.big-number-status {
background: color("green-cool-40v");
display: flex;
display: flex;
padding: units(1) units(2);
&--failing {
padding: 0;
padding: 0;
a.usa-link {
color: white;
background: color("red-warm-50v");
padding: units(1) units(2);
margin: 0;
margin: 0;
width: 100%;
&:hover {
background: color("red-warm-60v");
@@ -383,12 +282,12 @@ td.table-empty-message {
}
}
.usa-table {
width: 100%;
width: 100%;
caption {
margin-bottom: 0;
margin-bottom: 0;
}
.table-field-center-aligned {
text-align: center;
text-align: center;
}
.template-statistics-table-template-name {
padding-left: units(4);
@@ -401,146 +300,63 @@ td.table-empty-message {
.dashboard-table {
table {
width: 100%;
width: 100%;
}
.file-list-filename {
font-weight: bold;
}
.file-list-hint {
margin: 0;
word-break: break-word;
margin: 0;
}
.table-field, .table-field-right-aligned {
width: 50%;
width: 50%;
}
&.usage-table {
.table-field, .table-field-left-aligned, .table-field-right-aligned {
width: auto;
width: auto;
}
}
}
.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;
padding: 0;
margin: 0;
list-style: none;
padding: 0;
margin: 0;
}
.big-number-smallest {
display: flex;
display: flex;
flex-direction: column;
}
}
.job-table {
width: 100%;
border-collapse: collapse;
td.file-name {
width: 25%;
overflow-wrap: anywhere;
}
td.jobid {
width: 5%;
}
td.template {
width: 25%;
}
td.time-sent {
width: 15%;
}
td.sender {
width: 20%;
overflow-wrap: break-word;
}
td.count-of-recipients {
width: 5%;
}
td.report {
width: 2%;
text-align: center;
}
td.delivered {
width: 2%;
text-align: center;
}
td.failed {
width: 2%;
text-align: center;
}
td.report img {
padding-top: 5px;
}
th {
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 {
max-height: 500px;
overflow-y: auto;
padding: units(1) 0 units(1) units(1);
margin: units(2) 0 units(5);
ul {
padding: 0;
margin: 0;
list-style: none;
}
}
.usa-prose > p.max-width-full {
max-width: 100%;
padding: 0;
margin: 0;
list-style: none;
}
}
// Tabs
.tabs {
.pill {
display: flex;
display: flex;
list-style: none;
padding: 0;
padding: 0;
.pill-item__container {
border: 1px solid color("gray-cool-10");
flex: 1;
display: flex;
flex-direction: column;
text-align: center;
flex: 1;
display: flex;
flex-direction: column;
text-align: center;
font-size: units(2);
a {
a {
padding: units(4);
.big-number-smaller {
font-size: units(5);
@@ -551,7 +367,7 @@ td.table-empty-message {
}
&:not(.pill-item--selected):hover {
background: color("blue-warm-70v");
}
}
&.pill-item--selected:hover {
color: color("blue-60v");
}
@@ -563,12 +379,12 @@ td.table-empty-message {
// Etc
.email-brand, .browse-list {
padding: 0;
margin: 0;
list-style: none;
padding: 0;
margin: 0;
list-style: none;
margin-bottom: units(2);
li {
padding: 8px 0;
padding: 8px 0;
}
}
@@ -576,7 +392,7 @@ details form {
box-sizing:border-box;
}
// Textbox highlight
// Textbox highlight
.textbox-highlight {
@@ -624,7 +440,7 @@ details form {
.placeholder,
.placeholder-conditional {
background-color: #fff;
position: relative;
position: relative;
&:after {
content: "";
background-color: color("yellow-20v");
@@ -633,9 +449,9 @@ details form {
position: absolute;
top: 0;
left: 5px;
right: 6px;
right: 6px;
height: 100%;
border-radius: 7px;
border-radius: 7px;
}
}
@@ -655,245 +471,3 @@ details form {
padding-left: 5px;
letter-spacing: 0.04em;
}
.edit-textbox-error-mt {
margin-top: 1.5rem;
}
// Login page
#countdown-container {
display: none; // Hide the countdown timer
}
.placeholder, .placeholder-conditional {
background-color: #face00;
border: 1px solid #face00;
border-radius: 7px;
}
.usa-prose.margin-top-0 {
margin-top: 0;
}
ol.best-practices-list {
counter-reset: item;
list-style-type: none;
padding-left: 0;
}
ol.best-practices-list.set-two {
counter-reset: item 6;
}
ol.best-practices-list.set-three {
counter-reset: item 10;
}
ol.best-practices-list li {
counter-increment: item;
margin-bottom: 15px;
position: relative;
padding-left: 40px;
}
ol.best-practices-list li::before {
content: counter(item);
background-color: #005ea2;
color: white;
width: 32px;
height: 32px;
border-radius: 50%;
display: inline-flex;
justify-content: center;
align-items: center;
line-height: 1;
position: absolute;
left: 0;
top: 0;
}
li.best-practices {
padding-bottom: 50px;
}
div.best-practices {
height: 400px
}
$do-dont-color-do: "green-cool-50v";
$do-dont-color-dont: "red-cool-50v";
$do-dont-color-border: "gray-cool-20";
$do-dont-padding: 3;
$do-dont-top-bar-width: 1;
.do-dont {
display: flex;
height: 100%;
padding-top: units(2);
}
.do-dont__do,
.do-dont__dont {
background-color: color("white");
border: 1px solid color($do-dont-color-border);
flex: 1 0 0;
height: 100%;
position: relative;
}
.do-dont__do {
padding: 0rem;
}
.do-dont__do:before,
.do-dont__dont:before {
content: "";
position: absolute;
height: units($do-dont-top-bar-width);
width: calc(100% + 2px);
top: -1px;
left: -1px;
}
.do-dont__do:before {
background-color: color($do-dont-color-do);
}
.do-dont__dont:before {
background-color: color($do-dont-color-dont);
}
.do-dont__heading {
// @include typeset("lang", "lg", 2);
align-items: center;
border-bottom: 1px solid color($do-dont-color-border);
display: flex;
font-weight: fw("bold");
margin: 0;
padding: units(4) units($do-dont-padding) units($do-dont-padding);
.usa-icon {
@include u-square(3);
margin-right: units(0.5);
}
}
.do-dont__do .do-dont__heading {
color: color($do-dont-color-do);
}
.do-dont__dont .do-dont__heading {
color: color($do-dont-color-dont);
}
.do-dont__content {
padding: 0 units($do-dont-padding) units($do-dont-padding) units($do-dont-padding);
ul {
margin-bottom: 0;
overflow-wrap: anywhere;
}
li+li {
margin-top: units(1);
}
}
.site-note {
background-color: #dfe1e2;
max-width: 72ex;
padding: .5rem;
border-radius: 2px;
}
.svg-circle-icon {
display: inline-block;
margin: 0;
padding: 0;
vertical-align: text-bottom;
margin-right: -2px;
}
.height-phone-lg {
max-height: 500px;
}
@media (max-width: 758px) {
.best-practices-flex-container {
flex-direction: column;
}
}
.linked-content:hover {
cursor: pointer;
transform: scale(1.05);
transition: transform 0.3s ease, background-color 0.3s ease,
}
li.linked-card:hover > div:first-child:hover {
border-color: #005ea2;
}
li.linked-card:hover h4,
li.linked-card:hover p,
li.linked-card:hover svg,
.best-practices_card_img:hover{
color: #005ea2;
}
.best-practices_card_img {
display: block;
width: 50%;
height: auto;
margin: 0 auto;
}
.best-practices_link {
text-decoration: none;
}
.usa-link--downloadable {
position: relative;
}
.usa-link--downloadable::after {
background-image: url(../img/material-icons/download.svg);
background-repeat: no-repeat;
background-position: center center;
background-size: 1.75ex 1.75ex;
height: 1.75ex;
width: 1.75ex;
content: "";
display: inline;
margin-top: .7ex;
margin-left: 2px;
padding-left: 1.75ex;
vertical-align: middle;
}
.usa-link--downloadable::after {
background: 0 0;
background-color: currentColor;
-webkit-mask-image: url(../img/material-icons/download.svg), linear-gradient(transparent, transparent);
mask-image: url(../img/material-icons/download.svg), linear-gradient(transparent, transparent);
-webkit-mask-position: center center;
mask-position: center center;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 1.75ex 1.75ex;
mask-size: 1.75ex 1.75ex;
}
.usa-sidenav__sublist {
display: none;
}
.usa-sidenav__item:hover .usa-sidenav__sublist,
.usa-sidenav__item:focus-within .usa-sidenav__sublist {
display: block;
}
.usa-sidenav__item a {
display: block;
}

View File

@@ -9,10 +9,5 @@ in the form $setting: value,
@use "uswds-core" with (
$theme-font-type-sans: "public-sans",
$theme-show-notifications: false,
$theme-banner-max-width: "desktop-lg",
$theme-grid-container-max-width: "desktop-lg",
$theme-footer-max-width: "desktop-lg",
$theme-header-max-width: "desktop-lg",
$theme-identifier-max-width: "desktop-lg"
$theme-show-notifications: false
);

View File

@@ -1,5 +1,4 @@
@forward "uswds-theme";
@forward "uswds";
@forward "uswds-theme-custom-styles";
@forward "legacy-styles";
@forward "data-visualization";
@forward "legacy-styles";

View File

@@ -34,11 +34,6 @@ $path: '/static/images/';
// Dependencies from GOV.UK Frontend, packaged to be specific to this application
@import './govuk-frontend/all';
// Custom overrides
.govuk-link {
font-weight: bold;
}
// Specific to this application
@import 'local/typography';
@import 'grids';

View File

@@ -2,13 +2,12 @@ import json
from os import getenv
import newrelic.agent
from notifications_utils import DAILY_MESSAGE_LIMIT
from app.cloudfoundry_config import cloud_config
from notifications_utils import DAILY_MESSAGE_LIMIT
class Config(object):
SIMULATED_SMS_NUMBERS = ("+14254147755", "+14254147167")
NOTIFY_APP_NAME = "admin"
NOTIFY_ENVIRONMENT = getenv("NOTIFY_ENVIRONMENT", "development")
API_HOST_NAME = getenv("API_HOST_NAME", "localhost")
@@ -37,9 +36,13 @@ class Config(object):
NR_BROWSER_KEY = getenv("NR_BROWSER_KEY")
settings = newrelic.agent.global_settings()
NR_MONITOR_ON = settings and settings.monitor_mode
COMMIT_HASH = getenv("COMMIT_HASH", "--------")[0:7]
GOVERNMENT_EMAIL_DOMAIN_NAMES = ["gov", "mil", "si.edu"]
TEMPLATE_PREVIEW_API_HOST = getenv(
"TEMPLATE_PREVIEW_API_HOST", "http://localhost:9999"
)
TEMPLATE_PREVIEW_API_KEY = getenv("TEMPLATE_PREVIEW_API_KEY", "my-secret-key")
GOVERNMENT_EMAIL_DOMAIN_NAMES = ["gov"]
# Logging
NOTIFY_LOG_LEVEL = getenv("NOTIFY_LOG_LEVEL", "INFO")
@@ -54,13 +57,7 @@ class Config(object):
PERMANENT_SESSION_LIFETIME = 1800 # 30 Minutes
SEND_FILE_MAX_AGE_DEFAULT = 365 * 24 * 60 * 60 # 1 year
REPLY_TO_EMAIL_ADDRESS_VALIDATION_TIMEOUT = 45
ACTIVITY_STATS_LIMIT_DAYS = {
"today": 0,
"one_day": 1,
"three_day": 3,
"five_day": 5,
"seven_day": 7,
}
ACTIVITY_STATS_LIMIT_DAYS = 7
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_NAME = "notify_admin_session"
SESSION_COOKIE_SECURE = True
@@ -84,12 +81,10 @@ class Config(object):
"IBAN": "GB33BUKB20201555555555",
"swift": "ABCDEF12",
"notify_billing_email_addresses": [
"tts-benefits-studio@gsa.gov",
"generic@digital.cabinet-office.gov.uk",
"first.last@digital.cabinet-office.gov.uk",
],
}
FEATURE_BEST_PRACTICES_ENABLED = (
getenv("FEATURE_BEST_PRACTICES_ENABLED", "false") == "true"
)
def _s3_credentials_from_env(bucket_prefix):
@@ -157,34 +152,6 @@ class Staging(Production):
HEADER_COLOUR = "#00ff00" # $green
class E2ETest(Staging):
"""
An environment config that is intended to operate as if it were in the
staging environment but with the configuration of the development and test
environments so the E2E tests work.
"""
# Borrowed from development environment
DEBUG = True
SESSION_COOKIE_SECURE = False
SESSION_PROTECTION = None
HTTP_PROTOCOL = "http"
ASSET_DOMAIN = ""
ASSET_PATH = "/static/"
# Borrowed from test environment
TESTING = True
WTF_CSRF_ENABLED = False
# buckets - mirror staging
CSV_UPLOAD_BUCKET = cloud_config.s3_credentials(
"notify-api-csv-upload-bucket-staging"
)
LOGO_UPLOAD_BUCKET = cloud_config.s3_credentials(
"notify-admin-logo-upload-bucket-staging"
)
class Demo(Staging):
HEADER_COLOUR = "#6F72AF" # $mauve
@@ -206,7 +173,6 @@ class Scanning(Production):
configs = {
"development": Development,
"test": Test,
"e2etest": E2ETest,
"scanning": Scanning,
"staging": Staging,
"demo": Demo,

View File

@@ -1,21 +0,0 @@
# Get started
1. ## Everyone starts in [Trial Mode](/using-notify/trial-mode)
Explore Notify, add team members, and practice [sending messages to teammates](/using-notify/trial-mode).
2. ## Personalize content
Learn how to [personalize messages](/using-notify/guidance) to increase response.
3. ## Check delivery status
[Analyze the delivery](/using-notify/delivery-status) of your messages and download reports
4. ## View messaging limits
Learn about message _parts_ and [how limits are calculated](/using-notify/pricing).
5. ## Start sending messages
To remove the restrictions of Trial Mode and begin sending messages to clients complete the <a class="usa-link usa-link--external" href="https://docs.google.com/forms/d/1fnaBtxuGf3q-OdGVyt2LqBKvp9_P21kmKJa0yIK8rWM/edit">Go-Live Form</a>.
Well respond within one business day.
### Questions?
[Contact the Notify team](/support)

View File

@@ -24,7 +24,6 @@ EVENT_SCHEMAS = {
"service_id",
"ui_permissions",
},
"resend_user_invite_to_service": {"email_address", "resent_by_id", "service_id"},
"cancel_user_invite_to_service": {"email_address", "canceled_by_id", "service_id"},
"set_user_permissions": {
"user_id",
@@ -64,10 +63,6 @@ def create_cancel_user_invite_to_service_event(**kwargs):
_send_event("cancel_user_invite_to_service", **kwargs)
def create_resend_user_invite_to_service_event(**kwargs):
_send_event("resend_user_invite_to_service", **kwargs)
def create_add_user_to_service_event(**kwargs):
_send_event("add_user_to_service", **kwargs)

View File

@@ -1,3 +1,5 @@
from notifications_utils.clients.redis.redis_client import RedisClient
from notifications_utils.clients.zendesk.zendesk_client import ZendeskClient
zendesk_client = ZendeskClient()
redis_client = RedisClient()

View File

@@ -1,4 +1,3 @@
import os
import re
import unicodedata
import urllib
@@ -10,55 +9,15 @@ from numbers import Number
import ago
import dateutil
import humanize
import markdown
import pytz
from bs4 import BeautifulSoup
from flask import render_template_string, url_for
from flask.helpers import get_root_path
from markupsafe import Markup
from app.utils.csv import get_user_preferred_timezone
from app.utils.time import parse_naive_dt
from flask import Markup, url_for
from notifications_utils.field import Field
from notifications_utils.formatters import make_quotes_smart
from notifications_utils.formatters import nl2br as utils_nl2br
from notifications_utils.recipients import InvalidPhoneError, validate_phone_number
from notifications_utils.take import Take
def apply_html_class(tags, html_file):
new_html = html_file
for tag in tags:
element = tag[0]
class_name = tag[1]
soup = BeautifulSoup(new_html, "html.parser")
for xtag in soup.find_all(element):
xtag["class"] = class_name
new_html = str(soup)
return new_html
def convert_markdown_template(mdf, test=False):
content_text = ""
if not test:
APP_ROOT = get_root_path("notifications-admin")
file = "app/content/" + mdf + ".md"
md_file = os.path.join(APP_ROOT, file)
with open(md_file) as f:
content_text = f.read()
else:
content_text = mdf
jn_render = render_template_string(content_text)
md_render = markdown.markdown(jn_render)
return md_render
from app.utils.time import parse_naive_dt
def convert_to_boolean(value):
@@ -72,14 +31,13 @@ def convert_to_boolean(value):
def format_datetime(date):
return "{} at {} {}".format(
format_date(date), format_time_24h(date), get_user_preferred_timezone()
)
return "{} at {} UTC".format(format_date(date), format_time_24h(date))
def format_datetime_24h(date):
return "{} at {} {}".format(
format_date(date), format_time_24h(date), get_user_preferred_timezone()
return "{} at {} UTC".format(
format_date(date),
format_time_24h(date),
)
@@ -88,77 +46,39 @@ def format_time(date):
def format_datetime_normal(date):
# example: February 20, 2024 at 07:00 PM US/Eastern, used for datetimes that's not within tables
return "{} at {} {}".format(
format_date_normal(date), format_time_12h(date), get_user_preferred_timezone()
)
return "{} at {} UTC".format(format_date_normal(date), format_time_24h(date))
def format_datetime_scheduled_notification(date):
# e.g. April 09, 2024 at 04:00 PM US/Eastern.
# Everything except scheduled notifications, the time is always "now".
# Scheduled notifications are the exception to the rule.
# Here we are formating and displaying the datetime without converting datetime to a different timezone.
datetime_obj = parse_naive_dt(date)
format_time_without_tz = datetime_obj.replace(tzinfo=timezone.utc).strftime(
"%I:%M %p"
)
return "{} at {} {}".format(
format_date_normal(date), format_time_without_tz, get_user_preferred_timezone()
)
def format_datetime_table(date):
# example: 03-18-2024 at 04:53 PM, intended for datetimes in tables
return "{} at {}".format(format_date_numeric(date), format_time_12h(date))
def format_time_12h(date):
date = parse_naive_dt(date)
preferred_tz = pytz.timezone(get_user_preferred_timezone())
return (
date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%I:%M %p")
)
def format_datetime_short(date):
return "{} at {} UTC".format(format_date_short(date), format_time_24h(date))
def format_datetime_relative(date):
return "{} at {} {}".format(
get_human_day(date), format_time_24h(date), get_user_preferred_timezone()
)
return "{} at {} UTC".format(get_human_day(date), format_time_24h(date))
def format_datetime_numeric(date):
return "{} {} {}".format(
format_date_numeric(date), format_time_24h(date), get_user_preferred_timezone()
return "{} {} UTC".format(
format_date_numeric(date),
format_time_24h(date),
)
def format_date_numeric(date):
date = parse_naive_dt(date)
preferred_tz = pytz.timezone(get_user_preferred_timezone())
return (
date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%m-%d-%Y")
)
return date.strftime("%Y-%m-%d")
def format_time_24h(date):
date = parse_naive_dt(date)
preferred_tz = pytz.timezone(get_user_preferred_timezone())
return date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%H:%M")
return date.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(preferred_tz)
now = datetime.now(pytz.utc)
if date == (now + timedelta(days=1)).date():
return "tomorrow"
@@ -180,25 +100,17 @@ def get_human_day(time, date_prefix=""):
def format_date(date):
date = parse_naive_dt(date)
preferred_tz = pytz.timezone(get_user_preferred_timezone())
return (
date.replace(tzinfo=timezone.utc)
.astimezone(preferred_tz)
.strftime("%A %d %B %Y")
)
return date.strftime("%A %d %B %Y")
def format_date_normal(date):
date = parse_naive_dt(date)
return date.strftime("%B %d, %Y").lstrip("0")
return date.strftime("%d %B %Y").lstrip("0")
def format_date_short(date):
date = parse_naive_dt(date)
preferred_tz = pytz.timezone(get_user_preferred_timezone())
return _format_datetime_short(
date.replace(tzinfo=timezone.utc).astimezone(preferred_tz)
)
return _format_datetime_short(date)
def format_date_human(date):
@@ -206,17 +118,15 @@ def format_date_human(date):
def format_datetime_human(date, date_prefix=""):
return "{} at {} {}".format(
return "{} at {} UTC".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)
preferred_tz = pytz.timezone(get_user_preferred_timezone())
return date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%A")
return date.strftime("%A")
def _format_datetime_short(datetime):
@@ -231,11 +141,6 @@ 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)
@@ -479,12 +384,6 @@ def message_count_noun(count, template_type):
else:
return "text messages"
if template_type == "parts":
if count == 1:
return "text message part"
else:
return "text message parts"
elif template_type == "email":
if count == 1:
return "email"

View File

@@ -3,13 +3,13 @@ from flask import Blueprint
main = Blueprint("main", __name__)
from app.main.views import ( # noqa isort:skip
activity,
add_service,
api_keys,
choose_account,
code_not_received,
conversation,
dashboard,
email_branding,
feedback,
find_services,
find_users,
@@ -26,6 +26,7 @@ from app.main.views import ( # noqa isort:skip
performance,
platform_admin,
pricing,
providers,
register,
security_policy,
send,

View File

@@ -4,13 +4,15 @@ from itertools import chain
from numbers import Number
import pytz
from flask import render_template, request
from flask import Markup, render_template, request
from flask_login import current_user
from flask_wtf import FlaskForm as Form
from flask_wtf.file import FileAllowed
from flask_wtf.file import FileField as FileField_wtf
from flask_wtf.file import FileSize
from markupsafe import Markup
from notifications_utils.formatters import strip_all_whitespace
from notifications_utils.insensitive_dict import InsensitiveDict
from notifications_utils.recipients import InvalidPhoneError, validate_phone_number
from werkzeug.utils import cached_property
from wtforms import (
BooleanField,
@@ -50,7 +52,6 @@ from app.main.validators import (
CommonlyUsedPassword,
CsvFileValidator,
DoesNotStartWithDoubleZero,
FieldCannotContainComma,
LettersNumbersSingleQuotesFullStopsAndUnderscoresOnly,
MustContainAlphanumericCharacters,
NoCommasInPlaceHolders,
@@ -60,23 +61,18 @@ from app.main.validators import (
ValidEmail,
ValidGovEmail,
)
from app.models.feedback import PROBLEM_TICKET_TYPE, QUESTION_TICKET_TYPE
from app.models.organization import Organization
from app.utils import merge_jsonlike
from app.utils.csv import get_user_preferred_timezone
from app.utils import branding, merge_jsonlike
from app.utils.user_permissions import all_ui_permissions, permission_options
from notifications_utils.formatters import strip_all_whitespace
from notifications_utils.insensitive_dict import InsensitiveDict
from notifications_utils.recipients import InvalidPhoneError, validate_phone_number
def get_time_value_and_label(future_time):
preferred_tz = pytz.timezone(get_user_preferred_timezone())
return (
future_time.astimezone(preferred_tz).isoformat(),
"{} at {} {}".format(
get_human_day(future_time.astimezone(preferred_tz)),
get_human_time(future_time.astimezone(preferred_tz)),
get_user_preferred_timezone(),
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)),
),
)
@@ -89,25 +85,21 @@ 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(preferred_tz).strftime("%A"):
if time == datetime.now(pytz.utc).strftime("%A"):
return "{}oday".format(prefix_today_with)
if time == (datetime.now(preferred_tz) + timedelta(days=1)).strftime("%A"):
if time == (datetime.now(pytz.utc) + timedelta(days=1)).strftime("%A"):
return "Tomorrow"
return time
def get_furthest_possible_scheduled_time():
# We want local time to find date boundaries
preferred_tz = pytz.timezone(get_user_preferred_timezone())
return (datetime.now(preferred_tz) + timedelta(days=4)).replace(hour=0)
return (datetime.now(pytz.utc) + timedelta(days=4)).replace(hour=0)
def get_next_hours_until(until):
preferred_tz = pytz.timezone(get_user_preferred_timezone())
now = datetime.now(preferred_tz)
now = datetime.now(pytz.utc)
hours = int((until - now).total_seconds() / (60 * 60))
return [
(now + timedelta(hours=i)).replace(minute=0, second=0, microsecond=0)
@@ -116,8 +108,7 @@ def get_next_hours_until(until):
def get_next_days_until(until):
preferred_tz = pytz.timezone(get_user_preferred_timezone())
now = datetime.now(preferred_tz)
now = datetime.now(pytz.utc)
days = int((until - now).total_seconds() / (60 * 60 * 24))
return [
get_human_day((now + timedelta(days=i)), prefix_today_with="Later t")
@@ -607,15 +598,6 @@ class RegisterUserForm(StripWhitespaceForm):
auth_type = HiddenField("auth_type", default="sms_auth")
class SetupUserProfileForm(StripWhitespaceForm):
name = GovukTextInputField(
"Full name", validators=[DataRequired(message="Cannot be empty")]
)
mobile_number = international_phone_number()
# TODO This should be replaced with a select widget when one is available.
preferred_timezone = HiddenField("preferred_timezone", default="US/Eastern")
class RegisterUserFromInviteForm(RegisterUserForm):
def __init__(self, invited_user):
super().__init__(
@@ -825,6 +807,49 @@ class GovukCheckboxField(BooleanField):
)
class GovukTextareaField(TextAreaField):
def __init__(self, label="", validators=None, param_extensions=None, **kwargs):
super(TextAreaField, self).__init__(label, validators, **kwargs)
self.param_extensions = param_extensions
# self.__call__ renders the HTML for the field by:
# 1. delegating to self.meta.render_field which
# 2. calls field.widget
# this bypasses that by making self.widget a method with the same interface as widget.__call__
def widget(self, field, param_extensions=None, **kwargs):
# error messages
error_message = None
if field.errors:
error_message = {"text": field.errors[0]}
params = {
"name": field.name,
"id": field.id,
"rows": 8,
"label": {
"text": field.label.text,
"classes": None,
"isPageHeading": False,
},
"hint": {"text": None},
"errorMessage": error_message,
}
# extend default params with any sent in during instantiation
if self.param_extensions:
merge_jsonlike(params, self.param_extensions)
# add any sent in though use in templates
if param_extensions:
merge_jsonlike(params, param_extensions)
return Markup(
render_template(
"components/components/textarea/template.njk", params=params
)
)
# based on work done by @richardjpope: https://github.com/richardjpope/recourse/blob/master/recourse/forms.py#L6
class GovukCheckboxesField(SelectMultipleField):
render_as_list = False
@@ -923,9 +948,9 @@ class OnOffField(GovukRadiosField):
]
super().__init__(
label,
*args,
choices=choices,
thing=f"{choices[0][1].lower()} or {choices[1][1].lower()}",
*args,
**kwargs,
)
@@ -1163,7 +1188,7 @@ class AdminServiceSMSAllowanceForm(StripWhitespaceForm):
class AdminServiceMessageLimitForm(StripWhitespaceForm):
message_limit = GovukIntegerField(
"Max number of messages the service has per send",
"Number of messages the service is allowed to send each day",
validators=[DataRequired(message="Cannot be empty")],
)
@@ -1237,11 +1262,7 @@ class CsvUploadForm(StripWhitespaceForm):
validators=[
DataRequired(message="Please pick a file"),
CsvFileValidator(),
FileSize(
max_size=10e6,
message="File must be smaller than 10Mb. If you are trying to upload an Excel file, \
please export the contents in the CSV format and then try again.",
), # 10Mb
FileSize(max_size=10e6, message="File must be smaller than 10Mb"), # 10Mb
],
)
@@ -1250,27 +1271,6 @@ class ChangeNameForm(StripWhitespaceForm):
new_name = GovukTextInputField("Your name")
# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
class ChangePreferredTimezoneForm(StripWhitespaceForm):
def __init__(self, *args, **kwargs):
super(ChangePreferredTimezoneForm, self).__init__(*args, **kwargs)
self.new_preferred_timezone.choices = [
("America/Puerto_Rico", "America/Puerto_Rico"),
("US/Eastern", "US/Eastern"),
("US/Central", "US/Central"),
("US/Mountain", "US/Mountain"),
("US/Pacific", "US/Pacific"),
("US/Alaska", "US/Alaska"),
("US/Hawaii", "US/Hawaii"),
("US/Aleutian", "US/Aleutian"),
("US/Samoa", "US/Samoa"),
]
new_preferred_timezone = GovukRadiosField(
"What timezone would you like to use?",
)
class ChangeEmailForm(StripWhitespaceForm):
def __init__(self, validate_email_func, *args, **kwargs):
self.validate_email_func = validate_email_func
@@ -1337,6 +1337,49 @@ class CreateKeyForm(StripWhitespaceForm):
raise ValidationError("A key with this name already exists")
class SupportType(StripWhitespaceForm):
support_type = GovukRadiosField(
"How can we help you?",
choices=[
(PROBLEM_TICKET_TYPE, "Report a problem"),
(QUESTION_TICKET_TYPE, "Ask a question or give feedback"),
],
)
class SupportRedirect(StripWhitespaceForm):
who = GovukRadiosField(
"What do you need help with?",
choices=[
(
"public-sector",
"I work in the public sector and need to send emails or text messages",
),
("public", "Im a member of the public with a question for the government"),
],
param_extensions={"fieldset": {"legend": {"classes": "usa-sr-only"}}},
)
class FeedbackOrProblem(StripWhitespaceForm):
name = GovukTextInputField("Name (optional)")
email_address = email_address(label="Email address", gov_user=False, required=True)
feedback = TextAreaField(
"Your message", validators=[DataRequired(message="Cannot be empty")]
)
class Triage(StripWhitespaceForm):
severe = GovukRadiosField(
"Is it an emergency?",
choices=[
("yes", "Yes"),
("no", "No"),
],
thing="yes or no",
)
class EstimateUsageForm(StripWhitespaceForm):
volume_email = ForgivingIntegerField(
"How many emails do you expect to send in the next year?",
@@ -1372,6 +1415,52 @@ class EstimateUsageForm(StripWhitespaceForm):
return super().validate(*args, **kwargs)
class AdminProviderRatioForm(Form):
def __init__(self, providers):
self._providers = providers
# hack: https://github.com/wtforms/wtforms/issues/736
self._unbound_fields = [
(
provider["identifier"],
GovukIntegerField(
f"{provider['display_name']} (%)",
validators=[
validators.NumberRange(
min=0, max=100, message="Must be between 0 and 100"
)
],
param_extensions={
"classes": "width-8",
},
),
)
for provider in providers
]
super().__init__(
data={
provider["identifier"]: provider["priority"] for provider in providers
}
)
def validate(self, extra_validators=None):
if not super().validate(extra_validators):
return False
total = sum(
getattr(self, provider["identifier"]).data for provider in self._providers
)
if total == 100:
return True
for provider in self._providers:
getattr(self, provider["identifier"]).errors += ["Must add up to 100%"]
return False
class ServiceContactDetailsForm(StripWhitespaceForm):
contact_details_type = RadioField(
"Type of contact details",
@@ -1475,6 +1564,65 @@ class ServiceSwitchChannelForm(ServiceOnOffSettingForm):
super().__init__(name, *args, **kwargs)
class AdminSetEmailBrandingForm(StripWhitespaceForm):
branding_style = GovukRadiosFieldWithNoneOption(
"Branding style",
param_extensions={"fieldset": {"legend": {"classes": "usa-sr-only"}}},
thing="a branding style",
)
DEFAULT = (FieldWithNoneOption.NONE_OPTION_VALUE, "GOV.UK")
def __init__(self, all_branding_options, current_branding):
super().__init__(branding_style=current_branding)
self.branding_style.choices = sorted(
all_branding_options + [self.DEFAULT],
key=lambda branding: (
branding[0] != current_branding,
branding[0] is not self.DEFAULT[0],
branding[1].lower(),
),
)
class AdminPreviewBrandingForm(StripWhitespaceForm):
branding_style = HiddenFieldWithNoneOption("branding_style")
class AdminEditEmailBrandingForm(StripWhitespaceForm):
name = GovukTextInputField("Name of brand")
text = GovukTextInputField("Text")
colour = GovukTextInputField(
"Colour",
validators=[
Regexp(
regex="^$|^#(?:[0-9a-fA-F]{3}){1,2}$",
message="Must be a valid color hex code (starting with #)",
)
],
param_extensions={
"attributes": {"data-module": "colour-preview"},
},
)
file = FileField_wtf(
"Upload a PNG logo", validators=[FileAllowed(["png"], "PNG Images only!")]
)
brand_type = GovukRadiosField(
"Brand type",
choices=[
("both", "GOV.UK and branding"),
("org", "Branding only"),
("org_banner", "Branding banner"),
],
)
def validate_name(self, name):
op = request.form.get("operation")
if op == "email-branding-details" and not self.name.data:
raise ValidationError("This field is required")
class SVGFileUpload(StripWhitespaceForm):
file = FileField_wtf(
"Upload an SVG logo",
@@ -1651,11 +1799,7 @@ def get_placeholder_form_instance(
) # TODO: replace with us_mobile_number
else:
field = GovukTextInputField(
placeholder_name,
validators=[
DataRequired(message="Cannot be empty"),
FieldCannotContainComma(),
],
placeholder_name, validators=[DataRequired(message="Cannot be empty")]
)
PlaceholderForm.placeholder_value = field
@@ -1693,6 +1837,42 @@ class AdminSetOrganizationForm(StripWhitespaceForm):
)
class ChooseBrandingForm(StripWhitespaceForm):
FALLBACK_OPTION_VALUE = "something_else"
FALLBACK_OPTION = (FALLBACK_OPTION_VALUE, "Something else")
@property
def something_else_is_only_option(self):
return self.options.choices == (self.FALLBACK_OPTION,)
class ChooseEmailBrandingForm(ChooseBrandingForm):
options = RadioField("Choose your new email branding")
def __init__(self, service):
super().__init__()
self.options.choices = tuple(
list(branding.get_email_choices(service)) + [self.FALLBACK_OPTION]
)
class SomethingElseBrandingForm(StripWhitespaceForm):
something_else = GovukTextareaField(
"Describe the branding you want",
validators=[DataRequired("Cannot be empty")],
param_extensions={
"label": {
"isPageHeading": True,
"classes": "font-body-xl",
},
"hint": {
"text": "Include links to your brand guidelines or examples of how to use your branding."
},
},
)
class AdminServiceAddDataRetentionForm(StripWhitespaceForm):
notification_type = GovukRadiosField(
"What notification type?",
@@ -1800,16 +1980,12 @@ class TemplateAndFoldersSelectionForm(Form):
None,
[
# ('email', 'Email') if 'email' in available_template_types else None,
(
("sms", "Start with a blank template")
if "sms" in available_template_types
else None
),
(
("copy-existing", "Copy an existing template")
if allow_adding_copy_of_template
else None
),
("sms", "Start with a blank template")
if "sms" in available_template_types
else None,
("copy-existing", "Copy an existing template")
if allow_adding_copy_of_template
else None,
],
)
)
@@ -1888,6 +2064,13 @@ class AdminClearCacheForm(StripWhitespaceForm):
raise ValidationError("Select at least one option")
class AdminOrganizationGoLiveNotesForm(StripWhitespaceForm):
request_to_go_live_notes = TextAreaField(
"Go live notes",
filters=[lambda x: x or None],
)
class ChangeSecurityKeyNameForm(StripWhitespaceForm):
security_key_name = GovukTextInputField(
"Name of key",

View File

@@ -1,15 +1,15 @@
import re
from abc import ABC, abstractmethod
from notifications_utils.field import Field
from notifications_utils.formatters import formatted_list
from notifications_utils.recipients import InvalidEmailError, validate_email_address
from notifications_utils.sanitise_text import SanitiseSMS
from wtforms import ValidationError
from app.main._commonly_used_passwords import commonly_used_passwords
from app.models.spreadsheet import Spreadsheet
from app.utils.user import is_gov_user
from notifications_utils.field import Field
from notifications_utils.formatters import formatted_list
from notifications_utils.recipients import InvalidEmailError, validate_email_address
from notifications_utils.sanitise_text import SanitiseSMS
class CommonlyUsedPassword:
@@ -161,15 +161,6 @@ class DoesNotStartWithDoubleZero:
raise ValidationError(self.message)
class FieldCannotContainComma:
def __init__(self, message="Cannot contain a comma"):
self.message = message
def __call__(self, form, field):
if field.data and "," in field.data:
raise ValidationError(self.message)
class MustContainAlphanumericCharacters:
regex = re.compile(r".*[a-zA-Z0-9].*[a-zA-Z0-9].*")

View File

@@ -1,124 +0,0 @@
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("view_activity")
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)
message_type = ("sms",)
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,
download_link_one_day=url_for(
".download_notifications_csv",
service_id=current_service.id,
message_type=message_type,
status=request.args.get("status"),
number_of_days="one_day",
),
download_link_three_day=url_for(
".download_notifications_csv",
service_id=current_service.id,
message_type=message_type,
status=request.args.get("status"),
number_of_days="three_day",
),
download_link_five_day=url_for(
".download_notifications_csv",
service_id=current_service.id,
message_type=message_type,
status=request.args.get("status"),
number_of_days="five_day",
),
download_link_seven_day=url_for(
".download_notifications_csv",
service_id=current_service.id,
message_type=message_type,
status=request.args.get("status"),
number_of_days="seven_day",
),
)
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"],
"delivered_count": next(
(
stat["count"]
for stat in job.get("statistics", [])
if stat["status"] == "delivered"
),
None,
),
"failed_count": next(
(
stat["count"]
for stat in job.get("statistics", [])
if stat["status"] == "failed"
),
None,
),
}
for job in jobs["data"]
]

View File

@@ -41,8 +41,8 @@ def _create_example_template(service_id):
@main.route("/add-service", methods=["GET", "POST"])
@user_is_gov_user
@user_is_logged_in
@user_is_gov_user
def add_service():
default_organization_type = current_user.default_organization_type
if default_organization_type is None:

View File

@@ -1,6 +1,5 @@
from flask import abort, flash, redirect, render_template, request, url_for
from flask import Markup, abort, flash, redirect, render_template, request, url_for
from flask_login import current_user
from markupsafe import Markup
from app import (
api_key_api_client,
@@ -169,14 +168,12 @@ def api_callbacks(service_id):
return render_template(
"views/api/callbacks.html",
received_text_messages_callback=(
received_text_messages_callback["url"]
if received_text_messages_callback
else None
),
delivery_status_callback=(
delivery_status_callback["url"] if delivery_status_callback else None
),
received_text_messages_callback=received_text_messages_callback["url"]
if received_text_messages_callback
else None,
delivery_status_callback=delivery_status_callback["url"]
if delivery_status_callback
else None,
)
@@ -265,11 +262,9 @@ def received_text_messages_callback(service_id):
received_text_messages_callback = get_received_text_messages_callback()
form = CallbackForm(
url=(
received_text_messages_callback.get("url")
if received_text_messages_callback
else ""
),
url=received_text_messages_callback.get("url")
if received_text_messages_callback
else "",
bearer_token=dummy_bearer_token if received_text_messages_callback else "",
)

View File

@@ -1,14 +1,14 @@
from flask import jsonify, redirect, render_template, session, url_for
from flask_login import current_user
from notifications_python_client.errors import HTTPError
from notifications_utils.recipients import format_phone_number_human_readable
from notifications_utils.template import SMSPreviewTemplate
from app import current_service, notification_api_client, service_api_client
from app.main import main
from app.main.forms import SearchByNameForm
from app.models.template_list import TemplateList
from app.utils.user import user_has_permissions
from notifications_utils.recipients import format_phone_number_human_readable
from notifications_utils.template import SMSPreviewTemplate
@main.route("/services/<uuid:service_id>/conversation/<uuid:notification_id>")

View File

@@ -5,18 +5,17 @@ from itertools import groupby
from flask import Response, abort, jsonify, render_template, request, session, url_for
from flask_login import current_user
from notifications_utils.recipients import format_phone_number_human_readable
from werkzeug.utils import redirect
from app import (
billing_api_client,
current_service,
job_api_client,
service_api_client,
template_statistics_client,
)
from app.formatters import format_date_numeric, format_datetime_numeric, get_time_left
from app.formatters import format_date_numeric, format_datetime_numeric
from app.main import main
from app.main.views.user_profile import set_timezone
from app.statistics_utils import get_formatted_percentage
from app.utils import (
DELIVERED_STATUSES,
@@ -28,7 +27,6 @@ from app.utils.csv import Spreadsheet
from app.utils.pagination import generate_next_dict, generate_previous_dict
from app.utils.time import get_current_financial_year
from app.utils.user import user_has_permissions
from notifications_utils.recipients import format_phone_number_human_readable
@main.route("/services/<uuid:service_id>/dashboard")
@@ -40,7 +38,6 @@ def old_service_dashboard(service_id):
@main.route("/services/<uuid:service_id>")
@user_has_permissions()
def service_dashboard(service_id):
if session.get("invited_user_id"):
session.pop("invited_user_id", None)
session["service_id"] = service_id
@@ -48,77 +45,13 @@ def service_dashboard(service_id):
if not current_user.has_permissions("view_activity"):
return redirect(url_for("main.choose_template", service_id=service_id))
yearly_usage = billing_api_client.get_annual_usage_for_service(
service_id,
get_current_financial_year(),
)
free_sms_allowance = billing_api_client.get_free_sms_fragment_limit_for_year(
current_service.id,
)
usage_data = get_annual_usage_breakdown(yearly_usage, free_sms_allowance)
sms_sent = usage_data["sms_sent"]
sms_allowance_remaining = usage_data["sms_allowance_remaining"]
job_response = job_api_client.get_jobs(service_id)["data"]
service_data_retention_days = 7
jobs = [
{
"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"],
"processing_finished": job.get("processing_finished"),
"processing_started": job.get("processing_started"),
"notification_count": job["notification_count"],
"created_by": job["created_by"],
"template_name": job["template_name"],
"original_file_name": job["original_file_name"],
}
for job in job_response
if job["job_status"] != "cancelled"
]
return render_template(
"views/dashboard/dashboard.html",
updates_url=url_for(".service_dashboard_updates", service_id=service_id),
partials=get_dashboard_partials(service_id),
jobs=jobs,
service_data_retention_days=service_data_retention_days,
sms_sent=sms_sent,
sms_allowance_remaining=sms_allowance_remaining,
)
@main.route("/daily_stats.json")
def get_daily_stats():
service_id = session.get("service_id")
date_range = get_stats_date_range()
stats = service_api_client.get_service_notification_statistics_by_day(
service_id, start_date=date_range["start_date"], days=date_range["days"]
)
return jsonify(stats)
@main.route("/daily_stats_by_user.json")
def get_daily_stats_by_user():
service_id = session.get("service_id")
date_range = get_stats_date_range()
user_id = current_user.id
stats = service_api_client.get_user_service_notification_statistics_by_day(
service_id,
user_id,
start_date=date_range["start_date"],
days=date_range["days"],
)
return jsonify(stats)
@main.route("/services/<uuid:service_id>/dashboard.json")
@user_has_permissions("view_activity")
def service_dashboard_updates(service_id):
@@ -166,17 +99,11 @@ def template_usage(service_id):
months=months,
stats=stats,
most_used_template_count=max(
(
max(
(
template["requested_count"]
for template in month["templates_used"]
),
default=0,
)
for month in months
),
default=0,
max(
(template["requested_count"] for template in month["templates_used"]),
default=0,
)
for month in months
),
years=get_tuples_of_financial_years(
partial(url_for, ".template_usage", service_id=service_id),
@@ -193,16 +120,31 @@ def usage(service_id):
year, current_financial_year = requested_and_current_financial_year(request)
free_sms_allowance = billing_api_client.get_free_sms_fragment_limit_for_year(
service_id
service_id, year
)
units = billing_api_client.get_monthly_usage_for_service(service_id, year)
yearly_usage = billing_api_client.get_annual_usage_for_service(service_id, year)
more_stats = format_monthly_stats_to_list(
service_api_client.get_monthly_notification_stats(service_id, year)["data"]
)
if year == current_financial_year:
# This includes Oct, Nov, Dec
# but we don't need next year's data yet
more_stats = [
month
for month in more_stats
if month["name"] in ["October", "November", "December"]
]
elif year == (current_financial_year + 1):
# This is all the other months
# and we need last year's data
more_stats = [
month
for month in more_stats
if month["name"] not in ["October", "November", "December"]
]
return render_template(
"views/usage.html",
months=list(get_monthly_usage_breakdown(year, units, more_stats)),
@@ -325,19 +267,13 @@ def aggregate_template_usage(template_statistics, sort_key="count"):
key=lambda x: x["template_id"],
):
template_stats = list(v)
first_stat = template_stats[0] if template_stats else None
templates.append(
{
"template_id": k,
"template_name": first_stat.get("template_name"),
"template_type": first_stat.get("template_type"),
"template_name": template_stats[0]["template_name"],
"template_type": template_stats[0]["template_type"],
"count": sum(s["count"] for s in template_stats),
"created_by": first_stat.get("created_by"),
"created_by_id": first_stat.get("created_by_id"),
"last_used": first_stat.get("last_used"),
"status": first_stat.get("status"),
"template_folder": first_stat.get("template_folder"),
"template_folder_id": first_stat.get("template_folder_id"),
}
)
@@ -370,15 +306,8 @@ def get_dashboard_partials(service_id):
dashboard_totals = (get_dashboard_totals(stats),)
free_sms_allowance = billing_api_client.get_free_sms_fragment_limit_for_year(
current_service.id,
get_current_financial_year(),
)
# These 2 calls will update the dashboard sms allowance count while in trial mode.
billing_api_client.get_monthly_usage_for_service(
service_id, get_current_financial_year()
)
billing_api_client.create_or_update_free_sms_fragment_limit(
service_id, free_sms_fragment_limit=free_sms_allowance
)
yearly_usage = billing_api_client.get_annual_usage_for_service(
service_id,
get_current_financial_year(),
@@ -410,13 +339,11 @@ def get_dashboard_partials(service_id):
def get_dashboard_totals(statistics):
for msg_type in statistics.values():
msg_type["failed_percentage"] = get_formatted_percentage(
msg_type["failed"], msg_type["requested"]
)
msg_type["show_warning"] = float(msg_type["failed_percentage"]) > 3
return statistics
@@ -471,27 +398,13 @@ def aggregate_status_types(counts_dict):
def get_months_for_financial_year(year, time_format="%B"):
return [month.strftime(time_format) for month in (get_months_for_year(1, 13, year))]
def get_current_month_for_financial_year(year):
# Setting the timezone here because we need to set it somewhere.
set_timezone()
current_month = datetime.now().month
return current_month
def get_stats_date_range():
current_financial_year = get_current_financial_year()
current_month = get_current_month_for_financial_year(current_financial_year)
start_date = datetime.now().strftime("%Y-%m-%d")
days = 7
return {
"current_financial_year": current_financial_year,
"current_month": current_month,
"start_date": start_date,
"days": days,
}
return [
month.strftime(time_format)
for month in (
get_months_for_year(10, 13, year) + get_months_for_year(1, 10, year + 1)
)
if month < datetime.now()
]
def get_months_for_year(start, end, year):

View File

@@ -0,0 +1,133 @@
from flask import current_app, redirect, render_template, session, url_for
from app import email_branding_client
from app.main import main
from app.main.forms import AdminEditEmailBrandingForm, SearchByNameForm
from app.s3_client.s3_logo_client import (
TEMP_TAG,
delete_email_temp_file,
delete_email_temp_files_created_by,
permanent_email_logo_name,
persist_logo,
upload_email_logo,
)
from app.utils.user import user_is_platform_admin
@main.route("/email-branding", methods=["GET", "POST"])
@user_is_platform_admin
def email_branding():
brandings = email_branding_client.get_all_email_branding(sort_key="name")
return render_template(
"views/email-branding/select-branding.html",
email_brandings=brandings,
search_form=SearchByNameForm(),
)
@main.route("/email-branding/<uuid:branding_id>/edit", methods=["GET", "POST"])
@main.route("/email-branding/<uuid:branding_id>/edit/<logo>", methods=["GET", "POST"])
@user_is_platform_admin
def update_email_branding(branding_id, logo=None):
email_branding = email_branding_client.get_email_branding(branding_id)[
"email_branding"
]
form = AdminEditEmailBrandingForm(
name=email_branding["name"],
text=email_branding["text"],
colour=email_branding["colour"],
brand_type=email_branding["brand_type"],
)
logo = logo if logo else email_branding.get("logo") if email_branding else None
if form.validate_on_submit():
if form.file.data:
upload_filename = upload_email_logo(
form.file.data.filename, form.file.data, user_id=session["user_id"]
)
if logo and logo.startswith(TEMP_TAG.format(user_id=session["user_id"])):
delete_email_temp_file(logo)
return redirect(
url_for(
".update_email_branding",
branding_id=branding_id,
logo=upload_filename,
)
)
updated_logo_name = (
permanent_email_logo_name(logo, session["user_id"]) if logo else None
)
email_branding_client.update_email_branding(
branding_id=branding_id,
logo=updated_logo_name,
name=form.name.data,
text=form.text.data,
colour=form.colour.data,
brand_type=form.brand_type.data,
)
if logo:
persist_logo(logo, updated_logo_name)
delete_email_temp_files_created_by(session["user_id"])
return redirect(url_for(".email_branding", branding_id=branding_id))
return render_template(
"views/email-branding/manage-branding.html",
form=form,
email_branding=email_branding,
cdn_url=current_app.config["LOGO_CDN_DOMAIN"],
logo=logo,
)
@main.route("/email-branding/create", methods=["GET", "POST"])
@main.route("/email-branding/create/<logo>", methods=["GET", "POST"])
@user_is_platform_admin
def create_email_branding(logo=None):
form = AdminEditEmailBrandingForm(brand_type="org")
if form.validate_on_submit():
if form.file.data:
upload_filename = upload_email_logo(
form.file.data.filename, form.file.data, user_id=session["user_id"]
)
if logo and logo.startswith(TEMP_TAG.format(user_id=session["user_id"])):
delete_email_temp_file(logo)
return redirect(url_for(".create_email_branding", logo=upload_filename))
updated_logo_name = (
permanent_email_logo_name(logo, session["user_id"]) if logo else None
)
email_branding_client.create_email_branding(
logo=updated_logo_name,
name=form.name.data,
text=form.text.data,
colour=form.colour.data,
brand_type=form.brand_type.data,
)
if logo:
persist_logo(logo, updated_logo_name)
delete_email_temp_files_created_by(session["user_id"])
return redirect(url_for(".email_branding"))
return render_template(
"views/email-branding/manage-branding.html",
form=form,
cdn_url=current_app.config["LOGO_CDN_DOMAIN"],
logo=logo,
)

View File

@@ -1,10 +1,239 @@
from flask import render_template
from datetime import datetime
import pytz
from flask import redirect, render_template, request, session, url_for
from flask_login import current_user
from govuk_bank_holidays.bank_holidays import BankHolidays
from notifications_utils.clients.zendesk.zendesk_client import NotifySupportTicket
from app import convert_to_boolean, current_service
from app.extensions import zendesk_client
from app.main import main
from app.utils.user import user_is_logged_in
from app.main.forms import FeedbackOrProblem, SupportRedirect, SupportType, Triage
from app.models.feedback import (
GENERAL_TICKET_TYPE,
PROBLEM_TICKET_TYPE,
QUESTION_TICKET_TYPE,
)
from app.utils import hide_from_search_engines
bank_holidays = BankHolidays(use_cached_holidays=True)
@main.route("/support", methods=["GET"])
@user_is_logged_in
@main.route("/support", methods=["GET", "POST"])
@hide_from_search_engines
def support():
return render_template("views/support/index.html")
if current_user.is_authenticated:
form = SupportType()
if form.validate_on_submit():
return redirect(
url_for(
".feedback",
ticket_type=form.support_type.data,
)
)
else:
form = SupportRedirect()
if form.validate_on_submit():
if form.who.data == "public":
return redirect(url_for(".support_public"))
else:
return redirect(
url_for(
".feedback",
ticket_type=GENERAL_TICKET_TYPE,
)
)
return render_template("views/support/index.html", form=form)
@main.route("/support/public")
@hide_from_search_engines
def support_public():
return render_template("views/support/public.html")
@main.route("/support/triage", methods=["GET", "POST"])
@main.route("/support/triage/<ticket_type:ticket_type>", methods=["GET", "POST"])
@hide_from_search_engines
def triage(ticket_type=PROBLEM_TICKET_TYPE):
form = Triage()
if form.validate_on_submit():
return redirect(
url_for(".feedback", ticket_type=ticket_type, severe=form.severe.data)
)
return render_template(
"views/support/triage.html",
form=form,
page_title={
PROBLEM_TICKET_TYPE: "Report a problem",
GENERAL_TICKET_TYPE: "Contact Notify.gov support",
}.get(ticket_type),
)
@main.route("/support/<ticket_type:ticket_type>", methods=["GET", "POST"])
@hide_from_search_engines
def feedback(ticket_type):
form = FeedbackOrProblem()
if not form.feedback.data:
form.feedback.data = session.pop("feedback_message", "")
if request.args.get("severe") in ["yes", "no"]:
severe = convert_to_boolean(request.args.get("severe"))
else:
severe = None
out_of_hours_emergency = all(
(
ticket_type != QUESTION_TICKET_TYPE,
not in_business_hours(),
severe,
)
)
if needs_triage(ticket_type, severe):
session["feedback_message"] = form.feedback.data
return redirect(url_for(".triage", ticket_type=ticket_type))
if needs_escalation(ticket_type, severe):
return redirect(url_for(".bat_phone"))
if current_user.is_authenticated:
form.email_address.data = current_user.email_address
form.name.data = current_user.name
if form.validate_on_submit():
user_email = form.email_address.data
user_name = form.name.data or None
feedback_msg = render_template(
"support-tickets/support-ticket.txt",
content=form.feedback.data,
)
ticket = NotifySupportTicket(
subject="Notify feedback",
message=feedback_msg,
ticket_type=get_zendesk_ticket_type(ticket_type),
p1=out_of_hours_emergency,
user_name=user_name,
user_email=user_email,
org_id=current_service.organization_id if current_service else None,
org_type=current_service.organization_type if current_service else None,
service_id=current_service.id if current_service else None,
)
zendesk_client.send_ticket_to_zendesk(ticket)
return redirect(
url_for(
".thanks",
out_of_hours_emergency=out_of_hours_emergency,
email_address_provided=(
current_user.is_authenticated or bool(form.email_address.data)
),
)
)
return render_template(
"views/support/form.html",
form=form,
back_link=(
url_for(".support")
if severe is None
else url_for(".triage", ticket_type=ticket_type)
),
show_status_page_banner=(ticket_type == PROBLEM_TICKET_TYPE),
page_title={
GENERAL_TICKET_TYPE: "Contact Notify.gov support",
PROBLEM_TICKET_TYPE: "Report a problem",
QUESTION_TICKET_TYPE: "Ask a question or give feedback",
}.get(ticket_type),
)
@main.route("/support/escalate", methods=["GET", "POST"])
@hide_from_search_engines
def bat_phone():
if current_user.is_authenticated:
return redirect(url_for("main.feedback", ticket_type=PROBLEM_TICKET_TYPE))
return render_template("views/support/bat-phone.html")
@main.route("/support/thanks", methods=["GET", "POST"])
@hide_from_search_engines
def thanks():
return render_template(
"views/support/thanks.html",
out_of_hours_emergency=convert_to_boolean(
request.args.get("out_of_hours_emergency")
),
email_address_provided=convert_to_boolean(
request.args.get("email_address_provided")
),
out_of_hours=not in_business_hours(),
)
def in_business_hours():
now = datetime.utcnow().replace(tzinfo=pytz.utc)
if is_weekend(now) or is_bank_holiday(now):
return False
return london_time_today_as_utc(9, 30) <= now < london_time_today_as_utc(17, 30)
def london_time_today_as_utc(hour, minute):
return (
pytz.timezone("Europe/London")
.localize(datetime.now().replace(hour=hour, minute=minute))
.astimezone(pytz.utc)
)
def is_weekend(time):
return time.strftime("%A") in {
"Saturday",
"Sunday",
}
def is_bank_holiday(time):
return bank_holidays.is_holiday(time.date())
def needs_triage(ticket_type, severe):
return all(
(
ticket_type != QUESTION_TICKET_TYPE,
severe is None,
(not current_user.is_authenticated or current_user.live_services),
not in_business_hours(),
)
)
def needs_escalation(ticket_type, severe):
return all(
(
ticket_type != QUESTION_TICKET_TYPE,
severe,
not current_user.is_authenticated,
not in_business_hours(),
)
)
def get_zendesk_ticket_type(ticket_type):
# Zendesk has 4 ticket types - "problem", "incident", "task" and "question".
# We don't want to use a Zendesk "problem" ticket type when someone reports a
# Notify problem because they are designed to group multiple incident tickets together,
# allowing them to be solved as a group.
if ticket_type == PROBLEM_TICKET_TYPE:
return NotifySupportTicket.TYPE_INCIDENT
return NotifySupportTicket.TYPE_QUESTION

View File

@@ -1,33 +1,21 @@
import os
import secrets
from urllib.parse import unquote
from flask import abort, current_app, redirect, render_template, request, url_for
from flask_login import current_user
from app import redis_client, status_api_client
from app.formatters import apply_html_class, convert_markdown_template
from app.main import main
from app.main.views.pricing import CURRENT_SMS_RATE
from app.main.views.sub_navigation_dictionaries import (
best_practices_nav,
features_nav,
using_notify_nav,
from flask import (
abort,
current_app,
make_response,
redirect,
render_template,
request,
url_for,
)
from flask_login import current_user
from notifications_utils.template import HTMLEmailTemplate
from app import email_branding_client, status_api_client
from app.main import main
from app.main.forms import FieldWithNoneOption
from app.main.views.pricing import CURRENT_SMS_RATE
from app.main.views.sub_navigation_dictionaries import features_nav, using_notify_nav
from app.utils.user import user_is_logged_in
from notifications_utils.url_safe_token import generate_token
# Hook to check for guidance routes
@main.before_request
def check_guidance_feature():
current_app.logger.warning("best practices 1234")
current_app.logger.warning(current_app.config["FEATURE_BEST_PRACTICES_ENABLED"])
if (
request.path.startswith("/best-practices")
and not current_app.config["FEATURE_BEST_PRACTICES_ENABLED"]
):
abort(404)
@main.route("/")
@@ -35,29 +23,10 @@ def index():
if current_user and current_user.is_authenticated:
return redirect(url_for("main.choose_account"))
# make and store the state
state = generate_token(
str(request.remote_addr),
current_app.config["SECRET_KEY"],
current_app.config["DANGEROUS_SALT"],
)
state_key = f"login-state-{unquote(state)}"
redis_client.set(state_key, state)
# make and store the nonce
nonce = secrets.token_urlsafe()
nonce_key = f"login-nonce-{unquote(nonce)}"
redis_client.set(nonce_key, nonce)
url = os.getenv("LOGIN_DOT_GOV_INITIAL_SIGNIN_URL")
if url is not None:
url = url.replace("NONCE", nonce)
url = url.replace("STATE", state)
return render_template(
"views/signedout.html",
sms_rate=CURRENT_SMS_RATE,
counts=status_api_client.get_count_of_live_services_and_organizations(),
initial_signin_url=url,
)
@@ -94,6 +63,105 @@ def design_content():
)
@main.route("/_email")
@user_is_logged_in
def email_template():
branding_type = "govuk"
branding_style = request.args.get("branding_style", None)
if branding_style == FieldWithNoneOption.NONE_OPTION_VALUE:
branding_style = None
if branding_style is not None:
email_branding = email_branding_client.get_email_branding(branding_style)[
"email_branding"
]
branding_type = email_branding["brand_type"]
if branding_type == "govuk":
brand_text = None
brand_colour = None
brand_logo = None
govuk_banner = True
brand_banner = False
brand_name = None
else:
colour = email_branding["colour"]
brand_text = email_branding["text"]
brand_colour = colour
brand_logo = (
f"https://{current_app.config['LOGO_CDN_DOMAIN']}/{email_branding['logo']}"
if email_branding["logo"]
else None
)
govuk_banner = branding_type in ["govuk", "both"]
brand_banner = branding_type == "org_banner"
brand_name = email_branding["name"]
template = {
"template_type": "email",
"subject": "Email branding preview",
"content": (
"Lorem Ipsum is simply dummy text of the printing and typesetting "
"industry.\n\nLorem Ipsum has been the industrys standard dummy "
"text ever since the 1500s, when an unknown printer took a galley "
"of type and scrambled it to make a type specimen book. "
"\n\n"
"# History"
"\n\n"
"It has "
"survived not only"
"\n\n"
"* five centuries"
"\n"
"* but also the leap into electronic typesetting"
"\n\n"
"It was "
"popularised in the 1960s with the release of Letraset sheets "
"containing Lorem Ipsum passages, and more recently with desktop "
"publishing software like Aldus PageMaker including versions of "
"Lorem Ipsum."
"\n\n"
"^ It is a long established fact that a reader will be distracted "
"by the readable content of a page when looking at its layout."
"\n\n"
"The point of using Lorem Ipsum is that it has a more-or-less "
"normal distribution of letters, as opposed to using Content "
"here, content here, making it look like readable English."
"\n\n\n"
"1. One"
"\n"
"2. Two"
"\n"
"10. Three"
"\n\n"
"This is an example of an email sent using Notify.gov."
"\n\n"
"https://www.notifications.service.gov.uk"
),
}
if not bool(request.args):
resp = make_response(str(HTMLEmailTemplate(template)))
else:
resp = make_response(
str(
HTMLEmailTemplate(
template,
govuk_banner=govuk_banner,
brand_text=brand_text,
brand_colour=brand_colour,
brand_logo=brand_logo,
brand_banner=brand_banner,
brand_name=brand_name,
)
)
)
resp.headers["X-Frame-Options"] = "SAMEORIGIN"
return resp
@main.route("/documentation")
@user_is_logged_in
def documentation():
@@ -128,6 +196,14 @@ def roadmap():
return render_template("views/roadmap.html", navigation_links=features_nav())
@main.route("/features/email")
@user_is_logged_in
def features_email():
return render_template(
"views/features/emails.html", navigation_links=features_nav()
)
@main.route("/features/sms")
@user_is_logged_in
def features_sms():
@@ -142,7 +218,16 @@ def security():
return render_template("views/security.html", navigation_links=features_nav())
@main.route("/features/using_notify")
@main.route("/features/terms", endpoint="terms")
@user_is_logged_in
def terms():
return render_template(
"views/terms-of-use.html",
navigation_links=features_nav(),
)
@main.route("/features/using-notify")
@user_is_logged_in
def using_notify():
return (
@@ -169,20 +254,9 @@ def get_started_old():
@main.route("/using-notify/get-started")
@user_is_logged_in
def get_started():
markdown = convert_markdown_template("get-started")
html_style = apply_html_class(
[
["ol", "usa-process-list"],
["li", "usa-process-list__item padding-bottom-4 margin-top-2"],
["h2", "usa-process-list__heading line-height-sans-3"],
],
markdown,
)
return render_template(
"views/get-started.html",
navigation_links=using_notify_nav(),
content=html_style,
)
@@ -205,78 +279,21 @@ def trial_mode_new():
)
@main.route("/best-practices")
@user_is_logged_in
def best_practices():
return render_template(
"views/best-practices/best-practices.html",
navigation_links=best_practices_nav(),
)
@main.route("/best-practices/clear-goals")
@user_is_logged_in
def clear_goals():
return render_template(
"views/best-practices/clear-goals.html",
navigation_links=best_practices_nav(),
)
@main.route("/best-practices/rules-and-regulations")
@user_is_logged_in
def rules_and_regulations():
return render_template(
"views/best-practices/rules-and-regulations.html",
navigation_links=best_practices_nav(),
)
@main.route("/best-practices/establish-trust")
@user_is_logged_in
def establish_trust():
return render_template(
"views/best-practices/establish-trust.html",
navigation_links=best_practices_nav(),
)
@main.route("/best-practices/write-for-action")
@user_is_logged_in
def write_for_action():
return render_template(
"views/best-practices/write-for-action.html",
navigation_links=best_practices_nav(),
)
@main.route("/best-practices/multiple-languages")
@user_is_logged_in
def multiple_languages():
return render_template(
"views/best-practices/multiple-languages.html",
navigation_links=best_practices_nav(),
)
@main.route("/best-practices/benchmark-performance")
@user_is_logged_in
def benchmark_performance():
return render_template(
"views/best-practices/benchmark-performance.html",
navigation_links=best_practices_nav(),
)
@main.route("/using-notify/guidance")
@user_is_logged_in
def guidance_index():
return render_template(
"views/guidance/index.html",
navigation_links=using_notify_nav(),
feature_best_practices_enabled=current_app.config[
"FEATURE_BEST_PRACTICES_ENABLED"
],
)
@main.route("/using-notify/guidance/branding-and-customisation")
@user_is_logged_in
def branding_and_customisation():
return render_template(
"views/guidance/branding-and-customisation.html",
navigation_links=using_notify_nav(),
)
@@ -311,6 +328,7 @@ def send_files_by_email():
@main.route("/roadmap", endpoint="old_roadmap")
@main.route("/terms", endpoint="old_terms")
@main.route("/information-security", endpoint="information_security")
@main.route("/using_notify", endpoint="old_using_notify")
@main.route("/information-risk-management", endpoint="information_risk_management")
@@ -318,6 +336,7 @@ def send_files_by_email():
def old_page_redirects():
redirects = {
"main.old_roadmap": "main.roadmap",
"main.old_terms": "main.terms",
"main.information_security": "main.using_notify",
"main.old_using_notify": "main.using_notify",
"main.information_risk_management": "main.security",

View File

@@ -17,12 +17,14 @@ def accept_invite(token):
and current_user.email_address.lower() != invited_user.email_address.lower()
):
message = Markup(
f"""
Youre signed in as {current_user.email_address}.
This invite is for another email address.
<a href={url_for("main.sign_out")} class="usa-link">Sign out</a>
and click the link again to accept this invite.
"""
Youre signed in as {}.
This invite is for another email address.
<a href={} class="usa-link">Sign out</a>
and click the link again to accept this invite.
""".format(
current_user.email_address, url_for("main.sign_out")
)
)
flash(message=message)

View File

@@ -3,22 +3,22 @@
from functools import partial
from flask import (
Markup,
Response,
abort,
jsonify,
redirect,
render_template,
request,
session,
stream_with_context,
url_for,
)
from flask_login import current_user
from markupsafe import Markup
from notifications_utils.template import EmailPreviewTemplate, SMSBodyPreviewTemplate
from app import (
current_service,
format_datetime_table,
format_datetime_short,
notification_api_client,
service_api_client,
)
@@ -34,7 +34,6 @@ from app.utils.pagination import (
get_page_from_request,
)
from app.utils.user import user_has_permissions
from notifications_utils.template import EmailPreviewTemplate, SMSBodyPreviewTemplate
@main.route("/services/<uuid:service_id>/jobs")
@@ -94,7 +93,7 @@ def view_job_csv(service_id, job_id):
mimetype="text/csv",
headers={
"Content-Disposition": 'inline; filename="{} - {}.csv"'.format(
job.template["name"], format_datetime_table(job.created_at)
job.template["name"], format_datetime_short(job.created_at)
)
},
)
@@ -143,40 +142,11 @@ def view_notifications(service_id, message_type=None):
True: ["reference"],
False: [],
}.get(bool(current_service.api_keys)),
download_link_one_day=url_for(
download_link=url_for(
".download_notifications_csv",
service_id=current_service.id,
message_type=message_type,
status=request.args.get("status"),
number_of_days="one_day",
),
download_link_today=url_for(
".download_notifications_csv",
service_id=current_service.id,
message_type=message_type,
status=request.args.get("status"),
number_of_days="today",
),
download_link_three_day=url_for(
".download_notifications_csv",
service_id=current_service.id,
message_type=message_type,
status=request.args.get("status"),
number_of_days="three_day",
),
download_link_five_day=url_for(
".download_notifications_csv",
service_id=current_service.id,
message_type=message_type,
status=request.args.get("status"),
number_of_days="five_day",
),
download_link_seven_day=url_for(
".download_notifications_csv",
service_id=current_service.id,
message_type=message_type,
status=request.args.get("status"),
number_of_days="seven_day",
),
)
@@ -212,9 +182,10 @@ def get_notifications(service_id, message_type, status_override=None): # noqa
filter_args["status"] = set_status_filters(filter_args)
service_data_retention_days = None
search_term = request.form.get("to", "")
if message_type is not None:
service_data_retention_days = current_service.get_days_of_retention(
message_type, number_of_days="seven_day"
message_type
)
if request.path.endswith("csv") and current_user.has_permissions("view_activity"):
@@ -240,6 +211,7 @@ def get_notifications(service_id, message_type, status_override=None): # noqa
)
url_args = {"message_type": message_type, "status": request.args.get("status")}
prev_page = None
if "links" in notifications and notifications["links"].get("prev", None):
prev_page = generate_previous_dict(
"main.view_notifications", service_id, page, url_args=url_args
@@ -260,6 +232,7 @@ def get_notifications(service_id, message_type, status_override=None): # noqa
)
else:
download_link = None
return {
"service_data_retention_days": service_data_retention_days,
"counts": render_template(
@@ -303,16 +276,12 @@ def get_status_filters(service, message_type, statistics):
}
else:
stats = statistics[message_type]
if stats.get("failure") is not None:
stats["failed"] = stats["failure"]
stats["pending"] = stats["requested"] - stats["delivered"] - stats["failed"]
stats["sending"] = stats["requested"] - stats["delivered"] - stats["failed"]
filters = [
# key, label, option
("requested", "total", "sending,delivered,failed"),
("pending", "pending", "sending,pending"),
("sending", "pending", "pending"),
("delivered", "delivered", "delivered"),
("failed", "failed", "failed"),
]
@@ -327,7 +296,7 @@ def get_status_filters(service, message_type, statistics):
message_type=message_type,
status=option,
),
stats.get(key),
stats[key],
)
for key, label, option in filters
]
@@ -388,7 +357,6 @@ def get_job_partials(job):
filter_args = parse_filter_args(request.args)
filter_args["status"] = set_status_filters(filter_args)
notifications = job.get_notifications(status=filter_args["status"])
number_of_days = "seven_day"
counts = render_template(
"partials/count.html",
counts=_get_job_counts(job),
@@ -398,16 +366,9 @@ def get_job_partials(job):
),
)
service_data_retention_days = current_service.get_days_of_retention(
job.template_type, number_of_days
job.template_type
)
if request.referrer is not None:
session["arrived_from_preview_page"] = "check" in request.referrer
else:
session["arrived_from_preview_page"] = False
arrived_from_preview_page_url = session.get("arrived_from_preview_page", False)
return {
"counts": counts,
"notifications": render_template(
@@ -431,7 +392,6 @@ def get_job_partials(job):
"status": render_template(
"partials/jobs/status.html",
job=job,
arrived_from_preview_page_url=arrived_from_preview_page_url,
),
}

View File

@@ -9,7 +9,6 @@ from app.event_handlers import (
create_invite_user_to_service_event,
create_mobile_number_change_event,
create_remove_user_from_service_event,
create_resend_user_invite_to_service_event,
)
from app.formatters import redact_mobile_number
from app.main import main
@@ -46,6 +45,7 @@ def manage_users(service_id):
@user_has_permissions("manage_service")
def invite_user(service_id, user_id=None):
form_class = InviteUserForm
form = form_class(
inviter_email_address=current_user.email_address,
all_template_folders=current_service.all_template_folders,
@@ -77,11 +77,6 @@ def invite_user(service_id, user_id=None):
form.login_authentication.data = "sms_auth"
if form.validate_on_submit():
if not form.permissions or len(form.permissions) == 0:
return render_template(
"views/user-has-no-permissions.html",
user_to_invite=user_to_invite,
)
email_address = form.email_address.data
invited_user = InvitedUser.create(
current_user.id,
@@ -127,18 +122,16 @@ def edit_user_permissions(service_id, user_id):
form = form_class.from_user(
user,
service_id,
folder_permissions=(
None
if user.platform_admin
else [
f["id"]
for f in current_service.all_template_folders
if user.has_template_folder_permission(f)
]
),
all_template_folders=(
None if user.platform_admin else current_service.all_template_folders
),
folder_permissions=None
if user.platform_admin
else [
f["id"]
for f in current_service.all_template_folders
if user.has_template_folder_permission(f)
],
all_template_folders=None
if user.platform_admin
else current_service.all_template_folders,
)
if form.validate_on_submit():
@@ -339,22 +332,3 @@ def cancel_invited_user(service_id, invited_user_id):
flash(f"Invitation cancelled for {invited_user.email_address}", "default_with_tick")
return redirect(url_for("main.manage_users", service_id=service_id))
@main.route(
"/services/<uuid:service_id>/resend-invite/<uuid:invited_user_id>",
methods=["GET"],
)
@user_has_permissions("manage_service")
def resend_invite(service_id, invited_user_id):
current_service.resend_invite(invited_user_id)
invited_user = InvitedUser.by_id_and_service_id(service_id, invited_user_id)
create_resend_user_invite_to_service_event(
email_address=invited_user.email_address,
resent_by_id=current_user.id,
service_id=service_id,
)
flash(f"Invitation resent for {invited_user.email_address}", "default_with_tick")
return redirect(url_for("main.manage_users", service_id=service_id))

View File

@@ -10,12 +10,12 @@ from flask import (
url_for,
)
from itsdangerous import SignatureExpired
from notifications_utils.url_safe_token import check_token
from app.main import main
from app.main.forms import NewPasswordForm
from app.models.user import User
from app.utils.login import log_in_user
from notifications_utils.url_safe_token import check_token
@main.route("/new-password/<path:token>", methods=["GET", "POST"])

View File

@@ -3,19 +3,21 @@ from datetime import datetime
from flask import (
Response,
flash,
jsonify,
render_template,
request,
stream_with_context,
url_for,
)
from flask_login import current_user
from app import current_service, job_api_client, notification_api_client
from app import (
current_service,
format_date_numeric,
job_api_client,
notification_api_client,
)
from app.main import main
from app.notify_client.api_key_api_client import KEY_TYPE_TEST
from app.s3_client.s3_csv_client import delete_report, report_upload
from app.utils import (
DELIVERED_STATUSES,
FAILURE_STATUSES,
@@ -23,23 +25,21 @@ from app.utils import (
parse_filter_args,
set_status_filters,
)
from app.utils.csv import generate_notifications_csv, get_user_preferred_timezone
from app.utils.csv import generate_notifications_csv
from app.utils.templates import get_template
from app.utils.user import user_has_permissions
@main.route("/services/<uuid:service_id>/notification/<uuid:notification_id>")
@user_has_permissions("view_activity", "send_messages")
def view_notification(service_id, notification_id, error_message=None):
if error_message:
flash(error_message)
def view_notification(service_id, notification_id):
notification = notification_api_client.get_notification(
service_id, str(notification_id)
)
notification["template"].update({"reply_to_text": notification["reply_to_text"]})
personalisation = get_all_personalisation_from_notification(notification)
error_message = None
template = get_template(
notification["template"],
@@ -92,7 +92,7 @@ def view_notification(service_id, notification_id, error_message=None):
partials=get_single_notification_partials(notification),
created_by=notification.get("created_by"),
created_at=notification["created_at"],
updated_at=notification["sent_at"],
updated_at=notification["updated_at"],
help=get_help_argument(),
notification_id=notification["id"],
can_receive_inbound=(current_service.has_permission("inbound_sms")),
@@ -139,43 +139,10 @@ def get_all_personalisation_from_notification(notification):
def download_notifications_csv(service_id):
filter_args = parse_filter_args(request.args)
filter_args["status"] = set_status_filters(filter_args)
number_of_days = request.args["number_of_days"]
service_data_retention_days = current_service.get_days_of_retention(
filter_args.get("message_type")[0], number_of_days
filter_args.get("message_type")[0]
)
file_time = datetime.now().strftime("%Y-%m-%d %I:%M:%S %p")
file_time = f"{file_time} {get_user_preferred_timezone()}"
csv = generate_notifications_csv(
service_id=service_id,
job_id=None,
status=filter_args.get("status"),
page=request.args.get("page", 1),
page_size=10000,
format_for_csv=True,
template_type=filter_args.get("message_type"),
limit_days=service_data_retention_days,
)
# START asynchronous reporting block
csv_file = "".join(csv)
file_location = f"reports/{service_id}/{current_user.id}/{service_data_retention_days}/report.csv"
# TODO these are some capabilities we will probably need when
# report generation becomes asynchronous.
# old_content = report_download(file_location)
# current_app.logger.info(f"OLD CONTENT IS {old_content}")
# reports = get_downloadable_reports(current_user.id, service_id)
# TODO these are to support asynchronous report generation.
# Leaving them commented in so they get exercised.
delete_report(file_location)
report_upload(file_location, csv_file)
# END asynchronous reporting block
# TODO eventually we want to remove this, when reports become fully asynchronous
# The UI should be retrieving the report elsewhere via the download_report() method call
return Response(
stream_with_context(
generate_notifications_csv(
@@ -192,7 +159,7 @@ def download_notifications_csv(service_id):
mimetype="text/csv",
headers={
"Content-Disposition": 'inline; filename="{} - {} - {} report.csv"'.format(
file_time,
format_date_numeric(datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ")),
filter_args["message_type"][0],
current_service.name,
)

View File

@@ -6,13 +6,21 @@ from flask import flash, redirect, render_template, request, url_for
from flask_login import current_user
from notifications_python_client.errors import HTTPError
from app import current_organization, org_invite_api_client, organizations_client
from app import (
current_organization,
email_branding_client,
org_invite_api_client,
organizations_client,
)
from app.main import main
from app.main.forms import (
AdminBillingDetailsForm,
AdminNewOrganizationForm,
AdminNotesForm,
AdminOrganizationDomainsForm,
AdminOrganizationGoLiveNotesForm,
AdminPreviewBrandingForm,
AdminSetEmailBrandingForm,
InviteOrgUserForm,
OrganizationOrganizationTypeForm,
RenameOrganizationForm,
@@ -23,6 +31,7 @@ from app.main.views.dashboard import (
get_tuples_of_financial_years,
requested_and_current_financial_year,
)
from app.main.views.service_settings import get_branding_as_value_and_label
from app.models.organization import AllOrganizations, Organization
from app.models.user import InvitedOrgUser, User
from app.utils.csv import Spreadsheet
@@ -274,6 +283,58 @@ def edit_organization_type(org_id):
)
@main.route(
"/organizations/<uuid:org_id>/settings/set-email-branding", methods=["GET", "POST"]
)
@user_is_platform_admin
def edit_organization_email_branding(org_id):
email_branding = email_branding_client.get_all_email_branding()
form = AdminSetEmailBrandingForm(
all_branding_options=get_branding_as_value_and_label(email_branding),
current_branding=current_organization.email_branding_id,
)
if form.validate_on_submit():
return redirect(
url_for(
".organization_preview_email_branding",
org_id=org_id,
branding_style=form.branding_style.data,
)
)
return render_template(
"views/organizations/organization/settings/set-email-branding.html",
form=form,
search_form=SearchByNameForm(),
)
@main.route(
"/organizations/<uuid:org_id>/settings/preview-email-branding",
methods=["GET", "POST"],
)
@user_is_platform_admin
def organization_preview_email_branding(org_id):
branding_style = request.args.get("branding_style", None)
form = AdminPreviewBrandingForm(branding_style=branding_style)
if form.validate_on_submit():
current_organization.update(
email_branding_id=form.branding_style.data,
delete_services_cache=True,
)
return redirect(url_for(".organization_settings", org_id=org_id))
return render_template(
"views/organizations/organization/settings/preview-email-branding.html",
form=form,
action=url_for("main.organization_preview_email_branding", org_id=org_id),
)
@main.route(
"/organizations/<uuid:org_id>/settings/edit-organization-domains",
methods=["GET", "POST"],
@@ -312,6 +373,28 @@ def edit_organization_domains(org_id):
)
@main.route(
"/organizations/<uuid:org_id>/settings/edit-go-live-notes", methods=["GET", "POST"]
)
@user_is_platform_admin
def edit_organization_go_live_notes(org_id):
form = AdminOrganizationGoLiveNotesForm()
if form.validate_on_submit():
organizations_client.update_organization(
org_id, request_to_go_live_notes=form.request_to_go_live_notes.data
)
return redirect(url_for(".organization_settings", org_id=org_id))
org = organizations_client.get_organization(org_id)
form.request_to_go_live_notes.data = org["request_to_go_live_notes"]
return render_template(
"views/organizations/organization/settings/edit-go-live-notes.html",
form=form,
)
@main.route("/organizations/<uuid:org_id>/settings/notes", methods=["GET", "POST"])
@user_is_platform_admin
def edit_organization_notes(org_id):

View File

@@ -8,15 +8,13 @@ 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(preferred_tz) - timedelta(days=7)).date(),
end_date=datetime.now(preferred_tz).date(),
start_date=(datetime.now(pytz.utc) - timedelta(days=7)).date(),
end_date=datetime.now(pytz.utc).date(),
)
stats["organizations_using_notify"] = sorted(
[
@@ -34,8 +32,7 @@ def performance():
stats["average_percentage_under_10_seconds"] = mean(
[row["percentage_under_10_seconds"] for row in stats["processing_time"]] or [0]
)
stats["count_of_live_services_and_organizations"] = (
status_api_client.get_count_of_live_services_and_organizations()
)
stats[
"count_of_live_services_and_organizations"
] = status_api_client.get_count_of_live_services_and_organizations()
return render_template("views/performance.html", **stats)

View File

@@ -1,20 +1,9 @@
import csv
import itertools
import json
from collections import OrderedDict
from datetime import datetime
from io import StringIO
from flask import (
Response,
abort,
current_app,
flash,
render_template,
request,
session,
url_for,
)
from flask import abort, flash, render_template, request, url_for
from notifications_python_client.errors import HTTPError
from app import (
@@ -34,7 +23,6 @@ from app.main.forms import (
DateFilterForm,
RequiredDateFilterForm,
)
from app.main.views.send import _send_notification
from app.statistics_utils import (
get_formatted_percentage,
get_formatted_percentage_two_dp,
@@ -82,40 +70,6 @@ def platform_admin():
)
@main.route("/platform-admin/download-all-users")
@user_is_platform_admin
def download_all_users():
# Create a CSV string from the user data
users = user_api_client.get_all_users_detailed()
if len(users) == 0:
return "No data to download."
output = StringIO()
header = ["Name", "Email Address", "Phone Number", "Service"]
fieldnames = ["name", "email_address", "mobile_number", "service"]
writer = csv.DictWriter(
output,
fieldnames=fieldnames,
delimiter=",",
)
# Write custom header
writer.writerow(dict(zip(fieldnames, header)))
for user in users:
user_no_commas = {key: value.replace(",", "") for key, value in user.items()}
if user_no_commas["name"].startswith("e2e"):
continue
writer.writerow(user_no_commas)
csv_data = output.getvalue()
# Create a direct download response with the CSV data and appropriate headers
response = Response(csv_data, content_type="text/csv; charset=utf-8")
response.headers["Content-Disposition"] = "attachment; filename=users.csv"
return response
def is_over_threshold(number, total, threshold):
percentage = number / total * 100 if total else 0
return percentage > threshold
@@ -642,6 +596,13 @@ def clear_cache():
"service-????????-????-????-????-????????????-template-????????-????-????-????-????????????-versions", # noqa
],
),
(
"email_branding",
[
"email_branding",
"email_branding-????????-????-????-????-????????????",
],
),
(
"organization",
[
@@ -708,21 +669,7 @@ def create_global_stats(services):
"email": {"delivered": 0, "failed": 0, "requested": 0},
"sms": {"delivered": 0, "failed": 0, "requested": 0},
}
# Issue #1323. The back end is now sending 'failure' instead of
# 'failed'. Adjust it here, but keep it flexible in case
# the backend reverts to 'failed'.
for service in services:
if service["statistics"]["sms"].get("failure") is not None:
service["statistics"]["sms"]["failed"] = service["statistics"]["sms"][
"failure"
]
if service["statistics"]["email"].get("failure") is not None:
service["statistics"]["email"]["failed"] = service["statistics"]["email"][
"failure"
]
for service in services:
for msg_type, status in itertools.product(
("sms", "email"), ("delivered", "failed", "requested")
):
@@ -742,6 +689,7 @@ def format_stats_by_service(services):
"name": service["name"],
"stats": service["statistics"],
"restricted": service["restricted"],
"research_mode": service["research_mode"],
"created_at": service["created_at"],
"active": service["active"],
}
@@ -781,80 +729,3 @@ def _get_user_row(r):
row.append(r["password_changed_at"])
row.append(r["state"])
return row
@main.route(
"/platform-admin/load-test",
methods=["POST", "GET"],
)
@user_is_platform_admin
def load_test():
"""
The load test assumes that a service called 'Test service' exists. It will make
the platform admin a member of this service if the platform is not already. All
messagese will be sent in this service.
"""
service = _find_load_test_service()
_prepare_load_test_service(service)
example_template = _find_example_template(service)
# Simulated success
for _ in range(0, 250):
session["recipient"] = current_app.config["SIMULATED_SMS_NUMBERS"][0]
session["placeholders"] = {
"day of week": "Monday",
"color": "blue",
"phone number": current_app.config["SIMULATED_SMS_NUMBERS"][0],
}
_send_notification(service["id"], example_template["id"])
# Simulated failure
for _ in range(0, 250):
session["recipient"] = current_app.config["SIMULATED_SMS_NUMBERS"][1]
session["placeholders"] = {
"day of week": "Wednesday",
"color": "orange",
"phone number": current_app.config["SIMULATED_SMS_NUMBERS"][1],
}
_send_notification(service["id"], example_template["id"])
# For now, just redirect to the splash page so we know it's done
return render_template(
"views/platform-admin/splash-page.html",
)
def _find_example_template(service):
templates = service_api_client.get_service_templates(service["id"])
templates = templates["data"]
for template in templates:
# template = json.loads(template)
if template["name"] == "Example text message template":
return template
raise Exception("Could not find example template for load test")
def _find_load_test_service():
services = service_api_client.find_services_by_name("Test service")
services = services["data"]
for service in services:
if service["name"] == "Test service":
return service
raise Exception("Could not find 'Test service' for load test")
def _prepare_load_test_service(service):
users = user_api_client.get_all_users()
for user in users:
if user["platform_admin"] == "t":
try:
user_api_client.add_user_to_service(
service["id"], user["id"], ["send messages"]
)
except Exception:
current_app.logger.exception(
"Couldnt add user, may already be part of service"
)
pass

View File

@@ -1,11 +1,11 @@
from flask import current_app, render_template
from flask_login import current_user
from notifications_utils.international_billing_rates import INTERNATIONAL_BILLING_RATES
from app.main import main
from app.main.forms import SearchByNameForm
from app.main.views.sub_navigation_dictionaries import using_notify_nav
from app.utils.user import user_is_logged_in
from notifications_utils.international_billing_rates import INTERNATIONAL_BILLING_RATES
CURRENT_SMS_RATE = "1.72"

View File

@@ -0,0 +1,79 @@
from datetime import datetime
from flask import render_template, url_for
from werkzeug.utils import redirect
from app import provider_client
from app.main import main
from app.main.forms import AdminProviderRatioForm
from app.utils.user import user_is_platform_admin
PROVIDER_PRIORITY_MEANING_SWITCHOVER = datetime(2019, 11, 29, 11, 0).isoformat()
@main.route("/providers")
@user_is_platform_admin
def view_providers():
providers = provider_client.get_all_providers()["provider_details"]
domestic_email_providers, domestic_sms_providers, intl_sms_providers = [], [], []
for provider in providers:
if provider["notification_type"] == "sms":
domestic_sms_providers.append(provider)
if provider.get("supports_international", None):
intl_sms_providers.append(provider)
elif provider["notification_type"] == "email":
domestic_email_providers.append(provider)
add_monthly_traffic(domestic_sms_providers)
return render_template(
"views/providers/providers.html",
email_providers=domestic_email_providers,
domestic_sms_providers=domestic_sms_providers,
intl_sms_providers=intl_sms_providers,
)
def add_monthly_traffic(domestic_sms_providers):
total_sms_sent = sum(
provider["current_month_billable_sms"] for provider in domestic_sms_providers
)
for provider in domestic_sms_providers:
percentage = (
(provider["current_month_billable_sms"] / total_sms_sent * 100)
if total_sms_sent
else 0
)
provider["monthly_traffic"] = round(percentage)
@main.route("/provider/edit-sms-provider-ratio", methods=["GET", "POST"])
@user_is_platform_admin
def edit_sms_provider_ratio():
providers = [
provider
for provider in provider_client.get_all_providers()["provider_details"]
if provider["notification_type"] == "sms" and provider["active"]
]
form = AdminProviderRatioForm(providers)
if form.validate_on_submit():
for provider in providers:
field = getattr(form, provider["identifier"])
provider_client.update_provider(provider["id"], field.data)
return redirect(url_for(".view_providers"))
return render_template(
"views/providers/edit-sms-provider-ratio.html", form=form, providers=providers
)
@main.route("/provider/<uuid:provider_id>")
@user_is_platform_admin
def view_provider(provider_id):
versions = provider_client.get_provider_versions(provider_id)
return render_template(
"views/providers/provider.html", provider_versions=versions["data"]
)

Some files were not shown because too many files have changed in this diff Show More