Coverage for src/couchers/models/postal_verification.py: 98%
41 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-20 11:53 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-20 11:53 +0000
1import enum
2from datetime import datetime
3from typing import TYPE_CHECKING, Any
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
20from couchers.models.base import Base
22if TYPE_CHECKING:
23 from couchers.models.users import User
26class PostalVerificationStatus(enum.Enum):
27 # User has initiated, awaiting address confirmation
28 pending_address_confirmation = enum.auto()
29 # Address confirmed, postcard being sent
30 in_progress = enum.auto()
31 # Postcard sent, awaiting user to enter code
32 awaiting_verification = enum.auto()
33 # Successfully verified
34 succeeded = enum.auto()
35 # Failed (too many wrong attempts or expired)
36 failed = enum.auto()
37 # Cancelled by user
38 cancelled = enum.auto()
41class PostalVerificationAttempt(Base):
42 """
43 An attempt to perform postal verification by sending a postcard with a code.
44 """
46 __tablename__ = "postal_verification_attempts"
48 id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
50 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
52 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
54 status: Mapped[PostalVerificationStatus] = mapped_column(
55 Enum(PostalVerificationStatus),
56 default=PostalVerificationStatus.pending_address_confirmation,
57 )
59 # Address fields (normalized/validated)
60 # Required: address_line_1, city, country
61 # Optional: address_line_2, state, postal_code (varies by country)
62 address_line_1: Mapped[str] = mapped_column(String)
63 address_line_2: Mapped[str | None] = mapped_column(String, nullable=True)
64 city: Mapped[str] = mapped_column(String)
65 state: Mapped[str | None] = mapped_column(String, nullable=True)
66 postal_code: Mapped[str | None] = mapped_column(String, nullable=True)
67 country: Mapped[str] = mapped_column(String) # ISO 3166-1 alpha-2
69 # The original address as entered by user (for audit), stored as JSON
70 original_address_json: Mapped[str | None] = mapped_column(String, nullable=True)
72 # Verification code (6 chars, uppercase alphanumeric)
73 verification_code: Mapped[str | None] = mapped_column(String, nullable=True)
75 # Timestamps
76 address_confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
77 postcard_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
78 verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
80 # Code entry attempts
81 code_attempts: Mapped[int] = mapped_column(Integer, server_default=text("0"))
83 # Hybrid properties
84 @hybrid_property
85 def is_valid(self) -> bool:
86 return self.status == PostalVerificationStatus.succeeded
88 @is_valid.expression
89 @classmethod
90 def is_valid(cls) -> Any:
91 return cls.status == PostalVerificationStatus.succeeded
93 # Relationships
94 user: Mapped["User"] = relationship("User")
96 # Constraints
97 __table_args__ = (
98 # Only one active attempt per user at a time
99 Index(
100 "ix_postal_verification_one_active_per_user",
101 user_id,
102 unique=True,
103 postgresql_where=(
104 (status == PostalVerificationStatus.pending_address_confirmation)
105 | (status == PostalVerificationStatus.in_progress)
106 | (status == PostalVerificationStatus.awaiting_verification)
107 ),
108 ),
109 # Index for looking up verification status for a user
110 Index(
111 "ix_postal_verification_attempts_current",
112 user_id,
113 postgresql_where=status == PostalVerificationStatus.succeeded,
114 ),
115 # Code must be set when in_progress or later (except cancelled)
116 CheckConstraint(
117 "(status IN ('pending_address_confirmation', 'cancelled') AND verification_code IS NULL) OR "
118 "(status IN ('in_progress', 'awaiting_verification', 'succeeded', 'failed') AND verification_code IS NOT NULL)",
119 name="postal_verification_code_status",
120 ),
121 # verified_at must be set when succeeded
122 CheckConstraint(
123 "(status != 'succeeded') OR (verified_at IS NOT NULL)",
124 name="postal_verification_verified_at_status",
125 ),
126 # postcard_sent_at must be set when awaiting_verification or succeeded
127 CheckConstraint(
128 "(status NOT IN ('awaiting_verification', 'succeeded')) OR (postcard_sent_at IS NOT NULL)",
129 name="postal_verification_postcard_sent_status",
130 ),
131 )