Coverage for app / backend / src / couchers / models / postal_verification.py: 98%
42 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +0000
1import enum
2from datetime import datetime
3from typing import TYPE_CHECKING
5from sqlalchemy import (
6 BigInteger,
7 CheckConstraint,
8 DateTime,
9 Enum,
10 ForeignKey,
11 Index,
12 Integer,
13 String,
14 func,
15 text,
16)
17from sqlalchemy.ext.hybrid import hybrid_property
18from sqlalchemy.orm import Mapped, mapped_column, relationship
19from sqlalchemy.sql.elements import ColumnElement
21from couchers.models.base import Base
23if TYPE_CHECKING:
24 from couchers.models.users import User
27class PostalVerificationStatus(enum.Enum):
28 # User has initiated, awaiting address confirmation
29 pending_address_confirmation = enum.auto()
30 # Address confirmed, postcard being sent
31 in_progress = enum.auto()
32 # Postcard sent, awaiting user to enter code
33 awaiting_verification = enum.auto()
34 # Successfully verified
35 succeeded = enum.auto()
36 # Failed (too many wrong attempts or expired)
37 failed = enum.auto()
38 # Cancelled by user
39 cancelled = enum.auto()
42class PostalVerificationAttempt(Base, kw_only=True):
43 """
44 An attempt to perform postal verification by sending a postcard with a code.
45 """
47 __tablename__ = "postal_verification_attempts"
49 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
51 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
53 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
55 status: Mapped[PostalVerificationStatus] = mapped_column(
56 Enum(PostalVerificationStatus),
57 default=PostalVerificationStatus.pending_address_confirmation,
58 )
60 # Address fields (normalized/validated)
61 # Required: address_line_1, city, country
62 # Optional: address_line_2, state, postal_code (varies by country)
63 address_line_1: Mapped[str] = mapped_column(String)
64 address_line_2: Mapped[str | None] = mapped_column(String, default=None)
65 city: Mapped[str] = mapped_column(String)
66 state: Mapped[str | None] = mapped_column(String, default=None)
67 postal_code: Mapped[str | None] = mapped_column(String, default=None)
68 country: Mapped[str] = mapped_column(String) # ISO 3166-1 alpha-2
70 # The original address as entered by user (for audit), stored as JSON
71 original_address_json: Mapped[str | None] = mapped_column(String, default=None)
73 # Verification code (6 chars, uppercase alphanumeric)
74 verification_code: Mapped[str | None] = mapped_column(String, default=None)
76 # Timestamps
77 address_confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
78 postcard_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
79 verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
81 # Code entry attempts
82 code_attempts: Mapped[int] = mapped_column(Integer, server_default=text("0"), init=False)
84 # Hybrid properties
85 @hybrid_property
86 def is_valid(self) -> bool:
87 return self.status == PostalVerificationStatus.succeeded
89 @is_valid.inplace.expression
90 @classmethod
91 def _is_valid_expression(cls) -> ColumnElement[bool]:
92 return cls.status == PostalVerificationStatus.succeeded
94 # Relationships
95 user: Mapped[User] = relationship(init=False)
97 # Constraints
98 __table_args__ = (
99 # Only one active attempt per user at a time
100 Index(
101 "ix_postal_verification_one_active_per_user",
102 user_id,
103 unique=True,
104 postgresql_where=(
105 (status == PostalVerificationStatus.pending_address_confirmation)
106 | (status == PostalVerificationStatus.in_progress)
107 | (status == PostalVerificationStatus.awaiting_verification)
108 ),
109 ),
110 # Index for looking up verification status for a user
111 Index(
112 "ix_postal_verification_attempts_current",
113 user_id,
114 postgresql_where=status == PostalVerificationStatus.succeeded,
115 ),
116 # Code must be set when in_progress or later (except cancelled)
117 CheckConstraint(
118 "(status IN ('pending_address_confirmation', 'cancelled') AND verification_code IS NULL) OR "
119 "(status IN ('in_progress', 'awaiting_verification', 'succeeded', 'failed') AND verification_code IS NOT NULL)",
120 name="postal_verification_code_status",
121 ),
122 # verified_at must be set when succeeded
123 CheckConstraint(
124 "(status != 'succeeded') OR (verified_at IS NOT NULL)",
125 name="postal_verification_verified_at_status",
126 ),
127 # postcard_sent_at must be set when awaiting_verification or succeeded
128 CheckConstraint(
129 "(status NOT IN ('awaiting_verification', 'succeeded')) OR (postcard_sent_at IS NOT NULL)",
130 name="postal_verification_postcard_sent_status",
131 ),
132 )