Coverage for app / backend / src / couchers / models / postal_verification.py: 98%
43 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-15 11:04 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-15 11:04 +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_code: 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 # MyPostcard job ID (returned by place_order)
82 mypostcard_job_id: Mapped[int | None] = mapped_column(Integer, default=None)
84 # Code entry attempts
85 code_attempts: Mapped[int] = mapped_column(Integer, server_default=text("0"), init=False)
87 # Hybrid properties
88 @hybrid_property
89 def is_valid(self) -> bool:
90 return self.status == PostalVerificationStatus.succeeded
92 @is_valid.inplace.expression
93 @classmethod
94 def _is_valid_expression(cls) -> ColumnElement[bool]:
95 return cls.status == PostalVerificationStatus.succeeded
97 # Relationships
98 user: Mapped[User] = relationship(init=False)
100 # Constraints
101 __table_args__ = (
102 # Only one active attempt per user at a time
103 Index(
104 "ix_postal_verification_one_active_per_user",
105 user_id,
106 unique=True,
107 postgresql_where=(
108 (status == PostalVerificationStatus.pending_address_confirmation)
109 | (status == PostalVerificationStatus.in_progress)
110 | (status == PostalVerificationStatus.awaiting_verification)
111 ),
112 ),
113 # Index for looking up verification status for a user
114 Index(
115 "ix_postal_verification_attempts_current",
116 user_id,
117 postgresql_where=status == PostalVerificationStatus.succeeded,
118 ),
119 # Code must be set when in_progress or later (except cancelled)
120 CheckConstraint(
121 "(status IN ('pending_address_confirmation', 'cancelled') AND verification_code IS NULL) OR "
122 "(status IN ('in_progress', 'awaiting_verification', 'succeeded', 'failed') AND verification_code IS NOT NULL)",
123 name="postal_verification_code_status",
124 ),
125 # verified_at must be set when succeeded
126 CheckConstraint(
127 "(status != 'succeeded') OR (verified_at IS NOT NULL)",
128 name="postal_verification_verified_at_status",
129 ),
130 # postcard_sent_at must be set when awaiting_verification or succeeded
131 CheckConstraint(
132 "(status NOT IN ('awaiting_verification', 'succeeded')) OR (postcard_sent_at IS NOT NULL)",
133 name="postal_verification_postcard_sent_status",
134 ),
135 )