• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / fx-private-relay / 4276dcd9-8c30-4786-8a41-f9c1cdae7f05

05 Mar 2024 07:15PM CUT coverage: 74.734% (+0.6%) from 74.139%
4276dcd9-8c30-4786-8a41-f9c1cdae7f05

Pull #4452

circleci

jwhitlock
Pass user to create_expected_glean_event

Pass the related user to the test helper create_expected_glean_event, so
that the user-specific values such as fxa_id and date_joined_relay can
be extracted in the helper rather than each test function.
Pull Request #4452: MPP-3352: Add first Glean metrics to measure email mask usage

2084 of 3047 branches covered (68.4%)

Branch coverage included in aggregate %.

251 of 256 new or added lines in 7 files covered. (98.05%)

79 existing lines in 3 files now uncovered.

6763 of 8791 relevant lines covered (76.93%)

20.12 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.78
/privaterelay/utils.py
1
from __future__ import annotations
1✔
2
from decimal import Decimal
1✔
3
from functools import cache, wraps
1✔
4
from pathlib import Path
1✔
5
from string import ascii_uppercase
1✔
6
from typing import Callable, TypedDict, cast, TYPE_CHECKING
1✔
7
import json
1✔
8
import logging
1✔
9
import random
1✔
10

11
from django.conf import settings
1✔
12
from django.contrib.auth.models import AbstractBaseUser
1✔
13
from django.http import Http404, HttpRequest
1✔
14
from django.utils.translation.trans_real import parse_accept_lang_header
1✔
15

16
from waffle import get_waffle_flag_model
1✔
17
from waffle.models import logger as waffle_logger
1✔
18
from waffle.utils import (
1✔
19
    get_cache as get_waffle_cache,
20
    get_setting as get_waffle_setting,
21
)
22

23
from .plans import (
1✔
24
    LanguageStr,
25
    PeriodStr,
26
    PlanCountryLangMapping,
27
    CountryStr,
28
    get_premium_country_language_mapping,
29
)
30

31
if TYPE_CHECKING:
1!
NEW
32
    from .glean_interface import RelayGleanLogger
×
33

34
info_logger = logging.getLogger("eventsinfo")
1✔
35

36

37
class CountryInfo(TypedDict):
1✔
38
    country_code: str
1✔
39
    countries: list[CountryStr]
1✔
40
    available_in_country: bool
1✔
41
    plan_country_lang_mapping: PlanCountryLangMapping
1✔
42

43

44
def get_countries_info_from_request_and_mapping(
1✔
45
    request: HttpRequest, mapping: PlanCountryLangMapping
46
) -> CountryInfo:
47
    country_code = _get_cc_from_request(request)
1✔
48
    countries = sorted(mapping.keys())
1✔
49
    available_in_country = country_code in countries
1✔
50
    return {
1✔
51
        "country_code": country_code,
52
        "countries": countries,
53
        "available_in_country": available_in_country,
54
        "plan_country_lang_mapping": mapping,
55
    }
56

57

58
def get_countries_info_from_lang_and_mapping(
1✔
59
    accept_lang: str, mapping: PlanCountryLangMapping
60
) -> CountryInfo:
61
    country_code = _get_cc_from_lang(accept_lang)
1✔
62
    countries = sorted(mapping.keys())
1✔
63
    available_in_country = country_code in countries
1✔
64
    return {
1✔
65
        "country_code": country_code,
66
        "countries": countries,
67
        "available_in_country": available_in_country,
68
        "plan_country_lang_mapping": mapping,
69
    }
70

71

72
def get_subplat_upgrade_link_by_language(
1✔
73
    accept_language: str, period: PeriodStr = "yearly"
74
) -> str:
75
    country_str = guess_country_from_accept_lang(accept_language)
1✔
76
    country = cast(CountryStr, country_str)
1✔
77
    language_str = accept_language.split("-")[0].lower()
1✔
78
    language = cast(LanguageStr, language_str)
1✔
79
    country_lang_mapping = get_premium_country_language_mapping()
1✔
80
    country_details = country_lang_mapping.get(country, country_lang_mapping["US"])
1✔
81
    if language in country_details:
