Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


backend/accounts.py (3579 bytes)

1 """Who an account is, and keeping one email to exactly one account.
2 
3 Every visitor starts anonymous, identified by a cookie. An email gets attached
4 in one of two ways - following a sign-in link, or paying (Stripe collects one at
5 checkout) - and from then on that email is the account. When the email already
6 belongs to another row, the anonymous row is folded into it, so whatever someone
7 converted before signing in is still there afterwards.
8 """
9 
10 from __future__ import annotations
11 
12 import re
13 import secrets
14 
15 from sqlalchemy.orm import Session
16 
17 from .db import Account, Job, Purchase
18 
19 # Deliberately loose: the real test of an address is whether the link arrives.
20 _EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
21 
22 
23 class InvalidEmail(ValueError):
24     pass
25 
26 
27 def normalize_email(raw: str | None) -> str:
28     email = (raw or "").strip().lower()
29     if not email or len(email) > 320 or not _EMAIL.match(email):
30         raise InvalidEmail("That does not look like an email address.")
31     return email
32 
33 
34 def new_account(session: Session) -> Account:
35     account = Account(device_token=secrets.token_urlsafe(24))
36     session.add(account)
37     session.flush()
38     return account
39 
40 
41 def by_email(session: Session, email: str) -> Account | None:
42     return session.query(Account).filter(Account.email == email).first()
43 
44 
45 def resolve(session: Session, account: Account) -> Account:
46     """Follow merged_into to the account that survived."""
47     seen = {account.id}
48     while account.merged_into:
49         target = session.get(Account, account.merged_into)
50         if target is None or target.id in seen:
51             break
52         seen.add(target.id)
53         account = target
54     return account
55 
56 
57 def merge(session: Session, src: Account, dst: Account) -> None:
58     """Fold `src` into `dst`: its books, its purchases and anything it paid for."""
59     if src.id == dst.id:
60         return
61     session.query(Job).filter(Job.account_id == src.id).update(
62         {Job.account_id: dst.id}, synchronize_session=False
63     )
64     session.query(Purchase).filter(Purchase.account_id == src.id).update(
65         {Purchase.account_id: dst.id}, synchronize_session=False
66     )
67     dst.purchased_credits += src.purchased_credits
68     src.purchased_credits = 0
69     if src.stripe_customer_id and not dst.stripe_customer_id:
70         dst.stripe_customer_id = src.stripe_customer_id
71     if src.subscription_id and not dst.subscription_id:
72         dst.subscription_id = src.subscription_id
73         dst.subscription_status = src.subscription_status
74         dst.subscription_period_end = src.subscription_period_end
75     src.stripe_customer_id = None
76     src.subscription_id = src.subscription_status = None
77     src.subscription_period_end = None
78     src.merged_into = dst.id
79 
80 
81 def adopt_email(session: Session, account: Account, email: str) -> Account:
82     """Attach `email` to `account`, returning whichever account ends up owning it.
83 
84     The caller must point the browser at the returned account - it is not
85     always the one passed in.
86     """
87     account = resolve(session, account)
88     if account.email == email:
89         return account
90 
91     owner = by_email(session, email)
92 
93     if account.email is None:
94         if owner is None:
95             account.email = email
96             return account
97         # Known email, anonymous device: bring the device's work along.
98         merge(session, account, owner)
99         return owner
100 
101     # Signed in as someone else already. Switch users; never merge two people.
102     if owner is None:
103         owner = new_account(session)
104         owner.email = email
105     return owner