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

hasgeek / funnel / 8456388393

27 Mar 2024 05:54PM UTC coverage: 72.154% (-0.05%) from 72.204%
8456388393

Pull #2005

github

jace
Migrate project participants to account followers
Pull Request #2005: Support account follow

61 of 99 new or added lines in 6 files covered. (61.62%)

1 existing line in 1 file now uncovered.

13549 of 18778 relevant lines covered (72.15%)

1.44 hits per line

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

90.39
/funnel/models/account.py
1
"""Account model with subtypes, and account-linked personal data models."""
2✔
2

3
# pylint: disable=unnecessary-lambda,invalid-unary-operand-type
4
# pyright: reportGeneralTypeIssues=false,reportAttributeAccessIssue=false
5

6
from __future__ import annotations
2✔
7

8
import hashlib
2✔
9
import itertools
2✔
10
from collections.abc import Iterable, Iterator, Sequence
2✔
11
from datetime import datetime
2✔
12
from typing import (
2✔
13
    TYPE_CHECKING,
14
    Any,
15
    ClassVar,
16
    Literal,
17
    Self,
18
    TypeAlias,
19
    cast,
20
    overload,
21
)
22
from uuid import UUID
2✔
23

24
import phonenumbers
2✔
25
from babel import Locale
2✔
26
from furl import furl
2✔
27
from passlib.hash import argon2, bcrypt
2✔
28
from pytz.tzinfo import BaseTzInfo
2✔
29
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
2✔
30
from sqlalchemy.ext.hybrid import Comparator
2✔
31
from sqlalchemy.sql.expression import ColumnElement
2✔
32
from werkzeug.utils import cached_property
2✔
33
from zbase32 import decode as zbase32_decode, encode as zbase32_encode
2✔
34

35
from baseframe import __
2✔
36
from coaster.sqlalchemy import (
2✔
37
    DynamicAssociationProxy,
38
    RoleMixin,
39
    StateManager,
40
    add_primary_relationship,
41
    auto_init_default,
42
    failsafe_add,
43
    immutable,
44
    role_check,
45
    with_roles,
46
)
47
from coaster.utils import LabeledEnum, newsecret, require_one_of, utcnow
2✔
48

49
from ..typing import OptionalMigratedTables
2✔
50
from .base import (
2✔
51
    BaseMixin,
52
    DynamicMapped,
53
    LocaleType,
54
    Mapped,
55
    Model,
56
    Query,
57
    TimezoneType,
58
    TSVectorType,
59
    UrlType,
60
    UuidMixin,
61
    db,
62
    hybrid_property,
63
    relationship,
64
    sa,
65
    sa_exc,
66
    sa_orm,
67
)
68
from .email_address import EmailAddress, EmailAddressMixin
2✔
69
from .helpers import (
2✔
70
    RESERVED_NAMES,
71
    ImgeeType,
72
    MarkdownCompositeDocument,
73
    add_search_trigger,
74
    quote_autocomplete_like,
75
    quote_autocomplete_tsquery,
76
    valid_account_name,
77
    visual_field_delimiter,
78
)
79
from .phone_number import PhoneNumber, PhoneNumberMixin
2✔
80

81
__all__ = [
2✔
82
    'ACCOUNT_STATE',
83
    'Account',
84
    'AccountEmail',
85
    'AccountEmailClaim',
86
    'AccountExternalId',
87
    'AccountOldId',
88
    'AccountPhone',
89
    'Anchor',
90
    'Community',
91
    'deleted_account',
92
    'DuckTypeAccount',
93
    'Organization',
94
    'Placeholder',
95
    'removed_account',
96
    'Team',
97
    'unknown_account',
98
    'User',
99
]
100

101

102
class ACCOUNT_STATE(LabeledEnum):  # noqa: N801
2✔
103
    """State codes for accounts."""
2✔
104

105
    #: Regular, active account
106
    ACTIVE = (1, __("Active"))
2✔
107
    #: Suspended account (cause and explanation not included here)
108
    SUSPENDED = (2, __("Suspended"))
2✔
109
    #: Merged into another account
110
    MERGED = (3, __("Merged"))
2✔
111
    #: Permanently deleted account
112
    DELETED = (5, __("Deleted"))
2✔
113

114
    #: This account is gone
115
    GONE = {MERGED, DELETED}
2✔
116

117

118
class PROFILE_STATE(LabeledEnum):  # noqa: N801
2✔
119
    """The visibility state of an account (auto/public/private)."""
2✔
120

121
    AUTO = (1, 'auto', __("Autogenerated"))
2✔
122
    PUBLIC = (2, 'public', __("Public"))
2✔
123
    PRIVATE = (3, 'private', __("Private"))
2✔
124

125
    NOT_PUBLIC = {AUTO, PRIVATE}
2✔
126
    NOT_PRIVATE = {PUBLIC}
2✔
127

128

129
class ZBase32Comparator(Comparator[str]):  # pylint: disable=abstract-method
2✔
130
    """Comparator to allow lookup by Account.uuid_zbase32."""
2✔
131

132
    def __eq__(self, other: object) -> sa.ColumnElement[bool]:  # type: ignore[override]
2✔
133
        """Return an expression for column == other."""
134
        try:
2✔
135
            return self.__clause_element__() == UUID(  # type: ignore[return-value]
2✔
136
                bytes=zbase32_decode(str(other))
137
            )
138
        except ValueError:  # zbase32 call failed, so it's not a valid string
×
139
            return sa.false()
×
140

141

142
# --- Tables ---------------------------------------------------------------------------
143

144
team_membership = sa.Table(
2✔
145
    'team_membership',
146
    Model.metadata,
147
    sa.Column(
148
        'account_id',
149
        sa.Integer,
150
        sa.ForeignKey('account.id'),
151
        nullable=False,
152
        primary_key=True,
153
    ),
154
    sa.Column(
155
        'team_id',
156
        sa.Integer,
157
        sa.ForeignKey('team.id'),
158
        nullable=False,
159
        primary_key=True,
160
    ),
161
    sa.Column(
162
        'created_at',
163
        sa.TIMESTAMP(timezone=True),
164
        nullable=False,
165
        default=sa.func.utcnow(),
166
    ),
167
)
168

169

170
# --- Models ---------------------------------------------------------------------------
171

172

173
class Account(UuidMixin, BaseMixin[int, 'Account'], Model):
2✔
174
    """Account model."""
2✔
175

176
    __tablename__ = 'account'
2✔
177
    # Name has a length limit 63 to fit DNS label limit
178
    __name_length__ = 63
2✔
179
    # Titles can be longer
180
    __title_length__ = 80
2✔
181

182
    __active_membership_attrs__: ClassVar[set[str]] = set()
2✔
183
    __noninvite_membership_attrs__: ClassVar[set[str]] = set()
2✔
184

185
    # Helper flags (see subclasses)
186
    is_user_profile: ClassVar[bool] = False
2✔
187
    is_organization_profile: ClassVar[bool] = False
2✔
188
    is_community_profile: ClassVar[bool] = False
2✔
189
    is_placeholder_profile: ClassVar[bool] = False
2✔
190

191
    reserved_names: ClassVar[set[str]] = RESERVED_NAMES
2✔
192

193
    type_: Mapped[str] = sa_orm.mapped_column('type', sa.CHAR(1), nullable=False)
2✔
194

195
    #: Join date for users and organizations (skipped for placeholders)
196
    joined_at: Mapped[datetime | None] = sa_orm.mapped_column(
2✔
197
        sa.TIMESTAMP(timezone=True), nullable=True
198
    )
199

200
    #: The optional "username", used in the URL stub, with a unique constraint on the
201
    #: lowercase value (defined in __table_args__ below)
202
    name: Mapped[str | None] = with_roles(
2✔
203
        sa_orm.mapped_column(
204
            sa.Unicode(__name_length__),
205
            sa.CheckConstraint("name <> ''"),
206
            nullable=True,
207
        ),
208
        read={'all'},
209
    )
210

211
    #: The account's title (user's fullname)
212
    title: Mapped[str] = with_roles(
2✔
213
        sa_orm.mapped_column(sa.Unicode(__title_length__), default='', nullable=False),
214
        read={'all'},
215
    )
216
    #: Alias title as user's fullname
217
    fullname: Mapped[str] = sa_orm.synonym('title')
2✔
218
    #: Alias name as user's username
219
    username: Mapped[str | None] = sa_orm.synonym('name')
2✔
220

221
    #: Argon2 or Bcrypt hash of the user's password
222
    pw_hash: Mapped[str | None] = sa_orm.mapped_column()
2✔
223
    #: Timestamp for when the user's password last changed
224
    pw_set_at: Mapped[datetime | None] = sa_orm.mapped_column(
2✔
225
        sa.TIMESTAMP(timezone=True), nullable=True
226
    )
227
    #: Expiry date for the password (to prompt user to reset it)
228
    pw_expires_at: Mapped[datetime | None] = sa_orm.mapped_column(
2✔
229
        sa.TIMESTAMP(timezone=True), nullable=True
230
    )
231
    #: User's preferred/last known timezone
232
    timezone: Mapped[BaseTzInfo | None] = with_roles(
2✔
233
        sa_orm.mapped_column(TimezoneType(backend='pytz'), nullable=True),
234
        read={'owner'},
235
    )
236
    #: Update timezone automatically from browser activity
237
    auto_timezone: Mapped[bool] = sa_orm.mapped_column(default=True)
2✔
238
    #: User's preferred/last known locale
239
    locale: Mapped[Locale | None] = with_roles(
2✔
240
        sa_orm.mapped_column(LocaleType, nullable=True), read={'owner'}
241
    )
242
    #: Update locale automatically from browser activity
243
    auto_locale: Mapped[bool] = sa_orm.mapped_column(default=True)
2✔
244
    #: User's state code (active, suspended, merged, deleted)
245
    _state: Mapped[int] = sa_orm.mapped_column(
2✔
246
        'state',
247
        sa.SmallInteger,
248
        StateManager.check_constraint('state', ACCOUNT_STATE, sa.SmallInteger),
249
        nullable=False,
250
        default=ACCOUNT_STATE.ACTIVE,
251
    )
252
    #: Account state manager
253
    state = StateManager['Account']('_state', ACCOUNT_STATE, doc="Account state")
2✔
254
    #: Other accounts that were merged into this account
255
    old_accounts: AssociationProxy[list[Account]] = association_proxy(
2✔
256
        'oldids', 'old_account'
257
    )
258

259
    _profile_state: Mapped[int] = sa_orm.mapped_column(
2✔
260
        'profile_state',
261
        sa.SmallInteger,
262
        StateManager.check_constraint('profile_state', PROFILE_STATE, sa.SmallInteger),
263
        nullable=False,
264
        default=PROFILE_STATE.AUTO,
265
    )
266
    profile_state = StateManager['Account'](
2✔
267
        '_profile_state', PROFILE_STATE, doc="Current state of the account profile"
268
    )
269

270
    tagline: Mapped[str | None] = sa_orm.mapped_column(
2✔
271
        sa.CheckConstraint("tagline <> ''")
272
    )
273
    description, description_text, description_html = MarkdownCompositeDocument.create(
2✔
274
        'description', default='', nullable=False
275
    )
276
    website: Mapped[furl | None] = sa_orm.mapped_column(
2✔
277
        UrlType, sa.CheckConstraint("website <> ''"), nullable=True
278
    )
279
    logo_url: Mapped[furl | None] = sa_orm.mapped_column(
2✔
280
        ImgeeType, sa.CheckConstraint("logo_url <> ''"), nullable=True
281
    )
282
    banner_image_url: Mapped[furl | None] = sa_orm.mapped_column(
2✔
283
        ImgeeType, sa.CheckConstraint("banner_image_url <> ''"), nullable=True
284
    )
285

286
    #: Protected accounts cannot be deleted
287
    is_protected: Mapped[bool] = with_roles(
2✔
288
        immutable(sa_orm.mapped_column(default=False)),
289
        read={'owner', 'admin'},
290
    )
291
    #: Verified accounts get listed on the home page and are not considered throwaway
292
    #: accounts for spam control. There are no other privileges at this time
293
    is_verified: Mapped[bool] = with_roles(
2✔
294
        sa_orm.mapped_column(default=False, index=True),
295
        read={'all'},
296
    )
297

298
    #: Revision number maintained by SQLAlchemy, starting at 1
299
    revisionid: Mapped[int] = with_roles(sa_orm.mapped_column(), read={'all'})
2✔
300

301
    search_vector: Mapped[str] = sa_orm.mapped_column(
2✔
302
        TSVectorType(
303
            'title',
304
            'name',
305
            'tagline',
306
            'description_text',
307
            weights={
308
                'title': 'A',
309
                'name': 'A',
310
                'tagline': 'B',
311
                'description_text': 'B',
312
            },
313
            regconfig='english',
314
            hltext=lambda: sa.func.concat_ws(
315
                visual_field_delimiter,
316
                Account.title,
317
                Account.name,
318
                Account.tagline,
319
                Account.description_html,
320
            ),
321
        ),
322
        nullable=False,
323
        deferred=True,
324
    )
325

326
    name_vector: Mapped[str] = sa_orm.mapped_column(
2✔
327
        TSVectorType(
328
            'title',
329
            'name',
330
            regconfig='simple',
331
            hltext=lambda: sa.func.concat_ws(' @', Account.title, Account.name),
332
        ),
333
        nullable=False,
334
        deferred=True,
335
    )
336

337
    # --- Backrefs
338

339
    # account.py:
340
    oldid: Mapped[AccountOldId] = relationship(
2✔
341
        primaryjoin=lambda: sa_orm.foreign(AccountOldId.id) == Account.uuid,
342
        uselist=False,
343
        back_populates='old_account',
344
    )
345
    oldids: Mapped[list[AccountOldId]] = relationship(
2✔
346
        foreign_keys=lambda: AccountOldId.account_id, back_populates='account'
347
    )
348
    teams: Mapped[list[Team]] = relationship(
2✔
349
        foreign_keys=lambda: Team.account_id,
350
        order_by=lambda: sa.func.lower(Team.title),
351
        back_populates='account',
352
    )
353
    member_teams: Mapped[list[Team]] = relationship(
2✔
354
        secondary='team_membership', back_populates='users'
355
    )