1!
82
        plan = country_details[language][period]
×
83
    else:
84
        first_key = list(country_details.keys())[0]
1✔
85
        plan = country_details[first_key][period]
1✔
86
    return (
1✔
87
        f"{settings.FXA_BASE_ORIGIN}/subscriptions/products/"
88
        f"{settings.PERIODICAL_PREMIUM_PROD_ID}?plan={plan['id']}"
89
    )
90

91

92
def _get_cc_from_request(request: HttpRequest) -> str:
1✔
93
    """Determine the user's region / country code."""
94

95
    log_data: dict[str, str] = {}
1✔
96
    cdn_region = None
1✔
97
    region = None
1✔
98
    if "X-Client-Region" in request.headers:
1✔
99
        cdn_region = region = request.headers["X-Client-Region"].upper()
1✔
100
        log_data["cdn_region"] = cdn_region
1✔
101
        log_data["region_method"] = "cdn"
1✔
102

103
    accept_language_region = None
1✔
104
    if "Accept-Language" in request.headers:
1✔
105
        log_data["accept_lang"] = request.headers["Accept-Language"]
1✔
106
        accept_language_region = _get_cc_from_lang(request.headers["Accept-Language"])
1✔
107
        log_data["accept_lang_region"] = accept_language_region
1✔
108
        if region is None:
1✔
109
            region = accept_language_region
1✔
110
            log_data["region_method"] = "accept_lang"
1✔
111

112
    if region is None:
1✔
113
        region = "US"
1✔
114
        log_data["region_method"] = "fallback"
1✔
115
    log_data["region"] = region
1✔
116

117
    # MPP-3284: Log details of region selection. Only log once per request, since some
118
    # endpoints, like /api/v1/runtime_data, call this multiple times.
119
    if not getattr(request, "_logged_region_details", False):
1✔
120
        setattr(request, "_logged_region_details", True)
1✔
121
        info_logger.info("region_details", extra=log_data)
1✔
122

123
    return region
1✔
124

125

126
def _get_cc_from_lang(accept_lang: str) -> str:
1✔
127
    try:
1✔
128
        return guess_country_from_accept_lang(accept_lang)
1✔
129
    except AcceptLanguageError:
1✔
130
        return ""
1✔
131

132

