Merge branch 'main' into 2401-add-in-flask-socketio-for-api-calls-rather-than-using-ajax

This commit is contained in:
Carlo Costino
2025-04-16 10:55:16 -04:00
27 changed files with 2015 additions and 1757 deletions
+15 -1
View File
@@ -5,7 +5,8 @@ DATE = $(shell date +%Y-%m-%d:%H:%M:%S)
APP_VERSION_FILE = app/version.py
GIT_BRANCH ?= $(shell git symbolic-ref --short HEAD 2> /dev/null || echo "detached")
GIT_COMMIT ?= $(shell git rev-parse HEAD)
GIT_COMMIT ?= $(shell git rev-parse HEAD 2> /dev/null || echo "")
GIT_HOOKS_PATH ?= $(shell git config --global core.hooksPath || echo "")
## DEVELOPMENT
@@ -23,6 +24,19 @@ bootstrap: ## Set up everything to run the app
createdb test_notification_api || true
(poetry run flask db upgrade) || true
.PHONY: bootstrap-with-git-hooks
bootstrap-with-git-hooks: ## Sets everything up and accounts for pre-existing git hooks
make generate-version-file
poetry self add poetry-dotenv-plugin
poetry lock --no-update
poetry install --sync --no-root
git config --global --unset-all core.hooksPath
poetry run pre-commit install
git config --global core.hookspath "${GIT_HOOKS_PATH}"
createdb notification_api || true
createdb test_notification_api || true
(poetry run flask db upgrade) || true
.PHONY: bootstrap-with-docker
bootstrap-with-docker: ## Build the image to run the app in Docker
docker build -f docker/Dockerfile -t notifications-api .
+1 -1
View File
@@ -33,7 +33,7 @@ class DocumentDownloadClient:
"document": file_contents,
"is_csv": is_csv or False,
},
timeout=30
timeout=30,
)
response.raise_for_status()
@@ -29,7 +29,7 @@ class PerformancePlatformClient:
self.performance_platform_url + payload["dataType"],
json=payload,
headers=headers,
timeout=30
timeout=30,
)
if resp.status_code == 200:
+4 -1
View File
@@ -48,7 +48,10 @@ class AwsSnsClient(SmsClient):
def send_sms(self, to, content, reference, sender=None, international=False):
matched = False
for match in phonenumbers.PhoneNumberMatcher(to, "US"):
if "+" not in to:
to = f"+{to}"
for match in phonenumbers.PhoneNumberMatcher(to, None):
matched = True
to = phonenumbers.format_number(
match.number, phonenumbers.PhoneNumberFormat.E164
+1 -1
View File
@@ -35,7 +35,7 @@ def cronitor(task_name):
params={
"host": current_app.config["API_HOST_NAME"],
},
timeout=30
timeout=30,
)
resp.raise_for_status()
except requests.RequestException as e:
+11
View File
@@ -105,6 +105,17 @@ def send_sms_to_provider(notification):
# The future home of the validation is TBD
_experimentally_validate_phone_numbers(recipient)
# TODO current we allow US phone numbers to be uploaded without the country code (1)
# This will break certain international phone numbers (Norway, Denmark, East Timor)
# When we officially announce support for international numbers, US numbers must contain
# their country code.
recipient = str(recipient)
if len(recipient) == 10:
if os.getenv("NOTIFY_ENVIRONMENT") not in [
"test"
]: # we want to test intl support
recipient = f"1{recipient}"
sender_numbers = get_sender_numbers(notification)
if notification.reply_to_text not in sender_numbers:
raise ValueError(
+1 -1
View File
@@ -2,7 +2,7 @@
Revision ID: 0216_remove_colours
Revises: 0215_email_brand_type
Create Date: 2018-08-24 13:36:49.346156
"""
"""
import sqlalchemy as sa
from alembic import op
@@ -2,7 +2,7 @@
Revision ID: 0219_default_email_branding
Revises: 0216_remove_colours
Create Date: 2018-08-24 13:36:49.346156
"""
"""
from alembic import op
from sqlalchemy import text
@@ -2,7 +2,7 @@
Revision ID: 0220_email_brand_type_non_null
Revises: 0219_default_email_branding
Create Date: 2018-08-24 13:36:49.346156
"""
"""
from alembic import op
@@ -2,7 +2,7 @@
Revision ID: 0221_nullable_service_branding
Revises: 0220_email_brand_type_non_null
Create Date: 2018-08-24 13:36:49.346156
"""
"""
from alembic import op
@@ -2,7 +2,7 @@
Revision ID: 0222_drop_service_branding
Revises: 0221_nullable_service_branding
Create Date: 2018-08-24 13:36:49.346156
"""
"""
import sqlalchemy as sa
from alembic import op
@@ -2,7 +2,7 @@
Revision ID: 0223_add_domain_constraint
Revises: 0222_drop_service_branding
Create Date: 2018-08-24 13:36:49.346156
"""
"""
from alembic import op
+7 -7
View File
@@ -12,15 +12,15 @@ from notifications_utils.sanitise_text import SanitiseSMS
from . import email_with_smart_quotes_regex
OBSCURE_ZERO_WIDTH_WHITESPACE = (
"\u180E" # Mongolian vowel separator
"\u200B" # zero width space
"\u200C" # zero width non-joiner
"\u200D" # zero width joiner
"\u180e" # Mongolian vowel separator
"\u200b" # zero width space
"\u200c" # zero width non-joiner
"\u200d" # zero width joiner
"\u2060" # word joiner
"\uFEFF" # zero width non-breaking space
"\ufeff" # zero width non-breaking space
)
OBSCURE_FULL_WIDTH_WHITESPACE = "\u00A0" # non breaking space
OBSCURE_FULL_WIDTH_WHITESPACE = "\u00a0" # non breaking space
ALL_WHITESPACE = (
string.whitespace + OBSCURE_ZERO_WIDTH_WHITESPACE + OBSCURE_FULL_WIDTH_WHITESPACE
@@ -61,7 +61,7 @@ more_than_two_newlines_in_a_row = re.compile(r"\n{3,}")
def unlink_govuk_escaped(message):
return re.sub(
govuk_not_a_link,
r"\1\2\3" + ".\u200B" + r"\4", # Unicode zero-width space
r"\1\2\3" + ".\u200b" + r"\4", # Unicode zero-width space
message,
)
-2
View File
@@ -628,8 +628,6 @@ def validate_phone_number(number, international=False):
try:
parsed = phonenumbers.parse(number, None)
if parsed.country_code != 1:
raise InvalidPhoneError("Invalid country code")
number = f"{parsed.country_code}{parsed.national_number}"
if len(number) < 8:
raise InvalidPhoneError("Not enough digits")
+6 -6
View File
@@ -15,13 +15,13 @@ class SanitiseText:
"": "'", # RIGHT SINGLE QUOTATION MARK (U+2019)
"": '"', # LEFT DOUBLE QUOTATION MARK (U+201C)
"": '"', # RIGHT DOUBLE QUOTATION MARK (U+201D)
"\u180E": "", # Mongolian vowel separator
"\u200B": "", # zero width space
"\u200C": "", # zero width non-joiner
"\u200D": "", # zero width joiner
"\u180e": "", # Mongolian vowel separator
"\u200b": "", # zero width space
"\u200c": "", # zero width non-joiner
"\u200d": "", # zero width joiner
"\u2060": "", # word joiner
"\uFEFF": "", # zero width non-breaking space
"\u00A0": " ", # NON BREAKING WHITE SPACE (U+200B)
"\ufeff": "", # zero width non-breaking space
"\u00a0": " ", # NON BREAKING WHITE SPACE (U+200B)
"\t": " ", # TAB
}
Generated
+1835 -1602
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -17,7 +17,7 @@ cachetools = "==5.4.0"
celery = {version = "==5.4.0", extras = ["redis"]}
certifi = ">=2022.12.7"
cffi = "==1.17.1"
charset-normalizer = "^3.1.0"
charset-normalizer = "^3.4.1"
click = "==8.1.7"
click-datetime = "==0.4.0"
click-didyoumean = "==0.3.1"
@@ -52,11 +52,11 @@ faker = "^26.0.0"
async-timeout = "^5.0.1"
bleach = "^6.1.0"
geojson = "^3.2.0"
numpy = "^2.2.3"
numpy = "^2.2.4"
ordered-set = "^4.1.0"
phonenumbers = "^8.13.42"
python-json-logger = "^2.0.7"
regex = "^2024.7.24"
regex = "^2024.11.6"
shapely = "^2.0.5"
smartypants = "^2.0.1"
mistune = "^3.1.3"
@@ -86,7 +86,7 @@ black = "^25.1.0"
cloudfoundry-client = "*"
exceptiongroup = "==1.2.2"
flake8 = "^7.2.0"
flake8-bugbear = "^24.1.17"
flake8-bugbear = "^24.12.12"
freezegun = "^1.5.1"
honcho = "*"
isort = "^5.13.2"
+1 -1
View File
@@ -5,7 +5,7 @@ from app import aws_sns_client
def test_send_sms_successful_returns_aws_sns_response(notify_api, mocker):
boto_mock = mocker.patch.object(aws_sns_client, "_client", create=True)
to = "6135555555"
to = "16135555555"
content = reference = "foo"
with notify_api.app_context():
aws_sns_client.send_sms(to, content, reference)
@@ -278,7 +278,7 @@ def test_send_notification_to_queue_throws_exception_deletes_notification(
with pytest.raises(Boto3Error):
send_notification_to_queue(sample_notification, False)
mocked.assert_called_once_with(
[(str(sample_notification.id))], queue="send-sms-tasks", countdown=60
[str(sample_notification.id)], queue="send-sms-tasks", countdown=60
)
assert _get_notification_query_count() == 0
+1 -1
View File
@@ -75,7 +75,7 @@ def test_create_invited_org_user(
# assert len(notification.personalisation["url"]) > len(expected_start_of_invite_url)
mocked.assert_called_once_with(
[(str(notification.id))], queue="notify-internal-tasks", countdown=60
[str(notification.id)], queue="notify-internal-tasks", countdown=60
)
@@ -92,7 +92,7 @@ def test_create_invited_user(
)
mocked.assert_called_once_with(
[(str(notification.id))], queue="notify-internal-tasks", countdown=60
[str(notification.id)], queue="notify-internal-tasks", countdown=60
)
+1 -1
View File
@@ -257,7 +257,7 @@ def test_what_will_trigger_conditional_placeholder(value):
"list: ",
),
(
{"placeholder": [" ", " \t ", "\u180E"]},
{"placeholder": [" ", " \t ", "\u180e"]},
"list: ",
"list: ",
),
+17 -17
View File
@@ -66,7 +66,7 @@ def test_HTML_template_has_URLs_replaced_with_links():
def test_escaping_govuk_in_email_templates():
template_content = "GOV.UK"
expected = "GOV.\u200BUK"
expected = "GOV.\u200bUK"
assert unlink_govuk_escaped(template_content) == expected
template_json = {
"content": template_content,
@@ -81,23 +81,23 @@ def test_escaping_govuk_in_email_templates():
("template_content", "expected"),
[
# Cases that we add the breaking space
("GOV.UK", "GOV.\u200BUK"),
("gov.uk", "gov.\u200Buk"),
("GOV.UK", "GOV.\u200bUK"),
("gov.uk", "gov.\u200buk"),
(
"content with space infront GOV.UK",
"content with space infront GOV.\u200BUK",
"content with space infront GOV.\u200bUK",
),
("content with tab infront\tGOV.UK", "content with tab infront\tGOV.\u200BUK"),
("content with tab infront\tGOV.UK", "content with tab infront\tGOV.\u200bUK"),
(
"content with newline infront\nGOV.UK",
"content with newline infront\nGOV.\u200BUK",
"content with newline infront\nGOV.\u200bUK",
),
("*GOV.UK", "*GOV.\u200BUK"),
("#GOV.UK", "#GOV.\u200BUK"),
("^GOV.UK", "^GOV.\u200BUK"),
(" #GOV.UK", " #GOV.\u200BUK"),
("GOV.UK with CONTENT after", "GOV.\u200BUK with CONTENT after"),
("#GOV.UK with CONTENT after", "#GOV.\u200BUK with CONTENT after"),
("*GOV.UK", "*GOV.\u200bUK"),
("#GOV.UK", "#GOV.\u200bUK"),
("^GOV.UK", "^GOV.\u200bUK"),
(" #GOV.UK", " #GOV.\u200bUK"),
("GOV.UK with CONTENT after", "GOV.\u200bUK with CONTENT after"),
("#GOV.UK with CONTENT after", "#GOV.\u200bUK with CONTENT after"),
# Cases that we don't add the breaking space
("https://gov.uk", "https://gov.uk"),
("https://www.gov.uk", "https://www.gov.uk"),
@@ -366,7 +366,7 @@ def test_unicode_dash_lookup():
"""
\t bar
""",
" \u180E\u200B \u200C bar \u200D \u2060\uFEFF ",
" \u180e\u200b \u200c bar \u200d \u2060\ufeff ",
],
)
def test_strip_all_whitespace(value):
@@ -378,7 +378,7 @@ def test_strip_all_whitespace(value):
[
"notifications-email",
" \tnotifications-email \x0c ",
"\rn\u200Coti\u200Dfi\u200Bcati\u2060ons-\u180Eemai\uFEFFl\uFEFF",
"\rn\u200coti\u200dfi\u200bcati\u2060ons-\u180eemai\ufeffl\ufeff",
],
)
def test_strip_and_remove_obscure_whitespace(value):
@@ -421,12 +421,12 @@ def test_strip_unsupported_characters():
@pytest.mark.parametrize(
"value",
[
"\u200C Your tax is\ndue\n\n",
"\u200c Your tax is\ndue\n\n",
" Your tax is due ",
# Non breaking spaces replaced by single spaces
"\u00A0Your\u00A0tax\u00A0 is\u00A0\u00A0due\u00A0",
"\u00a0Your\u00a0tax\u00a0 is\u00a0\u00a0due\u00a0",
# zero width spaces are removed
"\u180EYour \u200Btax\u200C is \u200D\u2060due \uFEFF",
"\u180eYour \u200btax\u200c is \u200d\u2060due \ufeff",
# tabs are replaced by single spaces
"\tYour tax\tis due ",
],
@@ -559,7 +559,7 @@ def test_normalise_postcode(postcode, normalised_postcode):
("N5 1AA", True),
("SO14 6WB", True),
("so14 6wb", True),
("so14\u00A06wb", True),
("so14\u00a06wb", True),
# invalida / incomplete postcodes
("N5", False),
("SO144 6WB", False),
@@ -600,7 +600,7 @@ def test_if_postcode_is_a_real_uk_postcode_normalises_before_checking_postcode(m
("N5 3EF", "N5 3EF"),
("N53EF ", "N5 3EF"),
("n53Ef", "N5 3EF"),
("n5 \u00A0 \t 3Ef", "N5 3EF"),
("n5 \u00a0 \t 3Ef", "N5 3EF"),
("SO146WB", "SO14 6WB"),
("BFPO2", "BFPO 2"),
("BFPO232", "BFPO 232"),
@@ -628,6 +628,8 @@ def test_bad_or_missing_data(
assert recipients.has_errors is True
# TODO, original test for number one had {0, 1, 2}, but it has morphed to {0, 1}
# Is +447900123 legit or not? What changed?
@pytest.mark.parametrize(
("file_contents", "rows_with_bad_recipients"),
[
@@ -638,7 +640,7 @@ def test_bad_or_missing_data(
1234
+447900123
""",
{0, 1, 2},
{0, 1},
),
(
"""
@@ -647,7 +649,7 @@ def test_bad_or_missing_data(
+12022340104, USA
+23051234567, Mauritius
""",
{2},
set(),
),
],
)
@@ -877,14 +879,14 @@ def test_ignores_spaces_and_case_in_placeholders(key, expected):
("\n", None), # newline
("\r", None), # carriage return
("\t", None), # tab
("\u180E", "MONGOLIAN VOWEL SEPARATOR"),
("\u200B", "ZERO WIDTH SPACE"),
("\u200C", "ZERO WIDTH NON-JOINER"),
("\u200D", "ZERO WIDTH JOINER"),
("\u180e", "MONGOLIAN VOWEL SEPARATOR"),
("\u200b", "ZERO WIDTH SPACE"),
("\u200c", "ZERO WIDTH NON-JOINER"),
("\u200d", "ZERO WIDTH JOINER"),
("\u2060", "WORD JOINER"),
("\uFEFF", "ZERO WIDTH NO-BREAK SPACE"),
("\ufeff", "ZERO WIDTH NO-BREAK SPACE"),
# all the things
(" \n\r\t\u000A\u000D\u180E\u200B\u200C\u200D\u2060\uFEFF", None),
(" \n\r\t\u000a\u000d\u180e\u200b\u200c\u200d\u2060\ufeff", None),
],
)
def test_ignores_leading_whitespace_in_file(character, name):
@@ -24,21 +24,16 @@ valid_us_phone_numbers = [
"(202) 555-0104",
]
# TODO
# International phone number tests are commented out as a result of issue #943 in notifications-admin. We are
# deliberately eliminating the ability to send to numbers outside of country code 1. These tests should
# be removed at some point when we are sure we are never going to support international numbers
valid_international_phone_numbers = [
# "+71234567890", # Russia
# "+447123456789", # UK
# "+4407123456789", # UK
# "+4407123 456789", # UK
# "+4407123-456-789", # UK
# "+23051234567", # Mauritius,
# "+682 12345", # Cook islands
# "+3312345678",
# "+9-2345-12345-12345", # 15 digits
"+71234567890", # Russia
"+447123456789", # UK
"+4407123456789", # UK
"+4407123 456789", # UK
"+4407123-456-789", # UK
"+23051234567", # Mauritius,
"+682 12345", # Cook islands
"+3312345678",
"+9-2345-12345-12345", # 15 digits
]
@@ -85,7 +80,7 @@ invalid_us_phone_numbers = sum(
invalid_phone_numbers = [
("+80233456789", "Not a valid country prefix"),
("1234567", "Not enough digits"),
("+682 1234", "Invalid country code"), # Cook Islands phone numbers can be 5 digits
("+682 1234", "Not enough digits"), # Cook Islands phone numbers are 5 digits
("+12345 12345 12345 6", "Too many digits"),
]
@@ -156,46 +151,46 @@ def test_detect_us_phone_numbers(phone_number):
@pytest.mark.parametrize(
("phone_number", "expected_info"),
[
# (
# "+4407900900123",
# international_phone_info(
# international=True,
# country_prefix="44", # UK
# billable_units=1,
# ),
# ),
# (
# "+4407700900123",
# international_phone_info(
# international=True,
# country_prefix="44", # Number in TV range
# billable_units=1,
# ),
# ),
# (
# "+4407700800123",
# international_phone_info(
# international=True,
# country_prefix="44", # UK Crown dependency, so prefix same as UK
# billable_units=1,
# ),
# ),
# ( #
# "+20-12-1234-1234",
# international_phone_info(
# international=True,
# country_prefix="20", # Egypt
# billable_units=1,
# ),
# ),
# (
# "+201212341234",
# international_phone_info(
# international=True,
# country_prefix="20", # Egypt
# billable_units=1,
# ),
# ),
(
"+4407900900123",
international_phone_info(
international=True,
country_prefix="44", # UK
billable_units=1,
),
),
(
"+4407700900123",
international_phone_info(
international=True,
country_prefix="44", # Number in TV range
billable_units=1,
),
),
(
"+4407700800123",
international_phone_info(
international=True,
country_prefix="44", # UK Crown dependency, so prefix same as UK
billable_units=1,
),
),
( #
"+20-12-1234-1234",
international_phone_info(
international=True,
country_prefix="20", # Egypt
billable_units=1,
),
),
(
"+201212341234",
international_phone_info(
international=True,
country_prefix="20", # Egypt
billable_units=1,
),
),
(
"+1 664-491-3434",
international_phone_info(
@@ -204,14 +199,14 @@ def test_detect_us_phone_numbers(phone_number):
billable_units=1,
),
),
# (
# "+71234567890",
# international_phone_info(
# international=True,
# country_prefix="7", # Russia
# billable_units=1,
# ),
# ),
(
"+71234567890",
international_phone_info(
international=True,
country_prefix="7", # Russia
billable_units=1,
),
),
(
"1-202-555-0104",
international_phone_info(
@@ -228,14 +223,14 @@ def test_detect_us_phone_numbers(phone_number):
billable_units=1,
),
),
# (
# "+23051234567",
# international_phone_info(
# international=True,
# country_prefix="230", # Mauritius
# billable_units=1,
# ),
# ),
(
"+23051234567",
international_phone_info(
international=True,
country_prefix="230", # Mauritius
billable_units=1,
),
),
],
)
def test_get_international_info(phone_number, expected_info):
@@ -288,11 +283,11 @@ def test_valid_us_phone_number_can_be_formatted_consistently(phone_number):
@pytest.mark.parametrize(
("phone_number", "expected_formatted"),
[
# ("+44071234567890", "+4471234567890"),
("+44071234567890", "+4471234567890"),
("1-202-555-0104", "+12025550104"),
("+12025550104", "+12025550104"),
("12025550104", "+12025550104"),
# ("+23051234567", "+23051234567"),
("+23051234567", "+23051234567"),
],
)
def test_valid_international_phone_number_can_be_formatted_consistently(
@@ -344,7 +339,7 @@ def test_validate_email_address_accepts_valid(email_address):
" email@domain.com ",
"\temail@domain.com",
"\temail@domain.com\n",
"\u200Bemail@domain.com\u200B",
"\u200bemail@domain.com\u200b",
],
)
def test_validate_email_address_strips_whitespace(email):
@@ -368,17 +363,17 @@ def test_validates_against_guestlist_of_phone_numbers(phone_number):
)
# @pytest.mark.parametrize(
# "recipient_number, allowlist_number",
# [
# ["+4407123-456-789", "+4407123456789"],
# ["+4407123456789", "+4407123-456-789"],
# ],
# )
# def test_validates_against_guestlist_of_international_phone_numbers(
# recipient_number, allowlist_number
# ):
# assert allowed_to_send_to(recipient_number, [allowlist_number])
@pytest.mark.parametrize(
"recipient_number, allowlist_number",
[
["+4407123-456-789", "+4407123456789"],
["+4407123456789", "+4407123-456-789"],
],
)
def test_validates_against_guestlist_of_international_phone_numbers(
recipient_number, allowlist_number
):
assert allowed_to_send_to(recipient_number, [allowlist_number])
@pytest.mark.parametrize("email_address", valid_email_addresses)
@@ -388,19 +383,21 @@ def test_validates_against_guestlist_of_email_addresses(email_address):
)
# TODO something wrong with formatting Egyptian numbers, doesn't seem
# like this would affect sendability need to confirm with AWS simulated numbers.
@pytest.mark.parametrize(
("phone_number", "expected_formatted"),
[
# ("+4407900900123", "+44 7900 900123"), # UK
# ("+44(0)7900900123", "+44 7900 900123"), # UK
# ("+447900900123", "+44 7900 900123"), # UK
("+4407900900123", "+44 7900 900123"), # UK
("+44(0)7900900123", "+44 7900 900123"), # UK
("+447900900123", "+44 7900 900123"), # UK
# ("+20-12-1234-1234", "+20 121 234 1234"), # Egypt
# ("+201212341234", "+20 121 234 1234"), # Egypt
("+1 664 491-3434", "+1 664-491-3434"), # Montserrat
# ("+7 499 1231212", "+7 499 123-12-12"), # Moscow (Russia)
("+7 499 1231212", "+7 499 123-12-12"), # Moscow (Russia)
("1-202-555-0104", "(202) 555-0104"), # Washington DC (USA)
# ("+23051234567", "+230 5123 4567"), # Mauritius
# ("+33(0)1 12345678", "+33 1 12 34 56 78"), # Paris (France)
("+23051234567", "+230 5123 4567"), # Mauritius
("+33(0)1 12345678", "+33 1 12 34 56 78"), # Paris (France)
],
)
def test_format_us_and_international_phone_numbers(phone_number, expected_formatted):
@@ -417,7 +414,7 @@ def test_format_us_and_international_phone_numbers(phone_number, expected_format
(None, ""),
("foo", "foo"),
("TeSt@ExAmPl3.com", "test@exampl3.com"),
# ("+4407900 900 123", "+447900900123"),
("+4407900 900 123", "+447900900123"),
("+1 800 555 5555", "+18005555555"),
],
)
@@ -17,7 +17,7 @@ params, ids = zip(
("", "..."),
"compatibility transform unicode char (HORIZONTAL ELLIPSIS (U+2026)",
),
(("\u200B", ""), "compatibility transform unicode char (ZERO WIDTH SPACE (U+200B)"),
(("\u200b", ""), "compatibility transform unicode char (ZERO WIDTH SPACE (U+200B)"),
(
("", "'"),
"compatibility transform unicode char (LEFT SINGLE QUOTATION MARK (U+2018)",