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

mozilla / fx-private-relay / f690661d-0404-4755-ae0d-9d16b3b9122e

02 Jul 2025 06:06PM UTC coverage: 85.311% (-0.02%) from 85.326%
f690661d-0404-4755-ae0d-9d16b3b9122e

Pull #5700

circleci

vpremamozilla
MPP-4236: fix(news) dont show megabundle ad for current megabundle users
Pull Request #5700: MPP-4236: fix(news) dont show megabundle ad for current megabundle users

2671 of 3943 branches covered (67.74%)

Branch coverage included in aggregate %.

8 of 10 new or added lines in 2 files covered. (80.0%)

1 existing line in 1 file now uncovered.

17691 of 19925 relevant lines covered (88.79%)

9.88 hits per line

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

90.69
/privaterelay/models.py
1
from __future__ import annotations
1✔
2

3
import logging
1✔
4
import uuid
1✔
5
from collections import namedtuple
1✔
6
from datetime import UTC, datetime, timedelta
1✔
7
from hashlib import sha256
1✔
8
from typing import TYPE_CHECKING, Literal
1✔
9

10
from django.conf import settings
1✔
11
from django.contrib.auth.models import User
1✔
12
from django.db import models, transaction
1✔
13
from django.utils.translation.trans_real import (
1✔
14
    get_supported_language_variant,
15
    parse_accept_lang_header,
16
)
17

18
from allauth.socialaccount.models import SocialAccount
1✔
19

20
from .country_utils import AcceptLanguageError, guess_country_from_accept_lang
1✔
21
from .exceptions import CannotMakeSubdomainException
1✔
22
from .plans import get_premium_countries
1✔
23
from .utils import flag_is_active_in_task
1✔
24
from .validators import valid_available_subdomain
1✔
25

26
if TYPE_CHECKING:
27
    from collections.abc import Iterable
28

29
    from django.db.models.base import ModelBase
30
    from django.db.models.query import QuerySet
31

32
    from emails.models import DomainAddress, RelayAddress
33

34

35
abuse_logger = logging.getLogger("abusemetrics")
1✔
36
BounceStatus = namedtuple("BounceStatus", "paused type")
1✔
37
PREMIUM_DOMAINS = ["mozilla.com", "getpocket.com", "mozillafoundation.org"]
1✔
38

39

40
def hash_subdomain(subdomain: str, domain: str = settings.MOZMAIL_DOMAIN) -> str:
1✔
41
    return sha256(f"{subdomain}.{domain}".encode()).hexdigest()
1✔
42

43

44
class Profile(models.Model):
1✔
45
    user = models.OneToOneField(User, on_delete=models.CASCADE)
1✔
46
    api_token = models.UUIDField(default=uuid.uuid4)
1✔
47
    num_address_deleted = models.PositiveIntegerField(default=0)
1✔
48
    date_subscribed = models.DateTimeField(blank=True, null=True)
1✔
49
    date_subscribed_phone = models.DateTimeField(blank=True, null=True)
1✔
50
    # TODO MPP-2972: delete date_phone_subscription_checked in favor of
51
    # date_phone_subscription_next_reset
52
    date_phone_subscription_checked = models.DateTimeField(blank=True, null=True)
1✔
53
    date_phone_subscription_start = models.DateTimeField(blank=True, null=True)
1✔
54
    date_phone_subscription_reset = models.DateTimeField(blank=True, null=True)
1✔
55
    date_phone_subscription_end = models.DateTimeField(blank=True, null=True)
1✔
56
    address_last_deleted = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
57
    last_soft_bounce = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
58
    last_hard_bounce = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
59
    last_account_flagged = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
60
    num_deleted_relay_addresses = models.PositiveIntegerField(default=0)
1✔
61
    num_deleted_domain_addresses = models.PositiveIntegerField(default=0)
1✔
62
    num_email_forwarded_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
63
    num_email_blocked_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
64
    num_level_one_trackers_blocked_in_deleted_address = models.PositiveIntegerField(
1✔
65
        default=0, null=True
66
    )