133
# Map a primary language to the most probable country
134
# Top country derived from CLDR42 Supplemental Data, Language-Territory Information
135
# with the exception of Spanish (es), which is mapped to Spain (es) instead of
136
# Mexico (mx), which has the most speakers, but usually specifies es-MX.
137
_PRIMARY_LANGUAGE_TO_COUNTRY = {
1✔
138
    "ace": "ID",  # # Acehnese -> Indonesia
139
    "ach": "UG",  # Acholi -> Uganda
140
    "af": "ZA",  # Afrikaans -> South Africa
141
    "an": "ES",  # Aragonese -> Spain
142
    "ar": "EG",  # Arabic -> Egypt
143
    "arn": "CL",  # Mapudungun -> Chile
144
    "as": "IN",  # Assamese -> India
145
    "ast": "ES",  # Asturian -> Spain
146
    "az": "AZ",  # Azerbaijani -> Azerbaijan
147
    "be": "BY",  # Belerusian -> Belarus
148
    "bg": "BG",  # Bulgarian -> Bulgaria
149
    "bn": "BD",  # Bengali -> Bangladesh
150
    "bo": "CN",  # Tibetan -> China
151
    "br": "FR",  # Breton -> France
152
    "brx": "IN",  # Bodo -> India
153
    "bs": "BA",  # Bosnian -> Bosnia and Herzegovina
154
    "ca": "FR",  # Catalan -> France
155
    "cak": "MX",  # Kaqchikel -> Mexico
156
    "ckb": "IQ",  # Central Kurdish -> Iraq
157
    "cs": "CZ",  # Czech -> Czech Republic
158
    "cv": "RU",  # Chuvash -> Russia
159
    "cy": "GB",  # Welsh -> United Kingdom
160
    "da": "DK",  # Danish -> Denmark
161
    "de": "DE",  # German -> Germany
162
    "dsb": "DE",  # Lower Sorbian -> Germany
163
    "el": "GR",  # Greek -> Greece
164
    "en": "US",  # English -> Canada
165
    "eo": "SM",  # Esperanto -> San Marino
166
    "es": "ES",  # Spanish -> Spain (instead of Mexico, top by population)
167
    "et": "EE",  # Estonian -> Estonia
168
    "eu": "ES",  # Basque -> Spain
169
    "fa": "IR",  # Persian -> Iran
170
    "ff": "SN",  # Fulah -> Senegal
171
    "fi": "FI",  # Finnish -> Finland
172
    "fr": "FR",  # French -> France
173
    "frp": "FR",  # Arpitan -> France
174
    "fur": "IT",  # Friulian -> Italy
175
    "fy": "NL",  # Frisian -> Netherlands
176
    "ga": "IE",  # Irish -> Ireland
177
    "gd": "GB",  # Scottish Gaelic -> United Kingdom
178
    "gl": "ES",  # Galician -> Spain
179
    "gn": "PY",  # Guarani -> Paraguay
180
    "gu": "IN",  # Gujarati -> India
181
    "gv": "IM",  # Manx -> Isle of Man
182
    "he": "IL",  # Hebrew -> Israel
183
    "hi": "IN",  # Hindi -> India
184
    "hr": "HR",  # Croatian -> Croatia
185
    "hsb": "DE",  # Upper Sorbian -> Germany
186
    "hu": "HU",  # Hungarian -> Hungary
187
    "hy": "AM",  # Armenian -> Armenia
188
    "hye": "AM",  # Armenian Eastern Classic Orthography -> Armenia
189
    "ia": "FR",  # Interlingua -> France
190
    "id": "ID",  # Indonesian -> Indonesia
191
    "ilo": "PH",  # Iloko -> Philippines
192
    "is": "IS",  # Icelandic -> Iceland
193
    "it": "IT",  # Italian -> Italy
194
    "ixl": "MX",  # Ixil -> Mexico
195
    "ja": "JP",  # Japanese -> Japan
196
    "jiv": "MX",  # Shuar -> Mexico
197
    "ka": "GE",  # Georgian -> Georgia
198
    "kab": "DZ",  # Kayble -> Algeria
199
    "kk": "KZ",  # Kazakh -> Kazakhstan
200
    "km": "KH",  # Khmer -> Cambodia
201
    "kn": "IN",  # Kannada -> India
202
    "ko": "KR",  # Korean -> South Korea
203
    "ks": "IN",  # Kashmiri -> India
204
    "lb": "LU",  # Luxembourgish -> Luxembourg
205
    "lg": "UG",  # Luganda -> Uganda
206
    "lij": "IT",  # Ligurian -> Italy
207
    "lo": "LA",  # Lao -> Laos
208
    "lt": "LT",  # Lithuanian -> Lithuania
209
    "ltg": "LV",  # Latgalian -> Latvia
210
    "lus": "US",  # Mizo -> United States
211
    "lv": "LV",  # Latvian -> Latvia
212
    "mai": "IN",  # Maithili -> India
213
    "meh": "MX",  # Mixteco Yucuhiti -> Mexico
214
    "mix": "MX",  # Mixtepec Mixtec -> Mexico
215
    "mk": "MK",  # Macedonian -> North Macedonia
216
    "ml": "IN",  # Malayalam -> India
217
    "mr": "IN",  # Marathi -> India
218
    "ms": "MY",  # Malay -> Malaysia
219
    "my": "MM",  # Burmese -> Myanmar
220
    "nb": "NO",  # Norwegian Bokmål -> Norway
221
    "ne": "NP",  # Nepali -> Nepal
222
    "nl": "NL",  # Dutch -> Netherlands
223
    "nn": "NO",  # Norwegian Nynorsk -> Norway
224
    "oc": "FR",  # Occitan -> France
225
    "or": "IN",  # Odia -> India
226
    "pa": "IN",  # Punjabi -> India
227
    "pl": "PL",  # Polish -> Poland
228
    "ppl": "MX",  # Náhuat Pipil -> Mexico
229
    "pt": "BR",  # Portuguese -> Brazil
230
    "quc": "GT",  # K'iche' -> Guatemala
231
    "rm": "CH",  # Romansh -> Switzerland
232
    "ro": "RO",  # Romanian -> Romania
233
    "ru": "RU",  # Russian -> Russia
234
    "sat": "IN",  # Santali (Ol Chiki) -> India
235
    "sc": "IT",  # Sardinian -> Italy
236
    "scn": "IT",  # Sicilian -> Italy
237
    "sco": "GB",  # Scots -> United Kingdom
238
    "si": "LK",  # Sinhala -> Sri Lanka
239
    "sk": "SK",  # Slovak -> Slovakia
240
    "skr": "PK",  # Saraiki -> Pakistan
241
    "sl": "SI",  # Slovenian -> Slovenia
242
    "son": "ML",  # Songhay -> Mali
243
    "sq": "AL",  # Albanian -> Albania
244
    "sr": "RS",  # Serbian -> Serbia
245
    "sv": "SE",  # Swedish -> Sweeden
246
    "sw": "TZ",  # Swahili -> Tanzania
247
    "szl": "PL",  # Silesian -> Poland
248
    "ta": "IN",  # Tamil -> India
249
    "te": "IN",  # Telugu -> India
250
    "tg": "TJ",  # Tajik -> Tajikistan
251
    "th": "TH",  # Thai -> Thailand
252
    "tl": "PH",  # Tagalog -> Philippines
253
    "tr": "TR",  # Turkish or Crimean Tatar -> Turkey
254
    "trs": "MX",  # Triqui -> Mexico
255
    "uk": "UA",  # Ukrainian -> Ukraine
256
    "ur": "PK",  # Urdu -> Pakistan
257
    "uz": "UZ",  # Uzbek -> Uzbekistan
258
    "vi": "VN",  # Vietnamese -> Vietnam
259
    "wo": "SN",  # Wolof -> Senegal
260
    "xcl": "AM",  # Armenian Classic -> Armenia
261
    "xh": "ZA",  # Xhosa -> South Africa
262
    "zam": "MX",  # Miahuatlán Zapotec -> Mexico
263
    "zh": "CN",  # Chinese -> China
264
}
265