356
    emails: Mapped[list[AccountEmail]] = relationship(back_populates='account')
2✔
357
    emailclaims: Mapped[list[AccountEmailClaim]] = relationship(
2✔
358
        back_populates='account'
359
    )
360
    phones: Mapped[list[AccountPhone]] = relationship(back_populates='account')
2✔
361
    externalids: Mapped[list[AccountExternalId]] = relationship(
2✔
362
        back_populates='account'
363
    )
364

365
    # account_membership.py
366
    memberships: DynamicMapped[AccountMembership] = relationship(
2✔
367
        foreign_keys=lambda: AccountMembership.account_id,
368
        lazy='dynamic',
369
        passive_deletes=True,
370
        back_populates='account',
371
    )
372
    active_follower_memberships: DynamicMapped[AccountMembership] = with_roles(
2✔
373
        relationship(
374
            lazy='dynamic',
375
            primaryjoin=lambda: sa.and_(
376
                sa_orm.remote(AccountMembership.account_id) == Account.id,
377
                AccountMembership.is_active,
378
            ),
379
            order_by=lambda: AccountMembership.granted_at.asc(),
380
            viewonly=True,
381
        ),
382
        read={'reader'},
383
        # Use offered_roles to determine which roles the user gets
384
        grants_via={
385
            'member': {
386
                'follower': 'follower',
387
                'member': 'member',
388
                'admin': 'admin',
389
                'owner': 'owner',
390
            }
391
        },
392
    )
393
    active_admin_memberships: DynamicMapped[AccountMembership] = relationship(
2✔
394
        lazy='dynamic',
395
        primaryjoin=lambda: sa.and_(
396
            sa_orm.remote(AccountMembership.account_id) == Account.id,
397
            AccountMembership.is_active,
398
            AccountMembership.is_admin.is_(True),
399
        ),
400
        order_by=lambda: AccountMembership.granted_at.asc(),
401
        viewonly=True,
402
    )
403

404
    active_owner_memberships: DynamicMapped[AccountMembership] = relationship(
2✔
405
        lazy='dynamic',
406
        primaryjoin=lambda: sa.and_(
407
            sa_orm.remote(AccountMembership.account_id) == Account.id,
408
            AccountMembership.is_active,
409
            AccountMembership.is_owner.is_(True),
410
        ),
411
        viewonly=True,
412
    )
413

414
    active_invitations: DynamicMapped[AccountMembership] = relationship(
2✔
415
        lazy='dynamic',
416
        primaryjoin=lambda: sa.and_(
417
            sa_orm.remote(AccountMembership.account_id) == Account.id,
418
            AccountMembership.is_invite,
419
            AccountMembership.revoked_at.is_(None),
420
        ),
421
        viewonly=True,
422
    )
423

424
    owner_users = with_roles(
2✔
425
        DynamicAssociationProxy['Account']('active_owner_memberships', 'member'),
426
        read={'all'},
427
    )
428
    admin_users = with_roles(
2✔
429
        DynamicAssociationProxy['Account']('active_admin_memberships', 'member'),
430
        read={'all'},
431
    )
432
    followers = with_roles(
2✔
433
        DynamicAssociationProxy['Account']('active_follower_memberships', 'member'),
434
        read={'reader'},
435
    )
436

437
    organization_admin_memberships: DynamicMapped[AccountMembership] = relationship(
2✔
438
        lazy='dynamic',
439
        foreign_keys=lambda: AccountMembership.member_id,
440
        viewonly=True,
441
    )
442

443
    noninvite_organization_admin_memberships: DynamicMapped[AccountMembership] = (
2✔
444
        relationship(
445
            lazy='dynamic',
446
            foreign_keys=lambda: AccountMembership.member_id,
447
            primaryjoin=lambda: sa.and_(
448
                sa_orm.remote(AccountMembership.member_id) == Account.id,
449
                ~AccountMembership.is_invite,
450
            ),
451
            viewonly=True,
452
        )
453
    )
454
    active_organization_admin_memberships: DynamicMapped[AccountMembership] = (
2✔
455
        relationship(
456
            lazy='dynamic',
457
            foreign_keys=lambda: AccountMembership.member_id,
458
            primaryjoin=lambda: sa.and_(
459
                sa_orm.remote(AccountMembership.member_id) == Account.id,
460
                AccountMembership.is_active,
461
            ),
462
            viewonly=True,
463
        )
464
    )
465
    active_organization_owner_memberships: DynamicMapped[AccountMembership] = (
2✔
466
        relationship(
467
            lazy='dynamic',
468
            foreign_keys=lambda: AccountMembership.member_id,
469
            primaryjoin=lambda: sa.and_(
470
                sa_orm.remote(AccountMembership.member_id) == Account.id,
471
                AccountMembership.is_active,
472
                AccountMembership.is_owner.is_(True),
473
            ),
474
            viewonly=True,
475
        )
476
    )
477
    active_organization_invitations: DynamicMapped[AccountMembership] = relationship(
2✔
478
        lazy='dynamic',
479
        foreign_keys=lambda: AccountMembership.member_id,
480
        primaryjoin=lambda: sa.and_(
481
            sa_orm.remote(AccountMembership.member_id) == Account.id,
482
            AccountMembership.is_invite,
483
            AccountMembership.revoked_at.is_(None),
484
        ),
485
        viewonly=True,
486
    )
487
    active_following_memberships: DynamicMapped[AccountMembership] = relationship(
2✔
488
        lazy='dynamic',
489
        primaryjoin=lambda: sa.and_(
490
            sa_orm.remote(AccountMembership.member_id) == Account.id,
491
            AccountMembership.is_active,
492
        ),
493
        order_by=lambda: AccountMembership.granted_at.asc(),
494
        viewonly=True,
495
    )
496

497
    organizations_as_owner = DynamicAssociationProxy['Account'](
2✔
498
        'active_organization_owner_memberships', 'account'
499
    )
500
    organizations_as_admin = DynamicAssociationProxy['Account'](
2✔
501
        'active_organization_admin_memberships', 'account'
502
    )
503
    accounts_following = with_roles(
2✔
504
        DynamicAssociationProxy['Account']('active_following_memberships', 'account'),
505
        read={'all'},
506
    )
507

508
    # auth_client.py
509
    clients: Mapped[AuthClient] = relationship(back_populates='account')
2✔
510
    authtokens: DynamicMapped[AuthToken] = relationship(
2✔
511
        lazy='dynamic', back_populates='account'
512
    )
513
    client_permissions: Mapped[list[AuthClientPermissions]] = relationship(
2✔
514
        back_populates='account'
515
    )
516

517
    # comment.py
518
    comments: DynamicMapped[Comment] = relationship(
2✔
519
        lazy='dynamic', back_populates='_posted_by'
520
    )
521
    # commentset_membership.py
522
    active_commentset_memberships: DynamicMapped[CommentsetMembership] = relationship(
2✔
523
        lazy='dynamic',
524
        primaryjoin='''and_(
525
            CommentsetMembership.member_id == Account.id,
526
            CommentsetMembership.is_active,
527
        )''',
528
        viewonly=True,
529
    )
530
    subscribed_commentsets = DynamicAssociationProxy['Commentset'](
2✔
531
        'active_commentset_memberships', 'commentset'
532
    )
533

534
    # contact_exchange.py
535
    scanned_contacts: DynamicMapped[ContactExchange] = relationship(
2✔
536
        lazy='dynamic',
537
        order_by='ContactExchange.scanned_at.desc()',
538
        passive_deletes=True,
539
        back_populates='account',
540
    )
541

542
    # login_session.py
543
    all_login_sessions: DynamicMapped[LoginSession] = relationship(
2✔
544
        lazy='dynamic', back_populates='account'
545
    )
546
    active_login_sessions: DynamicMapped[LoginSession] = relationship(
2✔
547
        lazy='dynamic',
548
        primaryjoin=lambda: sa.and_(
549
            LoginSession.account_id == Account.id,
550
            LoginSession.accessed_at > sa.func.utcnow() - LOGIN_SESSION_VALIDITY_PERIOD,
551
            LoginSession.revoked_at.is_(None),
552
        ),
553
        order_by=lambda: LoginSession.accessed_at.desc(),
554
        viewonly=True,
555
    )
556

557
    # mailer.py
558
    mailers: Mapped[list[Mailer]] = relationship(
2✔
559
        back_populates='user', order_by=lambda: Mailer.updated_at.desc()
560
    )
561

562
    # moderation.py
563
    moderator_reports: DynamicMapped[CommentModeratorReport] = relationship(
2✔
564
        lazy='dynamic', back_populates='reported_by'
565
    )
566

567
    # notification.py
568
    all_notifications: DynamicMapped[NotificationRecipient] = with_roles(
2✔
569
        relationship(
570
            lazy='dynamic',
571
            order_by=lambda: NotificationRecipient.created_at.desc(),
572
            viewonly=True,
573
        ),
574
        read={'owner'},
575
    )
576

577
    notification_preferences: Mapped[dict[str, NotificationPreferences]] = relationship(
2✔
578
        collection_class=sa_orm.attribute_keyed_dict('notification_type'),
579
        back_populates='account',
580
    )
581

582
    # This relationship is wrapped in a property that creates it on first access
583
    _main_notification_preferences: Mapped[NotificationPreferences] = relationship(
2✔
584
        primaryjoin=lambda: sa.and_(
585
            NotificationPreferences.account_id == Account.id,
586
            NotificationPreferences.notification_type == '',
587
        ),
588
        uselist=False,
589
        viewonly=True,
590
    )
591

592
    @cached_property
2✔
593
    def main_notification_preferences(self) -> NotificationPreferences:
2✔
594
        """Return user's main notification preferences, toggling transports on/off."""
595
        if not self._main_notification_preferences:
2✔
596
            main = NotificationPreferences(
2✔
597
                notification_type='',
598
                account=self,
599
                by_email=True,
600
                by_sms=True,
601
                by_webpush=False,
602
                by_telegram=False,
603
                by_whatsapp=False,
604
            )
605
            db.session.add(main)
2✔
606
            return main
2✔
607
        return self._main_notification_preferences
2✔
608

609
    # project_membership.py
610
    projects_as_crew_memberships: DynamicMapped[ProjectMembership] = relationship(
2✔
611
        lazy='dynamic',
612
        foreign_keys=lambda: ProjectMembership.member_id,
613
        viewonly=True,
614
    )
615

616
    # This is used to determine if it is safe to purge the subject's database record
617
    projects_as_crew_noninvite_memberships: DynamicMapped[ProjectMembership] = (
2✔
618
        relationship(
619
            lazy='dynamic',
620
            primaryjoin=lambda: sa.and_(
621
                ProjectMembership.member_id == Account.id,
622
                ~ProjectMembership.is_invite,
623
            ),
624
            viewonly=True,
625
        )
626
    )
627
    projects_as_crew_active_memberships: DynamicMapped[ProjectMembership] = (
2✔
628
        relationship(
629
            lazy='dynamic',
630
            primaryjoin=lambda: sa.and_(
631
                ProjectMembership.member_id == Account.id,
632
                ProjectMembership.is_active,
633
            ),
634
            viewonly=True,
635
        )
636
    )
637

638
    projects_as_crew = DynamicAssociationProxy['Project'](
2✔
639
        'projects_as_crew_active_memberships', 'project'
640
    )
641

642
    projects_as_editor_active_memberships: DynamicMapped[ProjectMembership] = (
2✔
643
        relationship(
644
            lazy='dynamic',
645
            primaryjoin=lambda: sa.and_(
646
                ProjectMembership.member_id == Account.id,
647
                ProjectMembership.is_active,
648
                ProjectMembership.is_editor.is_(True),
649
            ),
650
            viewonly=True,
651
        )
652
    )
653

654
    projects_as_editor = DynamicAssociationProxy['Project'](
2✔
655
        'projects_as_editor_active_memberships', 'project'
656
    )
657

658
    # project.py
659
    projects: DynamicMapped[Project] = relationship(
2✔
660
        lazy='dynamic',
661
        foreign_keys=lambda: Project.account_id,
662
        back_populates='account',
663
    )
664
    project_redirects: DynamicMapped[ProjectRedirect] = relationship(
2✔
665
        lazy='dynamic', back_populates='account'
666
    )
667

668
    listed_projects: DynamicMapped[Project] = relationship(
2✔
669
        lazy='dynamic',
670
        primaryjoin=lambda: sa.and_(
671
            Account.id == Project.account_id,
672
            Project.state.PUBLISHED,
673
        ),
674
        viewonly=True,
675
    )
676
    draft_projects: DynamicMapped[Project] = relationship(
2✔
677
        lazy='dynamic',
678
        primaryjoin=lambda: sa.and_(
679
            Account.id == Project.account_id,
680
            sa.or_(Project.state.DRAFT, Project.cfp_state.DRAFT),
681
        ),
682
        viewonly=True,
683
    )
684
    projects_by_name: Mapped[dict[str, Project]] = with_roles(
2✔
685
        relationship(
686
            foreign_keys=lambda: Project.account_id,
687
            collection_class=sa_orm.attribute_keyed_dict('name'),
688
            viewonly=True,
689
        ),
690
        read={'all'},
691
    )
692

693
    # proposal_membership.py
694
    all_proposal_memberships: DynamicMapped[ProposalMembership] = relationship(
2✔
695
        lazy='dynamic',
696
        foreign_keys=lambda: ProposalMembership.member_id,
697
        viewonly=True,
698
    )
699

700
    noninvite_proposal_memberships: DynamicMapped[ProposalMembership] = relationship(
2✔
701
        lazy='dynamic',
702
        primaryjoin=lambda: sa.and_(
703
            ProposalMembership.member_id == Account.id,
704
            ~ProposalMembership.is_invite,
705
        ),
706
        viewonly=True,
707
    )
708

709
    proposal_memberships: DynamicMapped[ProposalMembership] = relationship(
2✔
710
        lazy='dynamic',
711
        primaryjoin=lambda: sa.and_(
712
            ProposalMembership.member_id == Account.id,
713
            ProposalMembership.is_active,
714
        ),
715
        viewonly=True,
716
    )
717

718
    # This is a User property of the proposals the user account is a collaborator in
719
    proposals = DynamicAssociationProxy['Proposal']('proposal_memberships', 'proposal')
2✔
720

721
    @property
2✔
722
    def public_proposal_memberships(self) -> Query[ProposalMembership]:
2✔
723
        """Query for all proposal memberships to proposals that are public."""
724
        # TODO: Include proposal state filter (pending proposal workflow fix)
725
        return (
×
726
            self.proposal_memberships.join(Proposal, ProposalMembership.proposal)
727
            .join(Project, Proposal.project)
728
            .filter(ProposalMembership.is_uncredited.is_(False))
729
        )
730

731
    public_proposals = DynamicAssociationProxy['Proposal'](
2✔
732
        'public_proposal_memberships', 'proposal'
733
    )
734

735
    # proposal.py
736
    created_proposals: DynamicMapped[Proposal] = relationship(
2✔
737
        lazy='dynamic', back_populates='created_by'
738
    )
739

740
    # rsvp.py
741
    rsvps: DynamicMapped[Rsvp] = relationship(
2✔
742
        lazy='dynamic', back_populates='participant'
743
    )
744

745
    @property
2✔
746
    def rsvp_followers(self) -> Query[Account]:
2✔
747
        """All users with an active RSVP in a project."""
748
        return (
2✔
749
            Account.query.filter(Account.state.ACTIVE)
750
            .join(Rsvp, Rsvp.participant_id == Account.id)
751
            .join(Project, Rsvp.project_id == Project.id)
752
            .filter(Rsvp.state.YES, Project.state.PUBLISHED, Project.account == self)
753
        )
754

755
    with_roles(rsvp_followers, grants={'follower'})
2✔
756

757
    # saved.py
758
    saved_projects: DynamicMapped[SavedProject] = relationship(
2✔
759
        lazy='dynamic', passive_deletes=True, back_populates='account'
760
    )
761
    saved_sessions: DynamicMapped[SavedSession] = relationship(
2✔
762
        lazy='dynamic', passive_deletes=True, back_populates='account'
763
    )
764

765
    def saved_sessions_in(self, project: Project) -> Query[SavedSession]:
2✔
766
        return self.saved_sessions.join(Session).filter(Session.project == project)
×
767

768
    # site_membership.py
769
    # Singular, as only one can be active
770
    active_site_membership: Mapped[SiteMembership] = relationship(
2✔
771
        lazy='select',
772
        primaryjoin=lambda: sa.and_(
773
            SiteMembership.member_id == Account.id, SiteMembership.is_active
774
        ),
775
        viewonly=True,
776
        uselist=False,
777
    )
778

779
    @cached_property
2✔
780
    def is_comment_moderator(self) -> bool:
2✔
781
        """Test if this user is a comment moderator."""
782
        return (
2✔
783
            self.active_site_membership is not None
784
            and self.active_site_membership.is_comment_moderator
785
        )
786

787
    @cached_property
2✔
788
    def is_user_moderator(self) -> bool:
2✔
789
        """Test if this user is an account moderator."""
790
        return (
2✔
791
            self.active_site_membership is not None
792
            and self.active_site_membership.is_user_moderator
793
        )
794

795
    @cached_property
2✔
796
    def is_site_editor(self) -> bool:
2✔
797
        """Test if this user is a site editor."""
798
        return (
2✔
799
            self.active_site_membership is not None
800
            and self.active_site_membership.is_site_editor
801
        )
802

803
    @cached_property
2✔
804
    def is_sysadmin(self) -> bool:
2✔
805
        """Test if this user is a sysadmin."""
806
        return (
2✔
807
            self.active_site_membership is not None
808
            and self.active_site_membership.is_sysadmin
809
        )
810

811
    # site_admin means user has one or more of above roles
812
    @cached_property
2✔
813
    def is_site_admin(self) -> bool:
2✔
814
        """Test if this user has any site-level admin rights."""
815
        return self.active_site_membership is not None
2✔
816

817
    # sponsor_membership.py
818
    noninvite_project_sponsor_memberships: DynamicMapped[ProjectSponsorMembership] = (
2✔
819
        relationship(
820
            lazy='dynamic',
821
            primaryjoin=lambda: sa.and_(
822
                ProjectSponsorMembership.member_id == Account.id,
823
                ~ProjectSponsorMembership.is_invite,
824
            ),
825
            order_by=lambda: ProjectSponsorMembership.granted_at.desc(),
826
            viewonly=True,
827
        )
828
    )
829

830
    project_sponsor_memberships: DynamicMapped[ProjectSponsorMembership] = relationship(
2✔
831
        lazy='dynamic',
832
        primaryjoin=lambda: sa.and_(
833
            ProjectSponsorMembership.member_id == Account.id,
834
            ProjectSponsorMembership.is_active,
835
        ),
836
        order_by=lambda: ProjectSponsorMembership.granted_at.desc(),
837
        viewonly=True,
838
    )
839

840
    project_sponsor_membership_invites: DynamicMapped[ProjectSponsorMembership] = (
2✔
841
        with_roles(
842
            relationship(
843
                lazy='dynamic',
844
                primaryjoin=lambda: sa.and_(
845
                    ProjectSponsorMembership.member_id == Account.id,
846
                    ProjectSponsorMembership.is_invite,
847
                    ProjectSponsorMembership.revoked_at.is_(None),
848
                ),
849
                order_by=lambda: ProjectSponsorMembership.granted_at.desc(),
850
                viewonly=True,
851
            ),
852
            read={'admin'},
853
        )
854
    )
855

856
    noninvite_proposal_sponsor_memberships: DynamicMapped[ProposalSponsorMembership] = (
2✔
857
        relationship(
858
            lazy='dynamic',
859
            primaryjoin=lambda: sa.and_(
860
                ProposalSponsorMembership.member_id == Account.id,
861
                ~ProposalSponsorMembership.is_invite,
862
            ),
863
            order_by=lambda: ProposalSponsorMembership.granted_at.desc(),
864
            viewonly=True,
865
        )
866
    )
867

868
    proposal_sponsor_memberships: DynamicMapped[ProposalSponsorMembership] = (
2✔
869
        relationship(
870
            lazy='dynamic',
871
            primaryjoin=lambda: sa.and_(
872
                ProposalSponsorMembership.member_id == Account.id,
873
                ProposalSponsorMembership.is_active,
874
            ),
875
            order_by=lambda: ProposalSponsorMembership.granted_at.desc(),
876
            viewonly=True,
877
        )
878
    )
879

880
    proposal_sponsor_membership_invites: DynamicMapped[ProposalSponsorMembership] = (
2✔
881
        with_roles(
882
            relationship(
883
                lazy='dynamic',
884
                primaryjoin=lambda: sa.and_(
885
                    ProposalSponsorMembership.member_id == Account.id,
886
                    ProposalSponsorMembership.is_invite,
887
                    ProposalSponsorMembership.revoked_at.is_(None),
888
                ),
889
                order_by=lambda: ProposalSponsorMembership.granted_at.desc(),
890
                viewonly=True,
891
            ),
892
            read={'admin'},
893
        )
894
    )
895

896
    sponsored_projects = DynamicAssociationProxy['Project'](
2✔
897
        'project_sponsor_memberships', 'project'
898
    )
899

900
    sponsored_proposals = DynamicAssociationProxy['Project'](
2✔
901
        'proposal_sponsor_memberships', 'proposal'
902
    )
903

904
    # sync_ticket.py:
905
    ticket_participants: Mapped[list[TicketParticipant]] = relationship(
2✔
906
        back_populates='participant'
907
    )
908

909
    @property
2✔
910
    def ticket_followers(self) -> Query[Account]:
2✔
911
        """All users with a ticket in a project."""
912
        return (
2✔
913
            Account.query.filter(Account.state.ACTIVE)
914
            .join(TicketParticipant, TicketParticipant.participant_id == Account.id)
915
            .join(Project, TicketParticipant.project_id == Project.id)
916
            .filter(Project.state.PUBLISHED, Project.account == self)
917
        )
918

919
    with_roles(ticket_followers, grants={'follower'})
2✔
920

921
    # update.py
922
    created_updates: DynamicMapped[Update] = relationship(
2✔
923
        lazy='dynamic',
924
        foreign_keys=lambda: Update.created_by_id,
925
        back_populates='created_by',
926
    )
927
    published_updates: DynamicMapped[Update] = relationship(
2✔
928
        lazy='dynamic',
929
        foreign_keys=lambda: Update.published_by_id,
930
        back_populates='published_by',
931
    )
932
    deleted_updates: DynamicMapped[Update] = relationship(
2✔
933
        lazy='dynamic',
934
        foreign_keys=lambda: Update.deleted_by_id,
935
        back_populates='deleted_by',
936
    )
937

938
    __table_args__ = (
2✔
939
        sa.Index(
940
            'ix_account_name_lower',
941
            sa.func.lower(name).label('name_lower'),
942
            unique=True,
943
            postgresql_ops={'name_lower': 'varchar_pattern_ops'},
944
        ),
945
        sa.Index(
946
            'ix_account_title_lower',
947
            sa.func.lower(title).label('title_lower'),
948
            postgresql_ops={'title_lower': 'varchar_pattern_ops'},
949
        ),
950
        sa.Index('ix_account_search_vector', 'search_vector', postgresql_using='gin'),
951
        sa.Index('ix_account_name_vector', 'name_vector', postgresql_using='gin'),
952
    )
953

954
    __mapper_args__ = {
2✔
955
        # 'polymorphic_identity' from subclasses is stored in the type column
956
        'polymorphic_on': type_,
957
        # When querying the Account model, cast automatically to all subclasses
958
        'with_polymorphic': '*',
959
        # Store a version id in this column to prevent edits to obsolete data
960
        'version_id_col': revisionid,
961
    }
962

963
    __roles__ = {
2✔
964
        'all': {
965
            'read': {
966
                'uuid',
967
                'name',
968
                'urlname',
969
                'title',
970
                'fullname',
971
                'username',
972
                'pickername',
973
                'timezone',
974
                'description',
975
                'website',
976
                'logo_url',
977
                'banner_image_url',
978
                'joined_at',
979
                'absolute_url',
980
                'urls',
981
                'is_user_profile',
982
                'is_organization_profile',
983
                'is_placeholder_profile',
984
            },
985
            'call': {'views', 'forms', 'features', 'url_for', 'state', 'profile_state'},
986
        }
987
    }
988

989
    __datasets__ = {
2✔
990
        'primary': {
991
            'urls',
992
            'uuid_b58',
993
            'name',
994
            'urlname',
995
            'title',
996
            'fullname',
997
            'username',
998
            'pickername',
999
            'timezone',
1000
            'description',
1001
            'logo_url',
1002
            'website',
1003
            'joined_at',
1004
            'absolute_url',
1005
            'is_verified',
1006
        },
1007
        'related': {
1008
            'urls',
1009
            'uuid_b58',
1010
            'name',
1011
            'urlname',
1012
            'title',
1013
            'fullname',
1014
            'username',
1015
            'pickername',
1016
            'timezone',
1017
            'description',
1018
            'logo_url',
1019
            'joined_at',
1020
            'absolute_url',
1021
            'is_verified',
1022
        },
1023
    }
1024
    __json_datasets__ = ('primary', 'related')
2✔
1025

1026
    profile_state.add_conditional_state(
2✔
1027
        'ACTIVE_AND_PUBLIC',
1028
        profile_state.PUBLIC,
1029
        lambda account: bool(account.state.ACTIVE),
1030
    )
1031

1032
    @classmethod
2✔
1033
    def _defercols(cls) -> list[sa_orm.interfaces.LoaderOption]:
2✔
1034
        """Return columns that are typically deferred when loading a user."""
1035
        defer = sa_orm.defer
2✔
1036
        return [
2✔
1037
            defer(cls.created_at),
1038
            defer(cls.updated_at),
1039
            defer(cls.pw_hash),
1040
            defer(cls.pw_set_at),
1041
            defer(cls.pw_expires_at),
1042
            defer(cls.timezone),
1043
        ]
1044

1045
    @classmethod
2✔
1046
    def type_filter(cls) -> sa.ColumnElement[bool]:
2✔
1047
        """Return filter for the subclass's type."""
1048
        return cls.type_ == cls.__mapper_args__.get('polymorphic_identity')
2✔
1049

1050
    if TYPE_CHECKING:
2✔
1051
        # These are added via add_primary_relationship
1052
        primary_email: Mapped[AccountEmail | None] = relationship()
1053
        primary_phone: Mapped[AccountPhone | None] = relationship()
1054

1055
    def __repr__(self) -> str:
2✔
1056
        if self.name:
2✔
1057
            return f'<{self.__class__.__name__} {self.title} @{self.name}>'
2✔
1058
        return f'<{self.__class__.__name__} {self.title}>'
2✔
1059

1060
    def __str__(self) -> str:
2✔
1061
        """Return picker name for account."""
1062
        return self.pickername
2✔
1063

1064
    def __format__(self, format_spec: str) -> str:
2✔
1065
        if not format_spec:
×
1066
            return self.pickername
×
1067
        return format(self.pickername, format_spec)
×
1068

1069
    @property
2✔
1070
    def pickername(self) -> str:
2✔
1071
        """Return title and @name in a format suitable for identification."""
1072
        if self.name:
2✔
1073
            return f'{self.title} (@{self.name})'
2✔
1074
        return self.title
2✔
1075

1076
    with_roles(pickername, read={'all'})
2✔
1077

1078
    @role_check('reader')
2✔
1079
    def has_reader_role(
2✔
1080
        self, _actor: Account | None, _anchors: Sequence[Any] = ()
1081
    ) -> bool:
1082
        """Grant 'reader' role to all if the profile state is active and public."""
1083
        return bool(self.profile_state.ACTIVE_AND_PUBLIC)
2✔
1084

1085
    @cached_property
2✔
1086
    def verified_contact_count(self) -> int:
2✔
1087
        """Count of verified contact details."""
1088
        return len(self.emails) + len(self.phones)
2✔
1089

1090
    @property
2✔
1091
    def has_verified_contact_info(self) -> bool:
2✔
1092
        """User has any verified contact info (email or phone)."""
1093
        return bool(self.emails) or bool(self.phones)
2✔
1094

1095
    @property
2✔
1096
    def has_contact_info(self) -> bool:
2✔
1097
        """User has any contact information (including unverified)."""
1098
        return self.has_verified_contact_info or bool(self.emailclaims)
2✔
1099

1100
    def merged_account(self) -> Account:
2✔
1101
        """Return the account that this account was merged into (default: self)."""
1102
        if self.state.MERGED:
2✔
1103
            # If our state is MERGED, there _must_ be a corresponding AccountOldId
1104
            # record
1105
            return cast(AccountOldId, AccountOldId.get(self.uuid)).account
2✔
1106
        return self
2✔
1107

1108
    def _set_password(self, password: str | None) -> None:
2✔
1109
        """Set a password (write-only property)."""
1110
        if password is None:
2✔
1111
            self.pw_hash = None
2✔
1112
        else:
1113
            self.pw_hash = argon2.hash(password)
2✔
1114
            # Also see :meth:`password_is` for transparent upgrade
1115
        self.pw_set_at = sa.func.utcnow()
2✔
1116
        # Expire passwords after one year. TODO: make this configurable
1117
        self.pw_expires_at = sa.func.utcnow() + sa.cast('1 year', sa.Interval)
2✔
1118

1119
    #: Write-only property (passwords cannot be read back in plain text)
1120
    password = property(fset=_set_password, doc=_set_password.__doc__)
2✔
1121

1122
    def password_has_expired(self) -> bool:
2✔
1123
        """Verify if password expiry timestamp has passed."""
1124
        return (
2✔
1125
            self.pw_hash is not None
1126
            and self.pw_expires_at is not None
1127
            and self.pw_expires_at <= utcnow()
1128
        )
1129

1130
    def password_is(self, password: str, upgrade_hash: bool = False) -> bool:
2✔
1131
        """Test if the candidate password matches saved hash."""
1132
        if self.pw_hash is None:
2✔
1133
            return False
×
1134

1135
        # Passwords may use the current Argon2 scheme or the older Bcrypt scheme.
1136
        # Bcrypt passwords are transparently upgraded if requested.
1137
        if argon2.identify(self.pw_hash):
2✔
1138
            return argon2.verify(password, self.pw_hash)
2✔
1139
        if bcrypt.identify(self.pw_hash):
2✔
1140
            verified = bcrypt.verify(password, self.pw_hash)
2✔
1141
            if verified and upgrade_hash:
2✔
1142
                self.pw_hash = argon2.hash(password)
2✔
1143
            return verified
2✔
1144
        return False
×
1145

1146
    def add_email(
2✔
1147
        self,
1148
        email: str,
1149
        primary: bool | None = None,
1150
        private: bool = False,
1151
    ) -> AccountEmail:
1152
        """
1153
        Add an email address (assumed to be verified).
1154

1155
        :param email: Email address as a string
1156
        :param primary: Mark this email address as primary (default: auto-assign)
1157
        :param private: Mark as private (currently unused)
1158
        """
1159
        accountemail = AccountEmail(account=self, email=email, private=private)
2✔
1160
        accountemail = failsafe_add(
2✔
1161
            db.session,
1162
            accountemail,
1163
            account=self,
1164
            email_address=accountemail.email_address,
1165
        )
1166
        if (primary is None and self.primary_email is None) or primary is True:
2✔
1167
            self.primary_email = accountemail
2✔
1168
        return accountemail
2✔
1169
        # FIXME: This should remove competing instances of AccountEmailClaim
1170

1171
    def del_email(self, email: str) -> None:
2✔
1172
        """Remove an email address from the user's account."""
1173
        accountemail = AccountEmail.get_for(account=self, email=email)
2✔
1174
        if accountemail is not None:
2✔
1175
            if self.primary_email in (accountemail, None):
2✔
1176
                self.primary_email = (
2✔
1177
                    AccountEmail.query.filter(
1178
                        AccountEmail.account == self, AccountEmail.id != accountemail.id
1179
                    )
1180
                    .order_by(AccountEmail.created_at.desc())
1181
                    .first()
1182
                )
1183
            db.session.delete(accountemail)
2✔
1184

1185
    @property
2✔
1186
    def email(self) -> Literal[''] | AccountEmail:
2✔
1187
        """Return primary email address for user."""
1188
        # Look for a primary address
1189
        accountemail = self.primary_email
2✔
1190
        if accountemail is not None:
2✔
1191
            return accountemail
2✔
1192
        # No primary? Maybe there's one that's not set as primary?
1193
        if self.emails:
2✔
1194
            accountemail = self.emails[0]
2✔
1195
            # XXX: Mark as primary. This may or may not be saved depending on
1196
            # whether the request ended in a database commit.
1197
            self.primary_email = accountemail
2✔
1198
            return accountemail
2✔
1199
        # This user has no email address. Return a blank string instead of None
1200
        # to support the common use case, where the caller will use str(user.email)
1201
        # to get the email address as a string.
1202
        return ''
2✔
1203

1204
    with_roles(email, read={'owner'})
2✔
1205

1206
    def add_phone(
2✔
1207
        self,
1208
        phone: str,
1209
        primary: bool | None = None,
1210
        private: bool = False,
1211
    ) -> AccountPhone:
1212
        """
1213
        Add a phone number (assumed to be verified).
1214

1215
        :param phone: Phone number as a string
1216
        :param primary: Mark this phone number as primary (default: auto-assign)
1217
        :param private: Mark as private (currently unused)
1218
        """
1219
        accountphone = AccountPhone(account=self, phone=phone, private=private)
2✔
1220
        accountphone = failsafe_add(
2✔
1221
            db.session,
1222
            accountphone,
1223
            account=self,
1224
            phone_number=accountphone.phone_number,
1225
        )
1226
        if (primary is None and self.primary_phone is None) or primary is True:
2✔
1227
            self.primary_phone = accountphone
2✔
1228
        return accountphone
2✔
1229

1230
    def del_phone(self, phone: str) -> None:
2✔
1231
        """Remove a phone number from the user's account."""
1232
        accountphone = AccountPhone.get_for(account=self, phone=phone)
2✔
1233
        if accountphone is not None:
2✔
1234
            if self.primary_phone in (accountphone, None):
2✔
1235
                self.primary_phone = (
2✔
1236
                    AccountPhone.query.filter(
1237
                        AccountPhone.account == self, AccountPhone.id != accountphone.id
1238
                    )
1239
                    .order_by(AccountPhone.created_at.desc())
1240
                    .first()
1241
                )
1242
            db.session.delete(accountphone)
2✔
1243

1244
    @property
2✔
1245
    def phone(self) -> Literal[''] | AccountPhone:
2✔
1246
        """Return primary phone number for user."""
1247
        # Look for a primary phone number
1248
        accountphone = self.primary_phone
2✔
1249
        if accountphone is not None:
2✔
1250
            return accountphone
2✔
1251
        # No primary? Maybe there's one that's not set as primary?
1252
        if self.phones:
2✔
1253
            accountphone = self.phones[0]
2✔
1254
            # XXX: Mark as primary. This may or may not be saved depending on
1255
            # whether the request ended in a database commit.
1256
            self.primary_phone = accountphone
2✔
1257
            return accountphone
2✔
1258
        # This user has no phone number. Return a blank string instead of None
1259
        # to support the common use case, where the caller will use str(user.phone)
1260
        # to get the phone number as a string.
1261
        return ''
2✔
1262

1263
    with_roles(phone, read={'owner'})
2✔
1264

1265
    @property
2✔
1266
    def has_public_profile(self) -> bool:
2✔
1267
        """Return the visibility state of an account."""
1268
        return self.name is not None and bool(self.profile_state.ACTIVE_AND_PUBLIC)
×
1269

1270
    with_roles(has_public_profile, read={'all'}, write={'owner'})
2✔
1271

1272
    def is_profile_complete(self) -> bool:
2✔
1273
        """Verify if profile is complete (fullname, username and contacts present)."""
1274
        return bool(self.title and self.name and self.has_verified_contact_info)
2✔
1275

1276
    def active_memberships(self) -> Iterator[ImmutableMembershipMixin]:
2✔
1277
        """Enumerate all active memberships."""
1278
        # Each collection is cast into a list before chaining to ensure that it does not
1279
        # change during processing (if, for example, membership is revoked or replaced).
1280
        return itertools.chain(
×
1281
            *(list(getattr(self, attr)) for attr in self.__active_membership_attrs__)
1282
        )
1283

1284
    def has_any_memberships(self) -> bool:
2✔
1285
        """
1286
        Test for any non-invite membership records that must be preserved.
1287

1288
        This is used to test for whether the account is safe to purge (hard delete) from
1289
        the database. If non-invite memberships are present, the account cannot be
1290
        purged as immutable records must be preserved. Instead, the account must be put
1291
        into DELETED state with all PII scrubbed.
1292
        """
1293
        return any(
×
1294
            db.session.query(getattr(self, attr).exists()).scalar()
1295
            for attr in self.__noninvite_membership_attrs__
1296
        )
1297

1298
    # --- Transport details
1299

1300
    @with_roles(call={'owner'})
2✔
1301
    def has_transport_email(self) -> bool:
2✔
1302
        """User has an email transport address."""
1303
        return bool(self.state.ACTIVE) and bool(self.email)
×
1304

1305
    @with_roles(call={'owner'})
2✔
1306
    def has_transport_sms(self) -> bool:
2✔
1307
        """User has an SMS transport address."""
1308
        return (
×
1309
            bool(self.state.ACTIVE)
1310
            and self.phone != ''
1311
            and self.phone.phone_number.has_sms is not False
1312
        )
1313

1314
    @with_roles(call={'owner'})
2✔
1315
    def has_transport_webpush(self) -> bool:  # TODO  # pragma: no cover
2✔
1316
        """User has a webpush transport address."""
1317
        return False
1318

1319
    @with_roles(call={'owner'})
2✔
1320
    def has_transport_telegram(self) -> bool:  # TODO  # pragma: no cover
2✔
1321
        """User has a Telegram transport address."""
1322
        return False
1323

1324
    @with_roles(call={'owner'})
2✔
1325
    def has_transport_whatsapp(self) -> bool:
2✔
1326
        """User has a WhatsApp transport address."""
1327
        return (
×
1328
            bool(self.state.ACTIVE)
1329
            and self.phone != ''
1330
            and self.phone.phone_number.has_wa is not False
1331
        )
1332

1333
    @with_roles(call={'owner'})
2✔
1334
    def transport_for_email(self, context: Model | None = None) -> AccountEmail | None:
2✔
1335
        """Return user's preferred email address within a context."""
1336
        # TODO: Per-account/project customization is a future option
1337
        if self.state.ACTIVE:
2✔
1338
            return self.email or None
2✔
1339
        return None
×
1340

1341
    @with_roles(call={'owner'})
2✔
1342
    def transport_for_sms(self, context: Model | None = None) -> AccountPhone | None:
2✔
1343
        """Return user's preferred phone number within a context."""
1344
        # TODO: Per-account/project customization is a future option
1345
        if (
2✔
1346
            self.state.ACTIVE
1347
            and self.phone != ''
1348
            and self.phone.phone_number.has_sms is not False
1349
        ):
1350
            return self.phone
2✔
1351
        return None
×
1352

1353
    @with_roles(call={'owner'})
2✔
1354
    def transport_for_webpush(
2✔
1355
        self, context: Model | None = None
1356
    ) -> None:  # TODO  # pragma: no cover
1357
        """Return user's preferred webpush transport address within a context."""
1358
        return None
1359

1360
    @with_roles(call={'owner'})
2✔
1361
    def transport_for_telegram(
2✔
1362
        self, context: Model | None = None
1363
    ) -> None:  # TODO  # pragma: no cover
1364
        """Return user's preferred Telegram transport address within a context."""
1365
        return None
1366

1367
    @with_roles(call={'owner'})
2✔
1368
    def transport_for_whatsapp(
2✔
1369
        self, context: Model | None = None
1370
    ) -> AccountPhone | None:
1371
        """Return user's preferred WhatsApp transport address within a context."""
1372
        # TODO: Per-account/project customization is a future option
1373
        if self.state.ACTIVE and self.phone != '' and self.phone.phone_number.has_wa:
×
1374
            return self.phone
×
1375
        return None
×
1376

1377
    @with_roles(call={'owner'})
2✔
1378
    def has_transport(self, transport: str) -> bool:
2✔
1379
        """
1380
        Verify if user has a given transport address.
1381

1382
        Helper method to call ``self.has_transport_<transport>()``.
1383

1384
        ..note::
1385
            Because this method does not accept a context, it may return True for a
1386
            transport that has been muted in that context. This may cause an empty
1387
            background job to be queued for a notification. Revisit this method when
1388
            preference contexts are supported.
1389
        """
1390
        return getattr(self, 'has_transport_' + transport)()
×
1391

1392
    @with_roles(call={'owner'})
2✔
1393
    def transport_for(
2✔
1394
        self, transport: str, context: Model | None = None
1395
    ) -> AccountEmail | AccountPhone | None:
1396
        """
1397
        Get transport address for a given transport and context.
1398

1399
        Helper method to call ``self.transport_for_<transport>(context)``.
1400
        """
1401
        return getattr(self, 'transport_for_' + transport)(context)
2✔
1402

1403
    def default_email(
2✔
1404
        self, context: Model | None = None
1405
    ) -> AccountEmail | AccountEmailClaim | None:
1406
        """
1407
        Return default email address (verified if present, else unverified).
1408

1409
        ..note::
1410
            This is a temporary helper method, pending merger of
1411
            :class:`AccountEmailClaim` into :class:`AccountEmail` with
1412
            :attr:`~AccountEmail.verified` ``== False``. The appropriate replacement is
1413
            :meth:`Account.transport_for_email` with a context.
1414
        """
1415
        email = self.transport_for_email(context=context)
2✔
1416
        if email:
2✔
1417
            return email
2✔
1418
        # Fallback when ``transport_for_email`` returns None
1419
        if self.email:
2✔
1420
            return self.email
×
1421
        if self.emailclaims:
2✔
1422
            return self.emailclaims[0]
×
1423
        # This user has no email addresses
1424
        return None
2✔
1425

1426
    @property
2✔
1427
    def _self_is_owner_of_self(self) -> Account | None:
2✔
1428
        """
1429
        Return self in a user account.
1430

1431
        Helper method for :meth:`roles_for` and :meth:`actors_with` to assert that the
1432
        user is owner and admin of their own account.
1433
        """
1434
        return self if self.is_user_profile else None
2✔
1435

1436
    with_roles(
2✔
1437
        _self_is_owner_of_self,
1438
        grants={'follower', 'member', 'admin', 'owner'},
1439
    )
1440

1441
    def organizations_as_owner_ids(self) -> list[int]:
2✔
1442
        """
1443
        Return the database ids of the organizations this user is an owner of.
1444

1445
        This is used for database queries.
1446
        """
1447
        return [
×
1448
            membership.account_id
1449
            for membership in self.active_organization_owner_memberships
1450
        ]
1451

1452
    @state.transition(state.ACTIVE, state.MERGED)
2✔
1453
    def mark_merged_into(self, other_account: Account) -> None:
2✔
1454
        """Mark account as merged into another account."""
1455
        db.session.add(AccountOldId(id=self.uuid, account=other_account))
2✔
1456

1457
    @state.transition(state.ACTIVE, state.SUSPENDED)
2✔
1458
    def mark_suspended(self) -> None:
2✔
1459
        """Mark account as suspended on support or moderator request."""
1460

1461
    @state.transition(state.SUSPENDED, state.ACTIVE)
2✔
1462
    def mark_active(self) -> None:
2✔
1463
        """Restore a suspended account to active state."""
1464

1465
    @state.transition(state.ACTIVE, state.DELETED)
2✔
1466
    def do_delete(self) -> None:
2✔
1467
        """Delete account."""
1468
        # 0: Safety check
1469
        if not self.is_safe_to_delete():
×
1470
            raise ValueError("Account cannot be deleted")
×
1471

1472
        # 1. Delete contact information
1473
        for contact_source in cast(
×
1474
            list,
1475
            (
1476
                self.emails,
1477
                self.emailclaims,
1478
                self.phones,
1479
                self.externalids,
1480
            ),
1481
        ):
1482
            for contact in contact_source:
×
1483
                db.session.delete(contact)
×
1484

1485
        # 2. Revoke all active memberships
1486
        for membership in self.active_memberships():
×
1487
            if callable(
×
1488
                freeze := getattr(membership, 'freeze_member_attribution', None)
1489
            ):
1490
                membership = freeze(self)
×
1491
            if membership.revoke_on_member_delete:
×
1492
                membership.revoke(actor=self)
×
1493
        # TODO: freeze fullname in unrevoked memberships (pending title column there)
1494
        if (
×
1495
            self.active_site_membership
1496
            and self.active_site_membership.revoke_on_member_delete
1497
        ):
1498
            self.active_site_membership.revoke(actor=self)
×
1499

1500
        # 3. Drop all team memberships
1501
        self.member_teams.clear()
×
1502

1503
        # 4. Revoke auth tokens
1504
        AuthToken.revoke_all_for(self)
×
1505
        AuthClientPermissions.revoke_all_for(self)
×
1506

1507
        # 5. Revoke all active login sessions
1508
        for login_session in self.active_login_sessions:
×
1509
            login_session.revoke()
×
1510

1511
        # 6. Clear name (username), title (fullname) and stored password hash
1512
        self.name = None
×
1513
        self.title = ''
×
1514
        self.password = None
×
1515

1516
        # 7. Unassign tickets assigned to the user
1517
        self.ticket_participants = []  # pylint: disable=attribute-defined-outside-init
×
1518

1519
    @with_roles(call={'owner'})
2✔
1520
    @profile_state.transition(
2✔
1521
        profile_state.NOT_PUBLIC,
1522
        profile_state.PUBLIC,
1523
        title=__("Make public"),
1524
    )
1525
    @state.requires(state.ACTIVE)
2✔
1526
    def make_profile_public(self) -> None:
2✔
1527
        """Make an account public if it is eligible."""
1528

1529
    @with_roles(call={'owner'})
2✔
1530
    @profile_state.transition(
2✔
1531
        profile_state.NOT_PRIVATE, profile_state.PRIVATE, title=__("Make private")
1532
    )
1533
    def make_profile_private(self) -> None:
2✔
1534
        """Make an account private."""
1535

1536
    def is_safe_to_delete(self) -> bool:
2✔
1537
        """Test if account is not protected and has no projects."""
1538
        return self.is_protected is False and self.projects.count() == 0
2✔
1539

1540
    def is_safe_to_purge(self) -> bool:
2✔
1541
        """Test if account is safe to delete and has no memberships (active or not)."""
1542
        return self.is_safe_to_delete() and not self.has_any_memberships()
×
1543

1544
    @property
2✔
1545
    def urlname(self) -> str:
2✔
1546
        """Return :attr:`name` or ``~``-prefixed :attr:`uuid_zbase32`."""
1547
        if self.name is not None:
2✔
1548
            return self.name
2✔
1549
        return f'~{self.uuid_zbase32}'
2✔
1550

1551
    @hybrid_property
2✔
1552
    def uuid_zbase32(self) -> str:
2✔
1553
        """Account UUID rendered in z-Base-32."""
1554
        return zbase32_encode(self.uuid.bytes)
2✔
1555

1556
    @uuid_zbase32.inplace.comparator
2✔
1557
    @classmethod
2✔
1558
    def _uuid_zbase32_comparator(cls) -> ZBase32Comparator:
2✔
1559
        """Return SQL comparator for :prop:`uuid_zbase32`."""
1560
        return ZBase32Comparator(cls.uuid)  # type: ignore[arg-type]
2✔
1561

1562
    @classmethod
2✔
1563
    def name_is(cls, name: str) -> ColumnElement:
2✔
1564
        """Generate query filter to check if name is matching (case insensitive)."""
1565
        if name.startswith('~'):
2✔
1566
            return cls.uuid_zbase32 == name[1:]
2✔
1567
        return sa.func.lower(cls.name) == sa.func.lower(sa.func.replace(name, '-', '_'))
2✔
1568

1569
    @classmethod
2✔
1570
    def name_in(cls, names: Iterable[str]) -> ColumnElement:
2✔
1571
        """Generate query filter to check if name is among candidates."""
1572
        return sa.func.lower(cls.name).in_(
2✔
1573
            [name.lower().replace('-', '_') for name in names]
1574
        )
1575

1576
    @classmethod
2✔
1577
    def name_like(cls, like_query: str) -> ColumnElement:
2✔
1578
        """Generate query filter for a LIKE query on name."""
1579
        return sa.func.lower(cls.name).like(
2✔
1580
            sa.func.lower(sa.func.replace(like_query, '-', r'\_'))
1581
        )
1582

1583
    @overload
2✔
1584
    @classmethod
2✔
1585
    def get(
2✔
1586
        cls,
1587
        *,
1588
        name: str,
1589
        defercols: bool = False,
1590
    ) -> Account | None: ...
1591

1592
    @overload
2✔
1593
    @classmethod
2✔
1594
    def get(
2✔
1595
        cls,
1596
        *,
1597
        buid: str,
1598
        defercols: bool = False,
1599
    ) -> Account | None: ...
1600

1601
    @overload
2✔
1602
    @classmethod
2✔
1603
    def get(
2✔
1604
        cls,
1605
        *,
1606
        userid: str,
1607
        defercols: bool = False,
1608
    ) -> Account | None: ...
1609

1610
    @classmethod
2✔
1611
    def get(
2✔
1612
        cls,
1613
        *,
1614
        name: str | None = None,
1615
        buid: str | None = None,
1616
        userid: str | None = None,
1617
        defercols: bool = False,
1618
    ) -> Account | None:
1619
        """
1620
        Return an Account with the given name or buid.
1621

1622
        :param str name: Username to lookup
1623
        :param str buid: Buid to lookup
1624
        :param bool defercols: Defer loading non-critical columns
1625
        """
1626
        require_one_of(name=name, buid=buid, userid=userid)
2✔
1627

1628
        # userid parameter is temporary for Flask-Lastuser compatibility
1629
        if userid:
2✔
1630
            buid = userid
×
1631

1632
        if name is not None:
2✔
1633
            query = cls.query.filter(cls.name_is(name))
2✔
1634
        else:
1635
            query = cls.query.filter_by(buid=buid)
2✔
1636
        if cls is not Account:
2✔
1637
            query = query.filter(cls.type_filter())
2✔
1638
        if defercols:
2✔
1639
            query = query.options(*cls._defercols())
2✔
1640
        account = query.one_or_none()
2✔
1641
        if account and account.state.MERGED:
2✔
1642
            account = account.merged_account()
2✔
1643
        if account and account.state.ACTIVE:
2✔
1644
            return account
2✔
1645
        return None
2✔
1646

1647
    @classmethod
2✔
1648
    def all(  # noqa: A003
2✔
1649
        cls,
1650
        buids: Iterable[str] | None = None,
1651
        names: Iterable[str] | None = None,
1652
        defercols: bool = False,
1653
    ) -> list[Account]:
1654
        """
1655
        Return all matching accounts.
1656

1657
        :param list buids: Buids to look up
1658
        :param list names: Names (usernames) to look up
1659
        :param bool defercols: Defer loading non-critical columns
1660
        """
1661
        accounts = set()
2✔
1662
        if buids and names:
2✔
1663
            query = cls.query.filter(sa.or_(cls.buid.in_(buids), cls.name_in(names)))
2✔
1664
        elif buids:
2✔
1665
            query = cls.query.filter(cls.buid.in_(buids))
2✔
1666
        elif names:
2✔
1667
            query = cls.query.filter(cls.name_in(names))
2✔
1668
        else:
1669
            return []
2✔
1670
        if cls is not Account:
2✔
1671
            query = query.filter(cls.type_filter())
2✔
1672

1673
        if defercols:
2✔
1674
            query = query.options(*cls._defercols())
2✔
1675
        for account in query.all():
2✔
1676
            account = account.merged_account()
2✔
1677
            if account.state.ACTIVE:
2✔
1678
                accounts.add(account)
2✔
1679
        return list(accounts)
2✔
1680

1681
    @classmethod
2✔
1682
    def all_public(cls) -> Query:
2✔
1683
        """Construct a query filtered by public profile state."""
1684
        query = cls.query.filter(cls.profile_state.PUBLIC)
2✔
1685
        if cls is not Account:
2✔
1686
            query = query.filter(cls.type_filter())
×
1687
        return query
2✔
1688

1689
    @classmethod
2✔
1690
    def autocomplete(cls, prefix: str) -> list[Self]:
2✔
1691
        """
1692
        Return accounts whose names begin with the prefix, for autocomplete UI.
1693

1694
        Looks up accounts by title, name, external ids and email addresses.
1695

1696
        :param prefix: Letters to start matching with
1697
        """
1698
        like_query = quote_autocomplete_like(prefix)
2✔
1699
        if not like_query or like_query == '@%':
2✔
1700
            return []
2✔
1701
        tsquery = quote_autocomplete_tsquery(prefix)
2✔
1702

1703
        # base_users is used in two of the three possible queries below
1704
        base_users = cls.query.filter(
2✔
1705
            cls.state.ACTIVE,
1706
            cls.name_vector.bool_op('@@')(tsquery),
1707
        )
1708

1709
        if cls is not Account:
2✔
1710
            base_users = base_users.filter(cls.type_filter())
2✔
1711
        base_users = (
2✔
1712
            base_users.options(*cls._defercols()).order_by(Account.title).limit(20)
1713
        )
1714

1715
        if (
2✔
1716
            prefix != '@'
1717
            and prefix.startswith('@')
1718
            and AccountExternalId.__at_username_services__
1719
        ):
1720
            # @-prefixed, so look for usernames, including other @username-using
1721
            # services like Twitter and GitHub. Make a union of three queries.
1722
            users = (
×
1723
                # Query 1: @query -> Account.name
1724
                cls.query.filter(
1725
                    cls.state.ACTIVE,
1726
                    cls.name_like(like_query[1:]),
1727
                )
1728
                .options(*cls._defercols())
1729
                .limit(20)
1730
                # FIXME: Still broken as of SQLAlchemy 1.4.23 (also see next block)
1731
                # .union(
1732
                #     # Query 2: @query -> UserExternalId.username
1733
                #     cls.query.join(UserExternalId)
1734
                #     .filter(
1735
                #         cls.state.ACTIVE,
1736
                #         UserExternalId.service.in_(
1737
                #             UserExternalId.__at_username_services__
1738
                #         ),
1739
                #         sa.func.lower(UserExternalId.username).like(
1740
                #             sa.func.lower(like_query[1:])
1741
                #         ),
1742
                #     )
1743
                #     .options(*cls._defercols())
1744
                #     .limit(20),
1745
                #     # Query 3: like_query -> Account.title
1746
                #     cls.query.filter(
1747
                #         cls.state.ACTIVE,
1748
                #         sa.func.lower(cls.title).like(sa.func.lower(like_query)),
1749
                #     )
1750
                #     .options(*cls._defercols())
1751
                #     .limit(20),
1752
                # )
1753
                .all()
1754
            )
1755
        elif '@' in prefix and not prefix.startswith('@'):
2✔
1756
            # Query has an @ in the middle. Match email address (exact match only).
1757
            # Use param `prefix` instead of `like_query` because it's not a LIKE query.
1758
            # Combine results with regular user search
1759
            email_filter = EmailAddress.get_filter(email=prefix)
2✔
1760
            if email_filter is not None:
2✔
1761
                users = (
2✔
1762
                    cls.query.join(AccountEmail)
1763
                    .join(EmailAddress)
1764
                    .filter(email_filter, cls.state.ACTIVE)
1765
                    .options(*cls._defercols())
1766
                    .limit(20)
1767
                    # .union(base_users)  # FIXME: Broken in SQLAlchemy 1.4.17
1768
                    .all()
1769
                )
1770
            else:
1771
                users = []
×
1772
        else:
1773
            # No '@' in the query, so do a regular autocomplete
1774
            try:
2✔
1775
                users = base_users.all()
2✔
1776
            except sa_exc.ProgrammingError:
2✔
1777
                # This can happen because the tsquery from prefix turned out to be ':*'
1778
                users = []
2✔
1779
        return users
2✔
1780

1781
    @classmethod
2✔
1782
    def validate_name_candidate(cls, name: str) -> str | None:
2✔
1783
        """
1784
        Validate an account name candidate.
1785

1786
        Returns one of several error codes, or `None` if all is okay:
1787

1788
        * ``blank``: No name supplied
1789
        * ``reserved``: Name is reserved
1790
        * ``invalid``: Invalid characters in name
1791
        * ``long``: Name is longer than allowed size
1792
        * ``user``: Name is assigned to a user
1793
        * ``org``: Name is assigned to an organization
1794
        """
1795
        if not name:
2✔
1796
            return 'blank'
2✔
1797
        if name.lower() in cls.reserved_names:
2✔
1798
            return 'reserved'
×
1799
        if not valid_account_name(name):
2✔
1800
            return 'invalid'
2✔
1801
        if len(name) > cls.__name_length__:
2✔
1802
            return 'long'
2✔
1803
        # Look for existing on the base Account model, not the subclass, as SQLAlchemy
1804
        # will add a filter condition on subclasses to restrict the query to that type.
1805
        existing = (
2✔
1806
            Account.query.filter(sa.func.lower(Account.name) == sa.func.lower(name))
1807
            .options(sa_orm.load_only(cls.id, cls.uuid, cls.type_))
1808
            .one_or_none()
1809
        )
1810
        if existing is not None:
2✔
1811
            if isinstance(existing, Placeholder):
2✔
1812
                return 'reserved'
2✔
1813
            if isinstance(existing, User):
2✔
1814
                return 'user'
2✔
1815
            if isinstance(existing, Organization):
2✔
1816
                return 'org'
2✔
1817
        return None
2✔
1818

1819
    def validate_new_name(self, name: str) -> str | None:
2✔
1820
        """Validate a new name for this account, returning an error code or None."""
1821
        if self.name and name.lower() == self.name.lower():
×
1822
            return None
×
1823
        return self.validate_name_candidate(name)
×
1824

1825
    @classmethod
2✔
1826
    def is_available_name(cls, name: str) -> bool:
2✔
1827
        """Test if the candidate name is available for use as an Account name."""
1828
        return cls.validate_name_candidate(name) is None
2✔
1829

1830
    @sa_orm.validates('name')
2✔
1831
    def _validate_name(self, key: str, value: str | None) -> str | None:
2✔
1832
        """Validate the value of Account.name."""
1833
        if value is None:
2✔
1834
            return value
2✔
1835

1836
        if not isinstance(value, str):
2✔
1837
            raise ValueError(f"Account name must be a string: {value}")
2✔
1838

1839
        if not value.strip():
2✔
1840
            raise ValueError("Account name cannot be blank")
2✔
1841

1842
        if value.lower() in self.reserved_names or not valid_account_name(value):
2✔
1843
            raise ValueError("Invalid account name: " + value)
2✔
1844

1845
        # We don't check for existence in the db since this validator only
1846
        # checks for valid syntax. To confirm the name is actually available,
1847
        # the caller must call :meth:`is_available_name` or attempt to commit
1848
        # to the db and catch IntegrityError.
1849
        return value
2✔
1850

1851
    @sa_orm.validates('logo_url', 'banner_image_url')
2✔
1852
    def _validate_nullable(self, key: str, value: str | None) -> str | None:
2✔
1853
        """Convert blank values into None."""
1854
        return value if value else None
2✔
1855

1856
    @classmethod
2✔
1857
    def active_count(cls) -> int:
2✔
1858
        """Count of all active accounts."""
1859
        return cls.query.filter(cls.state.ACTIVE).count()
×
1860

1861
    #: FIXME: Temporary values for Baseframe compatibility
1862
    def organization_links(self) -> list:
2✔
1863
        """Return list of organizations affiliated with this user (deprecated)."""
1864
        return []
×
1865

1866
    # Project methods
1867

1868
    def draft_projects_for(self, user: Account | None) -> list[Project]:
2✔
1869
        if user is not None:
×
1870
            return [
×
1871
                membership.project
1872
                for membership in user.projects_as_crew_active_memberships.join(
1873
                    Project
1874
                ).filter(
1875
                    # Project is attached to this account
1876
                    Project.account_id == self.id,
1877
                    # Project is in draft state OR has a draft call for proposals
1878
                    sa.or_(Project.state.DRAFT, Project.cfp_state.DRAFT),
1879
                )
1880
            ]
1881
        return []
×
1882

1883
    def unscheduled_projects_for(self, user: Account | None) -> list[Project]:
2✔
1884
        if user is not None:
×
1885
            return [
×
1886
                membership.project
1887
                for membership in user.projects_as_crew_active_memberships.join(
1888
                    Project
1889
                ).filter(
1890
                    # Project is attached to this account
1891
                    Project.account_id == self.id,
1892
                    # Project is in draft state OR has a draft call for proposals
1893
                    sa.or_(Project.state.PUBLISHED_WITHOUT_SESSIONS),
1894
                )
1895
            ]
1896
        return []
×
1897

1898
    @with_roles(read={'all'}, datasets={'primary', 'without_parent', 'related'})
2✔
1899
    @cached_property
2✔
1900
    def published_project_count(self) -> int:
2✔
1901
        return (
2✔
1902
            self.listed_projects.filter(Project.state.PUBLISHED).order_by(None).count()
1903
        )
1904

1905
    @with_roles(read={'all'}, grants_via={None: {'participant': 'member'}})
2✔
1906
    @cached_property
2✔
1907
    def membership_project(self) -> Project | None:
2✔
1908
        """Return a project that has memberships flag enabled (temporary)."""
1909
        return self.projects.filter(
2✔
1910
            Project.boxoffice_data.op('@>')({'has_membership': True})
1911
        ).first()
1912

1913
    # Make :attr:`type_` available under the name `type`, but declare this at the very
1914
    # end of the class to avoid conflicts with the Python `type` global that is
1915
    # used for type-hinting
1916
    type: Mapped[str] = sa_orm.synonym('type_')  # noqa: A003
2✔
1917

1918

1919
Account.__active_membership_attrs__.add('active_organization_admin_memberships')
2✔
1920
Account.__noninvite_membership_attrs__.add('noninvite_organization_admin_memberships')
2✔
1921
Account.__active_membership_attrs__.add('projects_as_crew_active_memberships')
2✔
1922
Account.__noninvite_membership_attrs__.add('projects_as_crew_noninvite_memberships')
2✔
1923
Account.__active_membership_attrs__.add('proposal_memberships')
2✔
1924
Account.__noninvite_membership_attrs__.add('noninvite_proposal_memberships')
2✔
1925
Account.__active_membership_attrs__.update(
2✔
1926
    {'project_sponsor_memberships', 'proposal_sponsor_memberships'}
1927
)
1928
Account.__noninvite_membership_attrs__.update(
2✔
1929
    {'noninvite_project_sponsor_memberships', 'noninvite_proposal_sponsor_memberships'}
1930
)
1931

1932
auto_init_default(Account._state)  # pylint: disable=protected-access
2✔
1933
auto_init_default(Account._profile_state)  # pylint: disable=protected-access
2✔
1934
add_search_trigger(Account, 'search_vector')
2✔
1935
add_search_trigger(Account, 'name_vector')
2✔
1936

1937

1938
class AccountOldId(UuidMixin, BaseMixin[UUID, Account], Model):
2✔
1939
    """Record of an older UUID for an account, after account merger."""
2✔
1940

1941
    __tablename__ = 'account_oldid'
2✔
1942

1943
    #: Old account, if still present
1944
    old_account: Mapped[Account] = relationship(
2✔
1945
        foreign_keys=lambda: AccountOldId.id,
1946
        primaryjoin=lambda: AccountOldId.id == Account.uuid,
1947
        back_populates='oldid',
1948
    )
1949
    #: User id of new user
1950
    account_id: Mapped[int] = sa_orm.mapped_column(
2✔
1951
        sa.ForeignKey('account.id'), default=None, nullable=False
1952
    )
1953
    #: New account
1954
    account: Mapped[Account] = relationship(
2✔
1955
        foreign_keys=[account_id], back_populates='oldids'
1956
    )
1957

1958
    def __repr__(self) -> str:
2✔
1959
        """Represent :class:`AccountOldId` as a string."""
1960
        return f'<AccountOldId {self.buid} of {self.account!r}>'
1961

1962
    @classmethod
2✔
1963
    def get(cls, uuid: UUID) -> AccountOldId | None:
2✔
1964
        """Get an old user record given a UUID."""
1965
        return cls.query.filter_by(id=uuid).one_or_none()
2✔
1966

1967

1968
class User(Account):
2✔
1969
    """User account."""
2✔
1970

1971
    __mapper_args__ = {'polymorphic_identity': 'U'}
2✔
1972
    is_user_profile = True
2✔
1973

1974
    def __init__(self, **kwargs: Any) -> None:
2✔
1975
        super().__init__(**kwargs)
2✔
1976
        if self.joined_at is None:
2✔
1977
            self.joined_at = sa.func.utcnow()
2✔
1978

1979

1980
# XXX: Deprecated, still here for Baseframe compatibility
1981
Account.userid = Account.uuid_b64
2✔
1982

1983

1984
# TODO: Make an Actor Protocol as the base for both -- maybe placing that in Coaster
1985
class DuckTypeAccount(RoleMixin):
2✔
1986
    """User singleton constructor. Duck types a regular user object."""
2✔
1987

1988
    id: None = None  # noqa: A003
2✔
1989
    created_at: None = None
2✔
1990
    updated_at: None = None
2✔
1991
    uuid: None = None
2✔
1992
    userid: None = None
2✔
1993
    buid: None = None
2✔
1994
    uuid_b58: None = None
2✔
1995
    username: None = None
2✔
1996
    name: None = None
2✔
1997
    absolute_url: None = None
2✔
1998
    email: None = None
2✔
1999
    phone: None = None
2✔
2000

2001
    is_user_profile = True
2✔
2002
    is_organization_profile = False
2✔
2003
    is_placeholder_profile = False
2✔
2004

2005
    # Copy registries from Account model
2006
    views = Account.views
2✔
2007
    features = Account.features
2✔
2008
    forms = Account.forms
2✔
2009

2010
    __roles__ = {
2✔
2011
        'all': {
2012
            'read': {
2013
                'id',
2014
                'uuid',
2015
                'username',
2016
                'fullname',
2017
                'pickername',
2018
                'absolute_url',
2019
            },
2020
            'call': {'views', 'forms', 'features', 'url_for'},
2021
        }
2022
    }
2023

2024
    __datasets__ = {
2✔
2025
        'related': {
2026
            'username',
2027
            'fullname',
2028
            'pickername',
2029
            'absolute_url',
2030
        }
2031
    }
2032

2033
    #: Make obj.user/obj.posted_by from a referring object falsy
2034
    def __bool__(self) -> bool:
2✔
2035
        """Represent boolean state."""
2036
        return False
×
2037

2038
    def __init__(self, representation: str) -> None:
2✔
2039
        self.fullname = self.title = self.pickername = representation
2✔
2040

2041
    def __str__(self) -> str:
2✔
2042
        return self.pickername
×
2043

2044
    def __format__(self, format_spec: str) -> str:
2✔
2045
        if not format_spec:
×
2046
            return self.pickername
×
2047
        return format(self.pickername, format_spec)
×
2048

2049
    def url_for(self, *args: Any, **kwargs: Any) -> Literal['']:
2✔
2050
        """Return blank URL for anything to do with this user."""
2051
        return ''
×
2052

2053

2054
deleted_account = DuckTypeAccount(__("[deleted]"))
2✔
2055
removed_account = DuckTypeAccount(__("[removed]"))
2✔
2056
unknown_account = DuckTypeAccount(__("[unknown]"))
2✔
2057

2058

2059
# --- Organizations and teams -------------------------------------------------
2060

2061

2062
class Organization(Account):
2✔
2063
    """An organization of one or more users with distinct roles."""
2✔
2064

2065
    __mapper_args__ = {'polymorphic_identity': 'O'}
2✔
2066
    is_organization_profile = True
2✔
2067

2068
    def __init__(self, owner: User, **kwargs: Any) -> None:
2✔
2069
        super().__init__(**kwargs)
2✔
2070
        if self.joined_at is None:
2✔
2071
            self.joined_at = sa.func.utcnow()
2✔
2072
        db.session.add(
2✔
2073
            AccountMembership(
2074
                account=self, member=owner, granted_by=owner, is_owner=True
2075
            )
2076
        )
2077

2078
    def people(self) -> Query[Account]:
2✔
2079
        """Return a list of users from across the public teams they are in."""
2080
        return (
×
2081
            Account.query.join(team_membership)
2082
            .join(Team)
2083
            .filter(Team.account == self, Team.is_public.is_(True))
2084
            .options(sa_orm.joinedload(Account.member_teams))
2085
            .order_by(sa.func.lower(Account.title))
2086
        )
2087

2088

2089
class Community(Account):
2✔
2090
    """
2✔
2091
    A community account.
2092

2093
    Communities differ from organizations in having open-ended membership.
2094
    """
2095

2096
    __mapper_args__ = {'polymorphic_identity': 'C'}
2✔
2097
    is_community_profile = True
2✔
2098

2099
    def __init__(self, owner: User, **kwargs: Any) -> None:
2✔
NEW
2100
        super().__init__(**kwargs)
×
NEW
2101
        if self.joined_at is None:
×
NEW
2102
            self.joined_at = sa.func.utcnow()
×
NEW
2103
        db.session.add(
×
2104
            AccountMembership(
2105
                account=self, member=owner, granted_by=owner, is_owner=True
2106
            )
2107
        )
2108

2109

2110
class Placeholder(Account):
2✔
2111
    """A placeholder account."""
2✔
2112

2113
    __mapper_args__ = {'polymorphic_identity': 'P'}
2✔
2114
    is_placeholder_profile = True
2✔
2115

2116

2117
class Team(UuidMixin, BaseMixin[int, Account], Model):
2✔
2118
    """A team of users within an organization."""
2✔
2119

2120
    __tablename__ = 'team'
2✔
2121
    __title_length__ = 250
2✔
2122
    #: Displayed name
2123
    title: Mapped[str] = sa_orm.mapped_column(
2✔
2124
        sa.Unicode(__title_length__), nullable=False
2125
    )
2126
    #: Organization
2127
    account_id: Mapped[int] = sa_orm.mapped_column(
2✔
2128
        sa.ForeignKey('account.id'), default=None, nullable=False, index=True
2129
    )
2130
    account: Mapped[Account] = with_roles(
2✔
2131
        relationship(foreign_keys=[account_id], back_populates='teams'),
2132
        grants_via={None: {'owner': 'owner', 'admin': 'admin'}},
2133
    )
2134
    users: DynamicMapped[Account] = with_roles(
2✔
2135
        relationship(
2136
            secondary=team_membership, lazy='dynamic', back_populates='member_teams'
2137
        ),
2138
        grants={'member'},
2139
    )
2140

2141
    is_public: Mapped[bool] = sa_orm.mapped_column(default=False)
2✔
2142

2143
    # --- Backrefs
2144
    client_permissions: Mapped[list[AuthClientTeamPermissions]] = relationship(
2✔
2145
        back_populates='team'
2146
    )
2147

2148
    def __repr__(self) -> str:
2✔
2149
        """Represent :class:`Team` as a string."""
2150
        return f'<Team {self.title} of {self.account!r}>'
2151

2152
    @property
2✔
2153
    def pickername(self) -> str:
2✔
2154
        """Return team's title in a format suitable for identification."""
2155
        return self.title
2✔
2156

2157
    @classmethod
2✔
2158
    def migrate_account(
2✔
2159
        cls, old_account: Account, new_account: Account
2160
    ) -> OptionalMigratedTables:
2161
        """Migrate one account's data to another when merging accounts."""
2162
        for team in list(old_account.teams):
2✔
2163
            team.account = new_account
×
2164
        for team in list(old_account.member_teams):
2✔
2165
            if team not in new_account.member_teams:
2✔
2166
                # FIXME: This creates new memberships, updating `created_at`.
2167
                # Unfortunately, we can't work with model instances as in the other
2168
                # `migrate_account` methods as team_membership is an unmapped table.
2169
                new_account.member_teams.append(team)
2✔
2170
            old_account.member_teams.remove(team)
2✔
2171
        return [cls.__table__.name, team_membership.name]
2✔
2172

2173
    @classmethod
2✔
2174
    def get(cls, buid: str, with_parent: bool = False) -> Team | None:
2✔
2175
        """
2176
        Return a Team with matching buid.
2177

2178
        :param str buid: Buid of the team
2179
        """
2180
        if with_parent:
2✔
2181
            query = cls.query.options(sa_orm.joinedload(cls.account))
×
2182
        else:
2183
            query = cls.query
2✔
2184
        return query.filter_by(buid=buid).one_or_none()
2✔
2185

2186

2187
# --- Account email/phone and misc
2188

2189

2190
class AccountEmail(EmailAddressMixin, BaseMixin[int, Account], Model):
2✔
2191
    """An email address linked to an account."""
2✔
2192

2193
    __tablename__ = 'account_email'
2✔
2194
    __email_unique__ = True
2✔
2195
    __email_is_exclusive__ = True
2✔
2196
    __email_for__ = 'account'
2✔
2197

2198
    account_id: Mapped[int] = sa_orm.mapped_column(
2✔
2199
        sa.ForeignKey('account.id'), default=None, nullable=False
2200
    )
2201
    account: Mapped[Account] = relationship(back_populates='emails')
2✔
2202
    user: Mapped[Account] = sa_orm.synonym('account')
2✔
2203

2204
    private: Mapped[bool] = sa_orm.mapped_column(default=False)
2✔
2205

2206
    __datasets__ = {
2✔
2207
        'primary': {'member', 'email', 'private', 'type'},
2208
        'without_parent': {'email', 'private', 'type'},
2209
        'related': {'email', 'private', 'type'},
2210
    }
2211

2212
    def __init__(self, *, account: Account, **kwargs: Any) -> None:
2✔
2213
        email = kwargs.pop('email', None)
2✔
2214
        if email:
2✔
2215
            kwargs['email_address'] = EmailAddress.add_for(account, email)
2✔
2216
        super().__init__(account=account, **kwargs)
2✔
2217

2218
    def __repr__(self) -> str:
2✔
2219
        """Represent this class as a string."""
2220
        return f'<AccountEmail {self.email} of {self.account!r}>'
2221

2222
    def __str__(self) -> str:  # pylint: disable=invalid-str-returned
2✔
2223
        """Email address as a string."""
2224
        return self.email or ''
2✔
2225

2226
    __json__ = __str__
2✔
2227

2228
    @property
2✔
2229
    def primary(self) -> bool:
2✔
2230
        """Check whether this email address is the user's primary."""
2231
        return self.account.primary_email == self
2✔
2232

2233
    @primary.setter
2✔
2234
    def primary(self, value: bool) -> None:
2✔
2235
        """Set or unset this email address as primary."""
2236
        if value:
2✔
2237
            self.account.primary_email = self
2✔
2238
        else:
2239
            if self.account.primary_email == self:
×
2240
                self.account.primary_email = None
×
2241

2242
    @overload
2✔
2243
    @classmethod
2✔
2244
    def get(
2✔
2245
        cls,
2246
        email: str,
2247
    ) -> AccountEmail | None: ...
2248

2249
    @overload
2✔
2250
    @classmethod
2✔
2251
    def get(
2✔
2252
        cls,
2253
        *,
2254
        blake2b160: bytes,
2255
    ) -> AccountEmail | None: ...
2256

2257
    @overload
2✔
2258
    @classmethod
2✔
2259
    def get(
2✔
2260
        cls,
2261
        *,
2262
        email_hash: str,
2263
    ) -> AccountEmail | None: ...
2264

2265
    @classmethod
2✔
2266
    def get(
2✔
2267
        cls,
2268
        email: str | None = None,
2269
        *,
2270
        blake2b160: bytes | None = None,
2271
        email_hash: str | None = None,
2272
    ) -> AccountEmail | None:
2273
        """
2274
        Return an AccountEmail with matching email or blake2b160 hash.
2275

2276
        :param email: Email address to look up
2277
        :param blake2b160: 160-bit blake2b of email address to look up
2278
        :param email_hash: blake2b hash rendered in Base58
2279
        """
2280
        email_filter = EmailAddress.get_filter(
2✔
2281
            email=email, blake2b160=blake2b160, email_hash=email_hash
2282
        )
2283
        if email_filter is None:
2✔
2284
            return None
2✔
2285
        return cls.query.join(EmailAddress).filter(email_filter).one_or_none()
2✔
2286

2287
    @overload
2✔
2288
    @classmethod
2✔
2289
    def get_for(
2✔
2290
        cls,
2291
        account: Account,
2292
        *,
2293
        email: str,
2294
    ) -> AccountEmail | None: ...
2295

2296
    @overload
2✔
2297
    @classmethod
2✔
2298
    def get_for(
2✔
2299
        cls,
2300
        account: Account,
2301
        *,
2302
        blake2b160: bytes,
2303
    ) -> AccountEmail | None: ...
2304

2305
    @overload
2✔
2306
    @classmethod
2✔
2307
    def get_for(
2✔
2308
        cls,
2309
        account: Account,
2310
        *,
2311
        email_hash: str,
2312
    ) -> AccountEmail | None: ...
2313

2314
    @classmethod
2✔
2315
    def get_for(
2✔
2316
        cls,
2317
        account: Account,
2318
        *,
2319
        email: str | None = None,
2320
        blake2b160: bytes | None = None,
2321
        email_hash: str | None = None,
2322
    ) -> AccountEmail | None:
2323
        """
2324
        Return instance with matching email or hash if it belongs to the given user.
2325

2326
        :param user: Account to look up for
2327
        :param email: Email address to look up
2328
        :param blake2b160: 160-bit blake2b of email address
2329
        :param email_hash: blake2b hash rendered in Base58
2330
        """
2331
        email_filter = EmailAddress.get_filter(
2✔
2332
            email=email, blake2b160=blake2b160, email_hash=email_hash
2333
        )
2334
        if email_filter is None:
2✔
2335
            return None
×
2336
        return (
2✔
2337
            cls.query.join(EmailAddress)
2338
            .filter(
2339
                cls.account == account,
2340
                email_filter,
2341
            )
2342
            .one_or_none()
2343
        )
2344

2345
    @classmethod
2✔
2346
    def migrate_account(
2✔
2347
        cls, old_account: Account, new_account: Account
2348
    ) -> OptionalMigratedTables:
2349
        """Migrate one account's data to another when merging accounts."""
2350
        primary_email = old_account.primary_email
2✔
2351
        for accountemail in list(old_account.emails):
2✔
2352
            accountemail.account = new_account
2✔
2353
        if new_account.primary_email is None:
2✔
2354
            new_account.primary_email = primary_email
2✔
2355
        old_account.primary_email = None
2✔
2356
        return [cls.__table__.name, account_email_primary_table.name]
2✔
2357

2358

2359
class AccountEmailClaim(EmailAddressMixin, BaseMixin[int, Account], Model):
2✔
2360
    """Claimed but unverified email address for a user."""
2✔
2361

2362
    __tablename__ = 'account_email_claim'
2✔
2363
    __email_unique__ = False
2✔
2364
    __email_for__ = 'account'
2✔
2365
    __email_is_exclusive__ = False
2✔
2366

2367
    account_id: Mapped[int] = sa_orm.mapped_column(
2✔
2368
        sa.ForeignKey('account.id'), default=None, nullable=False
2369
    )
2370
    account: Mapped[Account] = relationship(back_populates='emailclaims')
2✔
2371
    user: Mapped[Account] = sa_orm.synonym('account')
2✔
2372
    verification_code: Mapped[str] = sa_orm.mapped_column(
2✔
2373
        sa.String(44), nullable=False, insert_default=newsecret, default=None
2374
    )
2375

2376
    private: Mapped[bool] = sa_orm.mapped_column(default=False)
2✔
2377

2378
    __table_args__ = (sa.UniqueConstraint('account_id', 'email_address_id'),)
2✔
2379

2380
    __datasets__ = {
2✔
2381
        'primary': {'member', 'email', 'private', 'type'},
2382
        'without_parent': {'email', 'private', 'type'},
2383
        'related': {'email', 'private', 'type'},
2384
    }
2385

2386
    def __init__(self, *, account: Account, email: str, **kwargs: Any) -> None:
2✔
2387
        kwargs['email_address'] = EmailAddress.add_for(account, email)
2✔
2388
        super().__init__(account=account, **kwargs)
2✔
2389
        self.blake2b = hashlib.blake2b(
2✔
2390
            self.email.lower().encode(), digest_size=16
2391
        ).digest()
2392

2393
    def __repr__(self) -> str:
2✔
2394
        """Represent this class as a string."""
2395
        return f'<AccountEmailClaim {self.email} of {self.account!r}>'
2✔
2396

2397
    def __str__(self) -> str:
2✔
2398
        """Return email as a string."""
2399
        return str(self.email)
×
2400

2401
    @classmethod
2✔
2402
    def migrate_account(cls, old_account: Account, new_account: Account) -> None:
2✔
2403
        """Migrate one account's data to another when merging accounts."""
2404
        emails = {claim.email for claim in new_account.emailclaims}
2✔
2405
        for claim in list(old_account.emailclaims):
2✔
2406
            if claim.email not in emails:
×
2407
                claim.account = new_account
×
2408
            else:
2409
                # New user also made the same claim. Delete old user's claim
2410
                db.session.delete(claim)
×
2411

2412
    @overload
2✔
2413
    @classmethod
2✔
2414
    def get_for(
2✔
2415
        cls,
2416
        account: Account,
2417
        *,
2418
        email: str,
2419
    ) -> AccountEmailClaim | None: ...
2420

2421
    @overload
2✔
2422
    @classmethod
2✔
2423
    def get_for(
2✔
2424
        cls,
2425
        account: Account,
2426
        *,
2427
        blake2b160: bytes,
2428
    ) -> AccountEmailClaim | None: ...
2429

2430
    @overload
2✔
2431
    @classmethod
2✔
2432
    def get_for(
2✔
2433
        cls,
2434
        account: Account,
2435
        *,
2436
        email_hash: str,
2437
    ) -> AccountEmailClaim | None: ...
2438

2439
    @classmethod
2✔
2440
    def get_for(
2✔
2441
        cls,
2442
        account: Account,
2443
        *,
2444
        email: str | None = None,
2445
        blake2b160: bytes | None = None,
2446
        email_hash: str | None = None,
2447
    ) -> AccountEmailClaim | None:
2448
        """
2449
        Return an AccountEmailClaim with matching email address for the given user.
2450

2451
        :param account: Account that claimed this email address
2452
        :param email: Email address to look up
2453
        :param blake2b160: 160-bit blake2b of email address to look up
2454
        :param email_hash: Base58 rendering of 160-bit blake2b hash
2455
        """
2456
        email_filter = EmailAddress.get_filter(
2✔
2457
            email=email, blake2b160=blake2b160, email_hash=email_hash
2458
        )
2459
        if email_filter is None:
2✔
2460
            return None
×
2461
        return (
2✔
2462
            cls.query.join(EmailAddress)
2463
            .filter(
2464
                cls.account == account,
2465
                email_filter,
2466
            )
2467
            .one_or_none()
2468
        )
2469

2470
    @overload
2✔
2471
    @classmethod
2✔
2472
    def get_by(
2✔
2473
        cls,
2474
        verification_code: str,
2475
        *,
2476
        email: str,
2477
    ) -> AccountEmailClaim | None: ...
2478

2479
    @overload
2✔
2480
    @classmethod
2✔
2481
    def get_by(
2✔
2482
        cls,
2483
        verification_code: str,
2484
        *,
2485
        blake2b160: bytes,
2486
    ) -> AccountEmailClaim | None: ...
2487

2488
    @overload
2✔
2489
    @classmethod
2✔
2490
    def get_by(
2✔
2491
        cls,
2492
        verification_code: str,
2493
        *,
2494
        email_hash: str,
2495
    ) -> AccountEmailClaim | None: ...
2496

2497
    @classmethod
2✔
2498
    def get_by(
2✔
2499
        cls,
2500
        verification_code: str,
2501
        *,
2502
        email: str | None = None,
2503
        blake2b160: bytes | None = None,
2504
        email_hash: str | None = None,
2505
    ) -> AccountEmailClaim | None:
2506
        """Return an instance given verification code and email or hash."""
2507
        email_filter = EmailAddress.get_filter(
×
2508
            email=email, blake2b160=blake2b160, email_hash=email_hash
2509
        )
2510
        if email_filter is None:
×
2511
            return None
×
2512
        return (
×
2513
            cls.query.join(EmailAddress)
2514
            .filter(
2515
                cls.verification_code == verification_code,
2516
                email_filter,
2517
            )
2518
            .one_or_none()
2519
        )
2520

2521
    @classmethod
2✔
2522
    def all(cls, email: str) -> Query[Self]:  # noqa: A003
2✔
2523
        """
2524
        Return all instances with the matching email address.
2525

2526
        :param str email: Email address to lookup
2527
        """
2528
        email_filter = EmailAddress.get_filter(email=email)
2✔
2529
        if email_filter is None:
2✔
2530
            raise ValueError(email)
2✔
2531
        return cls.query.join(EmailAddress).filter(email_filter)
2✔
2532

2533

2534
auto_init_default(AccountEmailClaim.verification_code)
2✔
2535

2536

2537
class AccountPhone(PhoneNumberMixin, BaseMixin[int, Account], Model):
2✔
2538
    """A phone number linked to an account."""
2✔
2539

2540
    __tablename__ = 'account_phone'
2✔
2541
    __phone_unique__ = True
2✔
2542
    __phone_is_exclusive__ = True
2✔
2543
    __phone_for__ = 'account'
2✔
2544

2545
    account_id: Mapped[int] = sa_orm.mapped_column(
2✔
2546
        sa.ForeignKey('account.id'), default=None
2547
    )
2548
    account: Mapped[Account] = relationship(back_populates='phones')
2✔
2549
    user: Mapped[Account] = sa_orm.synonym('account')
2✔
2550

2551
    private: Mapped[bool] = sa_orm.mapped_column(default=False)
2✔
2552

2553
    __datasets__ = {
2✔
2554
        'primary': {'member', 'phone', 'private', 'type'},
2555
        'without_parent': {'phone', 'private', 'type'},
2556
        'related': {'phone', 'private', 'type'},
2557
    }
2558

2559
    def __init__(self, *, account: Account, **kwargs: Any) -> None:
2✔
2560
        phone = kwargs.pop('phone', None)
2✔
2561
        if phone:
2✔
2562
            kwargs['phone_number'] = PhoneNumber.add_for(account, phone)
2✔
2563
        super().__init__(account=account, **kwargs)
2✔
2564

2565
    def __repr__(self) -> str:
2✔
2566
        """Represent this class as a string."""
2567
        return f'AccountPhone(phone={self.phone!r}, account={self.account!r})'
2568

2569
    def __str__(self) -> str:
2✔
2570
        """Return phone number as a string."""
2571
        return self.phone or ''
2✔
2572

2573
    __json__ = __str__
2✔
2574

2575
    @cached_property
2✔
2576
    def parsed(self) -> phonenumbers.PhoneNumber | None:
2✔
2577
        """Return parsed phone number using libphonenumber."""
2578
        return self.phone_number.parsed
×
2579

2580
    @cached_property
2✔
2581
    def formatted(self) -> str:
2✔
2582
        """Return a phone number formatted for user display."""
2583
        return self.phone_number.formatted
2✔
2584

2585
    @property
2✔
2586
    def number(self) -> str | None:
2✔
2587
        return self.phone_number.number
×
2588

2589
    @property
2✔
2590
    def primary(self) -> bool:
2✔
2591
        """Check if this is the user's primary phone number."""
2592
        return self.account.primary_phone == self
2✔
2593

2594
    @primary.setter
2✔
2595
    def primary(self, value: bool) -> None:
2✔
2596
        if value:
2✔
2597
            self.account.primary_phone = self
2✔
2598
        else:
2599
            if self.account.primary_phone == self:
×
2600
                self.account.primary_phone = None
×
2601

2602
    @overload
2✔
2603
    @classmethod
2✔
2604
    def get(
2✔
2605
        cls,
2606
        phone: str,
2607
    ) -> AccountPhone | None: ...
2608

2609
    @overload
2✔
2610
    @classmethod
2✔
2611
    def get(
2✔
2612
        cls,
2613
        *,
2614
        blake2b160: bytes,
2615
    ) -> AccountPhone | None: ...
2616

2617
    @overload
2✔
2618
    @classmethod
2✔
2619
    def get(
2✔
2620
        cls,
2621
        *,
2622
        phone_hash: str,
2623
    ) -> AccountPhone | None: ...
2624

2625
    @classmethod
2✔
2626
    def get(
2✔
2627
        cls,
2628
        phone: str | None = None,
2629
        *,
2630
        blake2b160: bytes | None = None,
2631
        phone_hash: str | None = None,
2632
    ) -> AccountPhone | None:
2633
        """
2634
        Return an AccountPhone with matching phone number.
2635

2636
        :param phone: Phone number to lookup
2637
        :param blake2b160: 160-bit blake2b of phone number to look up
2638
        :param phone_hash: blake2b hash rendered in Base58
2639
        """
2640
        return (
2✔
2641
            cls.query.join(PhoneNumber)
2642
            .filter(
2643
                PhoneNumber.get_filter(
2644
                    phone=phone, blake2b160=blake2b160, phone_hash=phone_hash
2645
                )
2646
            )
2647
            .one_or_none()
2648
        )
2649

2650
    @overload
2✔
2651
    @classmethod
2✔
2652
    def get_for(
2✔
2653
        cls,
2654
        account: Account,
2655
        *,
2656
        phone: str,
2657
    ) -> AccountPhone | None: ...
2658

2659
    @overload
2✔
2660
    @classmethod
2✔
2661
    def get_for(
2✔
2662
        cls,
2663
        account: Account,
2664
        *,
2665
        blake2b160: bytes,
2666
    ) -> AccountPhone | None: ...
2667

2668
    @overload
2✔
2669
    @classmethod
2✔
2670
    def get_for(
2✔
2671
        cls,
2672
        account: Account,
2673
        *,
2674
        phone_hash: str,
2675
    ) -> AccountPhone | None: ...
2676

2677
    @classmethod
2✔
2678
    def get_for(
2✔
2679
        cls,
2680
        account: Account,
2681
        *,
2682
        phone: str | None = None,
2683
        blake2b160: bytes | None = None,
2684
        phone_hash: str | None = None,
2685
    ) -> AccountPhone | None:
2686
        """
2687
        Return an instance with matching phone or hash if it belongs to the given user.
2688

2689
        :param account: Account to look up for
2690
        :param phone: Email address to look up
2691
        :param blake2b160: 160-bit blake2b of phone number
2692
        :param phone_hash: blake2b hash rendered in Base58
2693
        """
2694
        return (
2✔
2695
            cls.query.join(PhoneNumber)
2696
            .filter(
2697
                cls.account == account,
2698
                PhoneNumber.get_filter(
2699
                    phone=phone, blake2b160=blake2b160, phone_hash=phone_hash
2700
                ),
2701
            )
2702
            .one_or_none()
2703
        )
2704

2705
    @classmethod
2✔
2706
    def migrate_account(
2✔
2707
        cls, old_account: Account, new_account: Account
2708
    ) -> OptionalMigratedTables:
2709
        """Migrate one account's data to another when merging accounts."""
2710
        primary_phone = old_account.primary_phone
2✔
2711
        for accountphone in list(old_account.phones):
2✔
2712
            accountphone.account = new_account
2✔
2713
        if new_account.primary_phone is None:
2✔
2714
            new_account.primary_phone = primary_phone
2✔
2715
        old_account.primary_phone = None
2✔
2716
        return [cls.__table__.name, account_phone_primary_table.name]
2✔
2717

2718

2719
class AccountExternalId(BaseMixin[int, Account], Model):
2✔
2720
    """An external connected account for a user."""
2✔
2721

2722
    __tablename__ = 'account_externalid'
2✔
2723
    __at_username_services__: ClassVar[list[str]] = []
2✔
2724
    #: Foreign key to user table
2725
    account_id: Mapped[int] = sa_orm.mapped_column(
2✔
2726
        sa.ForeignKey('account.id'), default=None
2727
    )
2728
    #: User that this connected account belongs to
2729
    account: Mapped[Account] = relationship(back_populates='externalids')
2✔
2730
    user: Mapped[Account] = sa_orm.synonym('account')
2✔
2731
    #: Identity of the external service (in app's login provider registry)
2732
    service: Mapped[str] = sa_orm.mapped_column(sa.Unicode, nullable=False)
2✔
2733
    #: Unique user id as per external service, used for identifying related accounts
2734
    userid: Mapped[str] = sa_orm.mapped_column(sa.Unicode, nullable=False)
2✔
2735
    #: Optional public-facing username on the external service
2736
    username: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode, nullable=True)
2✔
2737
    #: OAuth or OAuth2 access token
2738
    oauth_token: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode, nullable=True)
2✔
2739
    #: Optional token secret (not used in OAuth2, used by Twitter with OAuth1a)
2740
    oauth_token_secret: Mapped[str | None] = sa_orm.mapped_column(
2✔
2741
        sa.Unicode, nullable=True
2742
    )
2743
    #: OAuth token type (typically 'bearer')
2744
    oauth_token_type: Mapped[str | None] = sa_orm.mapped_column(
2✔
2745
        sa.Unicode, nullable=True
2746
    )
2747
    #: OAuth2 refresh token
2748
    oauth_refresh_token: Mapped[str | None] = sa_orm.mapped_column(
2✔
2749
        sa.Unicode, nullable=True
2750
    )
2751
    #: OAuth2 token expiry in seconds, as sent by service provider
2752
    oauth_expires_in: Mapped[int | None] = sa_orm.mapped_column()
2✔
2753
    #: OAuth2 token expiry timestamp, estimate from created_at + oauth_expires_in
2754
    oauth_expires_at: Mapped[datetime | None] = sa_orm.mapped_column(
2✔
2755
        sa.TIMESTAMP(timezone=True), nullable=True, index=True
2756
    )
2757

2758
    #: Timestamp of when this connected account was last (re-)authorised by the user
2759
    last_used_at: Mapped[datetime] = sa_orm.mapped_column(
2✔
2760
        sa.TIMESTAMP(timezone=True), insert_default=sa.func.utcnow(), default=None
2761
    )
2762

2763
    __table_args__ = (
2✔
2764
        sa.UniqueConstraint('service', 'userid'),
2765
        sa.Index(
2766
            'ix_account_externalid_username_lower',
2767
            sa.func.lower(username).label('username_lower'),
2768
            postgresql_ops={'username_lower': 'varchar_pattern_ops'},
2769
        ),
2770
    )
2771

2772
    def __repr__(self) -> str:
2✔
2773
        """Represent :class:`UserExternalId` as a string."""
2774
        return f'<UserExternalId {self.service}:{self.username} of {self.account!r}>'
2✔
2775

2776
    @overload
2✔
2777
    @classmethod
2✔
2778
    def get(
2✔
2779
        cls,
2780
        service: str,
2781
        *,
2782
        userid: str,
2783
    ) -> AccountExternalId | None: ...
2784

2785
    @overload
2✔
2786
    @classmethod
2✔
2787
    def get(
2✔
2788
        cls,
2789
        service: str,
2790
        *,
2791
        username: str,
2792
    ) -> AccountExternalId | None: ...
2793

2794
    @classmethod
2✔
2795
    def get(
2✔
2796
        cls,
2797
        service: str,
2798
        *,
2799
        userid: str | None = None,
2800
        username: str | None = None,
2801
    ) -> AccountExternalId | None:
2802
        """
2803
        Return a UserExternalId with the given service and userid or username.
2804

2805
        :param str service: Service to lookup
2806
        :param str userid: Userid to lookup
2807
        :param str username: Username to lookup (may be non-unique)
2808

2809
        Usernames are not guaranteed to be unique within a service. An example is with
2810
        Google, where the userid is a directed OpenID URL, unique but subject to change
2811
        if the Lastuser site URL changes. The username is the email address, which will
2812
        be the same despite different userids.
2813
        """
2814
        param, value = require_one_of(True, userid=userid, username=username)
2✔
2815
        return cls.query.filter_by(**{param: value, 'service': service}).one_or_none()
2✔
2816

2817

2818
account_email_primary_table = add_primary_relationship(
2✔
2819
    Account, 'primary_email', AccountEmail, 'account', 'account_id'
2820
)
2821
account_phone_primary_table = add_primary_relationship(
2✔
2822
    Account, 'primary_phone', AccountPhone, 'account', 'account_id'
2823
)
2824

2825
#: Anchor type
2826
Anchor: TypeAlias = (
2✔
2827
    AccountEmail | AccountEmailClaim | AccountPhone | EmailAddress | PhoneNumber
2828
)
2829

2830
# Tail imports
2831
from .account_membership import AccountMembership
2✔
2832
from .auth_client import AuthClient, AuthClientPermissions, AuthToken
2✔
2833
from .login_session import LOGIN_SESSION_VALIDITY_PERIOD, LoginSession
2✔
2834
from .mailer import Mailer
2✔
2835
from .membership_mixin import ImmutableMembershipMixin
2✔
2836
from .notification import NotificationPreferences, NotificationRecipient
2✔
2837
from .project import Project, ProjectRedirect
2✔
2838
from .project_membership import ProjectMembership
2✔
2839
from .proposal import Proposal
2✔
2840
from .proposal_membership import ProposalMembership
2✔
2841
from .rsvp import Rsvp
2✔
2842
from .saved import SavedProject, SavedSession
2✔
2843
from .session import Session
2✔
2844
from .site_membership import SiteMembership
2✔
2845
from .sponsor_membership import ProjectSponsorMembership, ProposalSponsorMembership
2✔
2846
from .sync_ticket import TicketParticipant
2✔
2847
from .update import Update
2✔
2848

2849
if TYPE_CHECKING:
2✔
2850
    from .auth_client import AuthClientTeamPermissions
2851
    from .comment import Comment, Commentset  # noqa: F401
2852
    from .commentset_membership import CommentsetMembership
2853
    from .contact_exchange import ContactExchange
2854
    from .moderation import CommentModeratorReport
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