67
    num_email_replied_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
68
    num_email_spam_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
69
    subdomain = models.CharField(
1✔
70
        blank=True,
71
        null=True,
72
        unique=True,
73
        max_length=63,
74
        db_index=True,
75
        validators=[valid_available_subdomain],
76
    )
77
    # Whether we store the user's alias labels in the server
78
    server_storage = models.BooleanField(default=True)
1✔
79
    # Whether we store the caller/sender log for the user's relay number
80
    store_phone_log = models.BooleanField(default=True)
1✔
81
    # TODO: Data migration to set null to false
82
    # TODO: Schema migration to remove null=True
83
    remove_level_one_email_trackers = models.BooleanField(null=True, default=False)
1✔
84
    onboarding_state = models.PositiveIntegerField(default=0)
1✔
85
    onboarding_free_state = models.PositiveIntegerField(default=0)
1✔
86
    auto_block_spam = models.BooleanField(default=False)
1✔
87
    forwarded_first_reply = models.BooleanField(default=False)
1✔
88
    # Empty string means the profile was created through relying party flow
89
    created_by = models.CharField(blank=True, null=True, max_length=63)
1✔
90
    sent_welcome_email = models.BooleanField(default=False)
1✔
91
    last_engagement = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
92

93
    class Meta:
1✔
94
        # Moved from emails to privaterelay, but old table name retained. See:
95
        # privaterelay/migrations/0010_move_profile_and_registered_subdomain_models.py
96
        # emails/migrations/0062_move_profile_and_registered_subdomain_models.py
97
        db_table = "emails_profile"
1✔
98

99
    def __str__(self):
1✔
100
        return f"{self.user} Profile"
1✔
101

102
    def save(
1✔
103
        self,
104
        force_insert: bool | tuple[ModelBase, ...] = False,
105
        force_update: bool = False,
106
        using: str | None = None,
107
        update_fields: Iterable[str] | None = None,
108
    ) -> None:
109
        from emails.models import DomainAddress, RelayAddress
1✔
110

111
        # always lower-case the subdomain before saving it
112
        # TODO: change subdomain field as a custom field inheriting from
113
        # CharField to validate constraints on the field update too
114
        if self.subdomain and not self.subdomain.islower():
1✔
115
            self.subdomain = self.subdomain.lower()
1✔
116
            if update_fields is not None:
1✔
117
                update_fields = {"subdomain"}.union(update_fields)
1✔
118
        super().save(
1✔
119
            force_insert=force_insert,
120
            force_update=force_update,
121
            using=using,
122
            update_fields=update_fields,
123
        )
124
        # any time a profile is saved with server_storage False, delete the
125
        # appropriate server-stored Relay address data.
126
        if not self.server_storage:
1✔
127
            relay_addresses = RelayAddress.objects.filter(user=self.user)
1✔
128
            relay_addresses.update(description="", generated_for="", used_on="")
1✔
129
            domain_addresses = DomainAddress.objects.filter(user=self.user)
1✔
130
            domain_addresses.update(description="", used_on="")
1✔
131
        if settings.PHONES_ENABLED:
1!
132
            # any time a profile is saved with store_phone_log False, delete the
133
            # appropriate server-stored InboundContact records
134
            from phones.models import InboundContact, RelayNumber
1✔
135

136
            if not self.store_phone_log:
1✔
137
                try:
1✔
138
                    relay_number = RelayNumber.objects.get(user=self.user)
1✔
139
                    InboundContact.objects.filter(relay_number=relay_number).delete()
1✔
140
                except RelayNumber.DoesNotExist:
1✔
141
                    pass
1✔
142

143
    @property
1✔
144
    def language(self):
1✔
145
        if self.fxa and self.fxa.extra_data.get("locale"):
1✔
146
            for accept_lang, _ in parse_accept_lang_header(
1!
147
                self.fxa.extra_data.get("locale")
148
            ):
149
                try:
1✔
150
                    return get_supported_language_variant(accept_lang)
1✔
151
                except LookupError:
×
152
                    continue
×
153
        return "en"
1✔
154

155
    # This method returns whether the locale associated with the user's Mozilla account
156
    # includes a country code from a Premium country. This is less accurate than using
157
    # get_countries_info_from_request_and_mapping(), which can use a GeoIP lookup, so
158
    # prefer using that if a request context is available. In other contexts, for
159
    # example when sending an email, this method can be useful.
160
    @property
1✔
161
    def fxa_locale_in_premium_country(self) -> bool:
1✔
162
        if self.fxa and self.fxa.extra_data.get("locale"):
1✔
163
            try:
1✔
164
                country = guess_country_from_accept_lang(self.fxa.extra_data["locale"])
1✔
165
            except AcceptLanguageError:
1✔
166
                return False
1✔
167
            premium_countries = get_premium_countries()
1✔
168
            if country in premium_countries:
1✔
169
                return True
1✔
170
        return False
1✔
171

172
    @property
1✔
173
    def avatar(self) -> str | None:
1✔
174
        if fxa := self.fxa:
1!
175
            return str(fxa.extra_data.get("avatar"))
1✔
176
        return None
×
177

178
    @property
1✔
179
    def relay_addresses(self) -> QuerySet[RelayAddress]:
1✔
180
        from emails.models import RelayAddress
1✔
181

182
        return RelayAddress.objects.filter(user=self.user)
1✔
183

184
    @property
1✔
185
    def domain_addresses(self) -> QuerySet[DomainAddress]:
1✔
186
        from emails.models import DomainAddress
1✔
187

188
        return DomainAddress.objects.filter(user=self.user)
1✔
189

190
    @property
1✔
191
    def total_masks(self) -> int:
1✔
192
        ra_count: int = self.relay_addresses.count()
1✔
193
        da_count: int = self.domain_addresses.count()
1✔
194
        return ra_count + da_count
1✔
195

196
    @property
1✔
197
    def at_mask_limit(self) -> bool:
1✔
198
        if self.has_premium:
1✔
199
            return False
1✔
200
        ra_count: int = self.relay_addresses.count()
1✔
201
        return ra_count >= settings.MAX_NUM_FREE_ALIASES
1✔
202

203
    def check_bounce_pause(self) -> BounceStatus:
1✔
204
        if self.last_hard_bounce:
1✔
205
            last_hard_bounce_allowed = datetime.now(UTC) - timedelta(
1✔
206
                days=settings.HARD_BOUNCE_ALLOWED_DAYS
207
            )
208
            if self.last_hard_bounce > last_hard_bounce_allowed:
1✔
209
                return BounceStatus(True, "hard")
1✔
210
            self.last_hard_bounce = None
1✔
211
            self.save()
1✔
212
        if self.last_soft_bounce:
1✔
213
            last_soft_bounce_allowed = datetime.now(UTC) - timedelta(
1✔
214
                days=settings.SOFT_BOUNCE_ALLOWED_DAYS
215
            )
216
            if self.last_soft_bounce > last_soft_bounce_allowed:
1✔
217
                return BounceStatus(True, "soft")
1✔
218
            self.last_soft_bounce = None
1✔
219
            self.save()
1✔
220
        return BounceStatus(False, "")
1✔
221

222
    @property
1✔
223
    def bounce_status(self) -> BounceStatus:
1✔
224
        return self.check_bounce_pause()
1✔
225

226
    @property
1✔
227
    def next_email_try(self) -> datetime:
1✔
228
        bounce_pause, bounce_type = self.check_bounce_pause()
1✔
229

230
        if not bounce_pause:
1✔
231
            return datetime.now(UTC)
1✔
232

233
        if bounce_type == "soft":
1✔
234
            if not self.last_soft_bounce:
1!
235
                raise ValueError("self.last_soft_bounce must be truthy value.")
×
236
            return self.last_soft_bounce + timedelta(
1✔
237
                days=settings.SOFT_BOUNCE_ALLOWED_DAYS
238
            )
239

240
        if bounce_type != "hard":
1!
241
            raise ValueError("bounce_type must be either 'soft' or 'hard'")
×
242
        if not self.last_hard_bounce:
1!
243
            raise ValueError("self.last_hard_bounce must be truthy value.")
×
244
        return self.last_hard_bounce + timedelta(days=settings.HARD_BOUNCE_ALLOWED_DAYS)
1✔
245

246
    @property
1✔
247
    def last_bounce_date(self):
1✔
248
        if self.last_hard_bounce:
1✔
249
            return self.last_hard_bounce
1✔
250
        if self.last_soft_bounce:
1✔
251
            return self.last_soft_bounce
1✔
252
        return None
1✔
253

254
    @property
1✔
255
    def at_max_free_aliases(self) -> bool:
1✔
256
        relay_addresses_count: int = self.relay_addresses.count()
1✔
257
        return relay_addresses_count >= settings.MAX_NUM_FREE_ALIASES
1✔
258

259
    @property
1✔
260
    def fxa(self) -> SocialAccount | None:
1✔
261
        # Note: we are NOT using .filter() here because it invalidates
262
        # any profile instances that were queried with prefetch_related, which
263
        # we use in at least the profile view to minimize queries
264
        if not hasattr(self.user, "socialaccount_set"):
1!
265
            raise AttributeError("self.user must have socialaccount_set attribute")
×
266
        for sa in self.user.socialaccount_set.all():
1✔
267
            if sa.provider == "fxa":
1!
268
                return sa
1✔
269
        return None
1✔
270

271
    @property
1✔
272
    def display_name(self) -> str | None:
1✔
273
        # if display name is not set on FxA the
274
        # displayName key will not exist on the extra_data
275
        if fxa := self.fxa:
1!
276
            name = fxa.extra_data.get("displayName")
1✔
277
            return name if name is None else str(name)
1✔
278
        return None
×
279

280
    @property
1✔
281
    def custom_domain(self) -> str:
1✔
282
        if not self.subdomain:
×
283
            raise ValueError("self.subdomain must be truthy value.")
×
284
        return f"@{self.subdomain}.{settings.MOZMAIL_DOMAIN}"
×
285

286
    @property
1✔
287
    def has_premium(self) -> bool:
1✔
288
        if not self.user.is_active:
1!
289
            return False
×
290

291
        # FIXME: as we don't have all the tiers defined we are over-defining
292
        # this to mark the user as a premium user as well
293
        if not self.fxa:
1✔
294
            return False
1✔
295
        for premium_domain in PREMIUM_DOMAINS:
1✔
296
            if self.user.email.endswith(f"@{premium_domain}"):
1!
297
                return True
×
298
        user_subscriptions = self.fxa.extra_data.get("subscriptions", [])
1✔
299
        for sub in settings.SUBSCRIPTIONS_WITH_UNLIMITED:
1✔
300
            if sub in user_subscriptions:
1✔
301
                return True
1✔
302
        return False
1✔
303

304
    @property
1✔
305
    def has_phone(self) -> bool:
1✔
306
        if not self.fxa:
1✔
307
            return False
1✔
308
        if settings.RELAY_CHANNEL != "prod" and not settings.IN_PYTEST:
1!
309
            if not flag_is_active_in_task("phones", self.user):
×
310
                return False
×
311
        if flag_is_active_in_task("free_phones", self.user):
1!
312
            return True
×
313
        user_subscriptions = self.fxa.extra_data.get("subscriptions", [])
1✔
314
        for sub in settings.SUBSCRIPTIONS_WITH_PHONE:
1✔
315
            if sub in user_subscriptions:
1✔
316
                return True
1✔
317
        return False
1✔
318

319
    @property
1✔
320
    def has_vpn(self) -> bool:
1✔
321
        if not self.fxa:
1!
322
            return False
×
323
        user_subscriptions = self.fxa.extra_data.get("subscriptions", [])
1✔
324
        for sub in settings.SUBSCRIPTIONS_WITH_VPN:
1✔
325
            if sub in user_subscriptions:
1✔
326
                return True
1✔
327
        return False
1✔
328

329
    @property
1✔
330
    def has_megabundle(self) -> bool:
1✔
331
        if not self.fxa:
1!
NEW
332
            return False
×
333
        user_subscriptions = self.fxa.extra_data.get("subscriptions", [])
1✔
334
        for sub in settings.SUBSCRIPTIONS_WITH_MEGABUNDLE:
1✔
335
            if sub in user_subscriptions:
1!
NEW
336
                return True
×
337
        return False
1✔
338

339
    @property
1✔
340
    def emails_forwarded(self) -> int:
1✔
341
        return (
1✔
342
            sum(ra.num_forwarded for ra in self.relay_addresses)
343
            + sum(da.num_forwarded for da in self.domain_addresses)
344
            + self.num_email_forwarded_in_deleted_address
345
        )
346

347
    @property
1✔
348
    def emails_blocked(self) -> int:
1✔
349
        return (
1✔
350
            sum(ra.num_blocked for ra in self.relay_addresses)
351
            + sum(da.num_blocked for da in self.domain_addresses)
352
            + self.num_email_blocked_in_deleted_address
353
        )
354

355
    @property
1✔
356
    def emails_replied(self) -> int:
1✔
357
        ra_sum = self.relay_addresses.aggregate(models.Sum("num_replied", default=0))
1✔
358
        da_sum = self.domain_addresses.aggregate(models.Sum("num_replied", default=0))
1✔
359
        return (
1✔
360
            int(ra_sum["num_replied__sum"])
361
            + int(da_sum["num_replied__sum"])
362
            + self.num_email_replied_in_deleted_address
363
        )
364

365
    @property
1✔
366
    def level_one_trackers_blocked(self) -> int:
1✔
367
        return (
1✔
368
            sum(ra.num_level_one_trackers_blocked or 0 for ra in self.relay_addresses)
369
            + sum(
370
                da.num_level_one_trackers_blocked or 0 for da in self.domain_addresses
371
            )
372
            + (self.num_level_one_trackers_blocked_in_deleted_address or 0)
373
        )
374

375
    @property
1✔
376
    def joined_before_premium_release(self):
1✔
377
        date_created = self.user.date_joined
1✔
378
        return date_created < datetime.fromisoformat("2021-10-22 17:00:00+00:00")
1✔
379

380
    @property
1✔
381
    def date_phone_registered(self) -> datetime | None:
1✔
382
        if not settings.PHONES_ENABLED:
1!
383
            return None
×
384

385
        from phones.models import RealPhone, RelayNumber
1✔
386

387
        try:
1✔
388
            real_phone = RealPhone.verified_objects.get_for_user(self.user)
1✔
389
            relay_number = RelayNumber.objects.get(user=self.user)
1✔
390
        except RealPhone.DoesNotExist:
1✔
391
            return None
1✔
392
        except RelayNumber.DoesNotExist:
1✔
393
            return real_phone.verified_date
1✔
394
        return relay_number.created_at or real_phone.verified_date
1✔
395

396
    def add_subdomain(self, subdomain):
1✔
397
        # Handles if the subdomain is "" or None
398
        if not subdomain:
1✔
399
            raise CannotMakeSubdomainException(
1✔
400
                "error-subdomain-cannot-be-empty-or-null"
401
            )
402

403
        # subdomain must be all lowercase
404
        subdomain = subdomain.lower()
1✔
405

406
        if not self.has_premium:
1✔
407
            raise CannotMakeSubdomainException("error-premium-set-subdomain")
1✔
408
        if self.subdomain is not None:
1✔
409
            raise CannotMakeSubdomainException("error-premium-cannot-change-subdomain")
1✔
410
        self.subdomain = subdomain
1✔
411
        # The validator defined in the subdomain field does not get run in full_clean()
412
        # when self.subdomain is "" or None, so we need to run the validator again to
413
        # catch these cases.
414
        valid_available_subdomain(subdomain)
1✔
415
        self.full_clean()
1✔
416
        self.save()
1✔
417

418
        RegisteredSubdomain.objects.create(subdomain_hash=hash_subdomain(subdomain))
1✔
419
        return subdomain
1✔
420

421
    def update_abuse_metric(
1✔
422
        self,
423
        address_created: bool = False,
424
        replied: bool = False,
425
        email_forwarded: bool = False,
426
        forwarded_email_size: int = 0,
427
    ) -> datetime | None:
428
        if self.user.email in settings.ALLOWED_ACCOUNTS:
1!
429
            return None
×
430

431
        with transaction.atomic():
1✔
432
            # look for abuse metrics created on the same UTC date, regardless of time.
433
            midnight_utc_today = datetime.combine(
1✔
434
                datetime.now(UTC).date(), datetime.min.time()
435
            ).astimezone(UTC)
436
            midnight_utc_tomorrow = midnight_utc_today + timedelta(days=1)
1✔
437
            abuse_metric = (
1✔
438
                self.user.abusemetrics_set.select_for_update()
439
                .filter(
440
                    first_recorded__gte=midnight_utc_today,
441
                    first_recorded__lt=midnight_utc_tomorrow,
442
                )
443
                .first()
444
            )
445
            if not abuse_metric:
1✔
446
                from emails.models import AbuseMetrics
1✔
447

448
                abuse_metric = AbuseMetrics.objects.create(user=self.user)
1✔
449
                AbuseMetrics.objects.filter(
1✔
450
                    first_recorded__lt=midnight_utc_today
451
                ).delete()
452

453
            # increment the abuse metric
454
            if address_created:
1✔
455
                abuse_metric.num_address_created_per_day += 1
1✔
456
            if replied:
1✔
457
                abuse_metric.num_replies_per_day += 1
1✔
458
            if email_forwarded:
1✔
459
                abuse_metric.num_email_forwarded_per_day += 1
1✔
460
            if forwarded_email_size > 0:
1✔
461
                abuse_metric.forwarded_email_size_per_day += forwarded_email_size
1✔
462
            abuse_metric.last_recorded = datetime.now(UTC)
1✔
463
            abuse_metric.save()
1✔
464

465
            # check user should be flagged for abuse
466
            hit_max_create = False
1✔
467
            hit_max_replies = False
1✔
468
            hit_max_forwarded = False
1✔
469
            hit_max_forwarded_email_size = False
1✔
470

471
            hit_max_create = (
1✔
472
                abuse_metric.num_address_created_per_day
473
                >= settings.MAX_ADDRESS_CREATION_PER_DAY
474
            )
475
            hit_max_replies = (
1✔
476
                abuse_metric.num_replies_per_day >= settings.MAX_REPLIES_PER_DAY
477
            )
478
            hit_max_forwarded = (
1✔
479
                abuse_metric.num_email_forwarded_per_day
480
                >= settings.MAX_FORWARDED_PER_DAY
481
            )
482
            hit_max_forwarded_email_size = (
1✔
483
                abuse_metric.forwarded_email_size_per_day
484
                >= settings.MAX_FORWARDED_EMAIL_SIZE_PER_DAY
485
            )
486
            if (
1✔
487
                hit_max_create
488
                or hit_max_replies
489
                or hit_max_forwarded
490
                or hit_max_forwarded_email_size
491
            ):
492
                self.last_account_flagged = datetime.now(UTC)
1✔
493
                self.save()
1✔
494
                data = {
1✔
495
                    "uid": self.fxa.uid if self.fxa else None,
496
                    "flagged": self.last_account_flagged.timestamp(),
497
                    "replies": abuse_metric.num_replies_per_day,
498
                    "addresses": abuse_metric.num_address_created_per_day,
499
                    "forwarded": abuse_metric.num_email_forwarded_per_day,
500
                    "forwarded_size_in_bytes": (
501
                        abuse_metric.forwarded_email_size_per_day
502
                    ),
503
                }
504
                # log for further secops review
505
                abuse_logger.info("Abuse flagged", extra=data)
1✔
506

507
        return self.last_account_flagged
1✔
508

509
    @property
1✔
510
    def is_flagged(self):
1✔
511
        if not self.last_account_flagged:
1✔
512
            return False
1✔
513
        account_premium_feature_resumed = self.last_account_flagged + timedelta(
1✔
514
            days=settings.PREMIUM_FEATURE_PAUSED_DAYS
515
        )
516
        if datetime.now(UTC) > account_premium_feature_resumed:
1!
517
            # premium feature has been resumed
518
            return False
×
519
        # user was flagged and the premium feature pause period is not yet over
520
        return True
1✔
521

522
    @property
1✔
523
    def metrics_enabled(self) -> bool:
1✔
524
        """
525
        Does the user allow us to record technical and interaction data?
526

527
        This is based on the Mozilla accounts opt-out option, added around 2022. A user
528
        can go to their Mozilla account profile settings, Data Collection and Use, and
529
        deselect "Help improve Mozilla Account". This setting defaults to On, and is
530
        sent as "metricsEnabled". Some older Relay accounts do not have
531
        "metricsEnabled", and we default to On.
532
        """
533
        if self.fxa:
1✔
534
            return bool(self.fxa.extra_data.get("metricsEnabled", True))
1✔
535
        return True
1✔
536

537
    @property
1✔
538
    def plan(self) -> Literal["free", "email", "phone", "bundle"]:
1✔
539
        """The user's Relay plan as a string."""
540
        if self.has_premium:
1✔
541
            if self.has_phone:
1✔
542
                return "bundle" if self.has_vpn else "phone"
1✔
543
            else:
544
                return "email"
1✔
545
        else:
546
            return "free"
1✔
547

548
    @property
1✔
549
    def plan_term(self) -> Literal[None, "unknown", "1_month", "1_year"]:
1✔
550
        """The user's Relay plan term as a string."""
551
        plan = self.plan
1✔
552
        if plan == "free":
1✔
553
            return None
1✔
554
        if plan == "phone":
1✔
555
            start_date = self.date_phone_subscription_start
1✔
556
            end_date = self.date_phone_subscription_end
1✔
557
            if start_date and end_date:
1✔
558
                span = end_date - start_date
1✔
559
                return "1_year" if span.days > 32 else "1_month"
1✔
560
        return "unknown"
1✔
561

562
    @property
1✔
563
    def metrics_premium_status(self) -> str:
1✔
564
        plan = self.plan
1✔
565
        if plan == "free":
1✔
566
            return "free"
1✔
567
        return f"{plan}_{self.plan_term}"
1✔
568

569
    @property
1✔
570
    def metrics_fxa_id(self) -> str:
1✔
571
        """Return Mozilla Accounts ID if user has metrics enabled, else empty string"""
572
        if (fxa := self.fxa) and self.metrics_enabled and isinstance(fxa.uid, str):
1✔
573
            return fxa.uid
1✔
574
        return ""
1✔
575

576

577
class RegisteredSubdomain(models.Model):
1✔
578
    subdomain_hash = models.CharField(max_length=64, db_index=True, unique=True)
1✔
579
    registered_at = models.DateTimeField(auto_now_add=True)
1✔
580

581
    def __str__(self):
1✔
582
        return self.subdomain_hash
×
583

584
    class Meta:
1✔
585
        # Moved from emails to privaterelay, but old table name retained. See:
586
        # privaterelay/migrations/0010_move_profile_and_registered_subdomain_models.py
587
        # emails/migrations/0062_move_profile_and_registered_subdomain_models.py
588
        db_table = "emails_registeredsubdomain"
1✔
589

590
    @classmethod
1✔
591
    def is_taken(cls, subdomain: str) -> bool:
1✔
592
        return cls.objects.filter(subdomain_hash=hash_subdomain(subdomain)).exists()
1✔
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