266
# Special cases for language tags
267
_LANGUAGE_TAG_TO_COUNTRY_OVERRIDE = {
1✔
268
    # Would be Catalan in Valencian script -> France
269
    # Change to Valencian -> Spain
270
    ("ca", "VALENCIA"): "ES",
271
    # Spanish in UN region 419 (Latin America and Carribean)
272
    # Pick Mexico, which has highest number of Spanish speakers
273
    ("es", "419"): "MX",
274
    # Would be Galician (Greenland) -> Greenland
275
    # Change to Galician (Galicia region of Spain) -> Spain
276
    ("gl", "GL"): "ES",
277
}
278

279

280
class AcceptLanguageError(ValueError):
1✔
281
    """There was an issue processing the Accept-Language header."""
282

283
    def __init__(self, message, accept_lang):
1✔
284
        super().__init__(message)
1✔
285
        self.accept_lang = accept_lang
1✔
286

287

288
def guess_country_from_accept_lang(accept_lang: str) -> str:
1✔
289
    """
290
    Guess the user's country from the Accept-Language header
291

292
    Return is a 2-letter ISO 3166 country code
293

294
    If an issue is detected, a AcceptLanguageError is raised.
295

296
    The header may come directly from a web request, or may be the header
297
    captured by Mozilla Accounts (FxA) at signup.
298

299
    Even with all this logic and special casing, it is still more accurate to
300
    use a GeoIP lookup or a country code provided by the infrastructure.
301

302
    See RFC 9110, "HTTP Semantics", section 12.5.4, "Accept-Language"
303
    See RFC 5646, "Tags for Identifying Languages", and examples in Appendix A
304
    """
305
    lang_q_pairs = parse_accept_lang_header(accept_lang.strip())
1✔
306
    if not lang_q_pairs:
1✔
307
        raise AcceptLanguageError("Invalid Accept-Language string", accept_lang)
1✔
308
    top_lang_tag = lang_q_pairs[0][0]
1✔
309

310
    subtags = top_lang_tag.split("-")
1✔
311
    lang = subtags[0].lower()
1✔
312
    if lang == "i":
1✔
313
        raise AcceptLanguageError("Irregular language tag", accept_lang)
1✔
314
    if lang == "x":
1✔
315
        raise AcceptLanguageError("Private-use language tag", accept_lang)
1✔
316
    if lang == "*":
1✔
317
        raise AcceptLanguageError("Wildcard language tag", accept_lang)
1✔
318
    if len(lang) < 2:
1✔
319
        raise AcceptLanguageError("Invalid one-character primary language", accept_lang)
1✔
320
    if len(lang) == 3 and lang[0] == "q" and lang[1] <= "t":
1✔
321
        raise AcceptLanguageError(
1✔
322
            "Private-use language tag (RFC 5646 2.2.1)", accept_lang
323
        )
324

325
    for maybe_region_raw in subtags[1:]:
1✔
326
        maybe_region = maybe_region_raw.upper()
1✔
327

328
        # Look for a special case
329
        if override := _LANGUAGE_TAG_TO_COUNTRY_OVERRIDE.get((lang, maybe_region)):
1✔
330
            return override
1✔
331

332
        if len(maybe_region) <= 1:
1✔
333
            # One-character extension or empty, stop processing
334
            break
1✔
335
        if (
1✔
336
            len(maybe_region) == 2
337
            and all(c in ascii_uppercase for c in maybe_region)
338
            and
339
            # RFC 5646 2.2.4 "Region Subtag" point 6, reserved subtags
340
            maybe_region != "AA"
341
            and maybe_region != "ZZ"
342
            and maybe_region[0] != "X"
343
            and (maybe_region[0] != "Q" or maybe_region[1] < "M")
344
        ):
345
            # Subtag is a non-private ISO 3166 country code
346
            return maybe_region
1✔
347

348
        # Subtag is probably a script, like "Hans" in "zh-Hans-CN"
349
        # Loop to the next subtag, which might be a ISO 3166 country code
350

351
    # Guess the country from a simple language tag
352
    try:
1✔
353
        return _PRIMARY_LANGUAGE_TO_COUNTRY[lang]
1✔
354
    except KeyError:
1✔
355
        raise AcceptLanguageError("Unknown langauge", accept_lang)
1✔
356

357

358
def enable_or_404(
1✔
359
    check_function: Callable[[], bool],
360
    message: str = "This conditional view is disabled.",
361
):
362
    """
363
    Returns decorator that enables a view if a check function passes,
364
    otherwise returns a 404.
365

366
    Usage:
367

368
        def percent_1():
369
           import random
370
           return random.randint(1, 100) == 1
371

372
        @enable_if(coin_flip)
373
        def lucky_view(request):
374
            #  1 in 100 chance of getting here
375
            # 99 in 100 chance of 404
376
    """
377

378
    def decorator(func):
1✔
379
        @wraps(func)
1✔
380
        def inner(*args, **kwargs):
1✔
381
            if check_function():
×
382
                return func(*args, **kwargs)
×
383
            else:
384
                raise Http404(message)  # Display a message with DEBUG=True
×
385

386
        return inner
1✔
387

388
    return decorator
1✔
389

390

391
def enable_if_setting(
1✔
392
    setting_name: str,
393
    message_fmt: str = "This view is disabled because {setting_name} is False",
394
):
395
    """
396
    Returns decorator that enables a view if a setting is truthy, otherwise
397
    returns a 404.
398

399
    Usage:
400

401
        @enable_if_setting("DEBUG")
402
        def debug_only_view(request):
403
            # DEBUG == True
404

405
    Or in URLS:
406

407
        path(
408
            "developer_info",
409
            enable_if_setting("DEBUG")(debug_only_view)
410
        ),
411
        name="developer-info",
412
    ),
413

414
    """
415

416
    def setting_is_truthy() -> bool:
1✔
417
        return bool(getattr(settings, setting_name))
×
418

419
    return enable_or_404(
1✔
420
        setting_is_truthy, message_fmt.format(setting_name=setting_name)
421
    )
422

423

424
def flag_is_active_in_task(flag_name: str, user: AbstractBaseUser | None) -> bool:
1✔
425
    """
426
    Test if a flag is active in a task (not in a web request).
427

428
    This mirrors AbstractBaseFlag.is_active, replicating these checks:
429
    * Logs missing flags, if configured
430
    * Creates missing flags, if configured
431
    * Returns default for missing flags
432
    * Checks flag.everyone
433
    * Checks flag.users and flag.groups, if a user is passed
434
    * Returns random results for flag.percent
435

436
    It does not include:
437
    * Overriding a flag with a query parameter
438
    * Persisting a flag in a cookie (includes percent flags)
439
    * Language-specific overrides (could be added later)
440
    * Read-only mode for percent flags
441

442
    When using this function, use the @override_flag decorator in tests, rather
443
    than manually creating flags in the database.
444
    """
445
    flag = get_waffle_flag_model().get(flag_name)
1✔
446
    if not flag.pk:
1✔
447
        log_level = get_waffle_setting("LOG_MISSING_FLAGS")
1✔
448
        if log_level:
1✔
449
            waffle_logger.log(log_level, "Flag %s not found", flag_name)
1✔
450
        if get_waffle_setting("CREATE_MISSING_FLAGS"):
1✔
451
            flag, _created = get_waffle_flag_model().objects.get_or_create(
1✔
452
                name=flag_name,
453
                defaults={"everyone": get_waffle_setting("FLAG_DEFAULT")},
454
            )
455
            cache = get_waffle_cache()
1✔
456
            cache.set(flag._cache_key(flag.name), flag)
1✔
457

458
        return bool(get_waffle_setting("FLAG_DEFAULT"))
1✔
459

460
    # Removed - check for override as request query parameter
461

462
    if flag.everyone:
1✔
463
        return True
1✔
464
    elif flag.everyone is False:
1✔
465
        return False
1✔
466

467
    # Removed - check for testing override in request query or cookie
468
    # Removed - check for language-specific override
469

470
    if user is not None:
1✔
471
        active_for_user = flag.is_active_for_user(user)
1✔
472
        if active_for_user is not None:
1✔
473
            return bool(active_for_user)
1✔
474

475
    if flag.percent and flag.percent > 0:
1✔
476
        # Removed - check for waffles attribute of request
477
        # Removed - check for cookie setting for flag
478
        # Removed - check for read-only mode
479

480
        if Decimal(str(random.uniform(0, 100))) <= flag.percent:
1✔
481
            # Removed - setting the flag for future checks
482
            return True
1✔
483

484
    return False
1✔
485

486

487
class VersionInfo(TypedDict):
1✔
488
    source: str
1✔
489
    version: str
1✔
490
    commit: str
1✔
491
    build: str
1✔
492

493

494
@cache
1✔
495
def get_version_info(base_dir: str | Path | None = None) -> VersionInfo:
1✔
496
    """Return version information written by build process."""
497
    if base_dir is None:
1✔
498
        base_path = Path(settings.BASE_DIR)
1✔
499
    else:
500
        base_path = Path(base_dir)
1✔
501
    version_json_path = base_path / "version.json"
1✔
502
    info = {}
1✔
503
    if version_json_path.exists():
1✔
504
        with version_json_path.open() as version_file:
1✔
505
            try:
1✔
506
                info = json.load(version_file)
1✔
507
            except ValueError:
1✔
508
                pass
1✔
509
            if not hasattr(info, "get"):
1✔
510
                info = {}
1✔
511
    version_info = VersionInfo(
1✔
512
        source=info.get("source", "https://github.com/mozilla/fx-private-relay"),
513
        version=info.get("version", "unknown"),
514
        commit=info.get("commit", "unknown"),
515
        build=info.get("build", "not built"),
516
    )
517
    return version_info
1✔
518

519

520
@cache
1✔
521
def glean_logger() -> RelayGleanLogger:
1✔
522
    from .glean_interface import RelayGleanLogger
1✔
523

524
    version_info = get_version_info()
1✔
525
    return RelayGleanLogger(
1✔
526
        application_id="relay-backend",
527
        app_display_version=version_info["version"],
528
        channel=settings.RELAY_CHANNEL,
529
    )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc