Added account_ids to the config

This commit is contained in:
Jonas Linter
2025-10-22 17:32:28 +02:00
parent 81074d839a
commit 90d79a71fb
9 changed files with 298 additions and 100 deletions

View File

@@ -23,20 +23,28 @@ alpine_bits_auth:
hotel_name: "Bemelmans Post" hotel_name: "Bemelmans Post"
username: "bemelman" username: "bemelman"
password: !secret BEMELMANS_PASSWORD password: !secret BEMELMANS_PASSWORD
meta_account: null # Optional: Meta advertising account ID
google_account: null # Optional: Google advertising account ID
- hotel_id: "135" - hotel_id: "135"
hotel_name: "Testhotel" hotel_name: "Testhotel"
username: "sebastian" username: "sebastian"
password: !secret BOB_PASSWORD password: !secret BOB_PASSWORD
meta_account: null # Optional: Meta advertising account ID
google_account: null # Optional: Google advertising account ID
- hotel_id: "39052_001" - hotel_id: "39052_001"
hotel_name: "Jagthof Kaltern" hotel_name: "Jagthof Kaltern"
username: "jagthof" username: "jagthof"
password: !secret JAGTHOF_PASSWORD password: !secret JAGTHOF_PASSWORD
meta_account: null # Optional: Meta advertising account ID
google_account: null # Optional: Google advertising account ID
- hotel_id: "39040_001" - hotel_id: "39040_001"
hotel_name: "Residence Erika" hotel_name: "Residence Erika"
username: "erika" username: "erika"
password: !secret ERIKA_PASSWORD password: !secret ERIKA_PASSWORD
meta_account: null # Optional: Meta advertising account ID
google_account: null # Optional: Google advertising account ID
api_tokens: api_tokens:
- tLTI8wXF1OVEvUX7kdZRhSW3Qr5feBCz0mHo-kbnEp0 - tLTI8wXF1OVEvUX7kdZRhSW3Qr5feBCz0mHo-kbnEp0

View File

@@ -38,7 +38,7 @@ from .alpinebits_server import (
) )
from .auth import generate_unique_id, validate_api_key from .auth import generate_unique_id, validate_api_key
from .config_loader import load_config from .config_loader import load_config
from .const import HttpStatusCode from .const import CONF_GOOGLE_ACCOUNT, CONF_HOTEL_ID, CONF_META_ACCOUNT, HttpStatusCode
from .conversion_service import ConversionService from .conversion_service import ConversionService
from .customer_service import CustomerService from .customer_service import CustomerService
from .db import Base, get_database_url from .db import Base, get_database_url
@@ -71,85 +71,47 @@ security_bearer = HTTPBearer()
# Constants for token sanitization # Constants for token sanitization
TOKEN_LOG_LENGTH = 10 TOKEN_LOG_LENGTH = 10
# Country name to ISO 3166-1 alpha-2 code mapping
COUNTRY_NAME_TO_CODE = {
# English names
"germany": "DE",
"italy": "IT",
"austria": "AT",
"switzerland": "CH",
"france": "FR",
"netherlands": "NL",
"belgium": "BE",
"spain": "ES",
"portugal": "PT",
"united kingdom": "GB",
"uk": "GB",
"czech republic": "CZ",
"poland": "PL",
"hungary": "HU",
"croatia": "HR",
"slovenia": "SI",
# German names
"deutschland": "DE",
"italien": "IT",
"österreich": "AT",
"schweiz": "CH",
"frankreich": "FR",
"niederlande": "NL",
"belgien": "BE",
"spanien": "ES",
"vereinigtes königreich": "GB",
"tschechien": "CZ",
"polen": "PL",
"ungarn": "HU",
"kroatien": "HR",
"slowenien": "SI",
# Italian names
"germania": "DE",
"italia": "IT",
"svizzera": "CH",
"francia": "FR",
"paesi bassi": "NL",
"belgio": "BE",
"spagna": "ES",
"portogallo": "PT",
"regno unito": "GB",
"repubblica ceca": "CZ",
"polonia": "PL",
"ungheria": "HU",
"croazia": "HR",
}
def get_advertising_account_ids(
def normalize_country_input(country_input: str | None) -> str | None: config: dict[str, Any],
"""Normalize country input to ISO 3166-1 alpha-2 code. hotel_code: str,
fbclid: str | None,
Handles: gclid: str | None
- Country names in English, German, and Italian ) -> tuple[str | None, str | None]:
- Already valid 2-letter codes (case-insensitive) """Get advertising account IDs based on hotel config and click IDs.
- None/empty values
Args: Args:
country_input: Country name or code (case-insensitive) config: Application configuration dict
hotel_code: Hotel identifier to look up in config
fbclid: Facebook click ID (if present, meta_account_id will be returned)
gclid: Google click ID (if present, google_account_id will be returned)
Returns: Returns:
2-letter ISO country code (uppercase) or None if input is None/empty Tuple of (meta_account_id, google_account_id) based on conditional logic:
- meta_account_id is set only if fbclid is present AND hotel has
meta_account configured
- google_account_id is set only if gclid is present AND hotel has
google_account configured
""" """
if not country_input: meta_account_id = None
return None google_account_id = None
country_input = country_input.strip() # Look up hotel in config
alpine_bits_auth = config.get("alpine_bits_auth", [])
for hotel in alpine_bits_auth:
if hotel.get(CONF_HOTEL_ID) == hotel_code:
# Conditionally set meta_account_id if fbclid is present
if fbclid:
meta_account_id = hotel.get(CONF_META_ACCOUNT)
# If already 2 letters, assume it's a country code (ISO 3166-1 alpha-2) # Conditionally set google_account_id if gclid is present
iso_country_code_length = 2 if gclid:
if len(country_input) == iso_country_code_length and country_input.isalpha(): google_account_id = hotel.get(CONF_GOOGLE_ACCOUNT)
return country_input.upper()
# Try to match as country name (case-insensitive) break
country_lower = country_input.lower()
return COUNTRY_NAME_TO_CODE.get(country_lower, country_input) return meta_account_id, google_account_id
# Pydantic models for language detection # Pydantic models for language detection
@@ -369,7 +331,7 @@ async def lifespan(app: FastAPI):
# Run migrations after tables exist (only primary worker for race conditions) # Run migrations after tables exist (only primary worker for race conditions)
if is_primary: if is_primary:
await run_all_migrations(engine) await run_all_migrations(engine, config)
else: else:
_LOGGER.info("Skipping migrations (non-primary worker)") _LOGGER.info("Skipping migrations (non-primary worker)")
@@ -690,6 +652,15 @@ async def process_wix_form_submission(request: Request, data: dict[str, Any], db
_LOGGER.exception("Error parsing submissionTime: %s", e) _LOGGER.exception("Error parsing submissionTime: %s", e)
submissionTime = None submissionTime = None
# Extract fbclid and gclid for conditional account ID lookup
fbclid = data.get("field:fbclid")
gclid = data.get("field:gclid")
# Get advertising account IDs conditionally based on fbclid/gclid presence
meta_account_id, google_account_id = get_advertising_account_ids(
request.app.state.config, hotel_code, fbclid, gclid
)
reservation = ReservationData( reservation = ReservationData(
unique_id=unique_id, unique_id=unique_id,
start_date=date.fromisoformat(start_date), start_date=date.fromisoformat(start_date),
@@ -707,8 +678,10 @@ async def process_wix_form_submission(request: Request, data: dict[str, Any], db
utm_term=data.get("field:utm_term"), utm_term=data.get("field:utm_term"),
utm_content=data.get("field:utm_content"), utm_content=data.get("field:utm_content"),
user_comment=data.get("field:long_answer_3524", ""), user_comment=data.get("field:long_answer_3524", ""),
fbclid=data.get("field:fbclid"), fbclid=fbclid,
gclid=data.get("field:gclid"), gclid=gclid,
meta_account_id=meta_account_id,
google_account_id=google_account_id,
) )
if reservation.md5_unique_id is None: if reservation.md5_unique_id is None:
@@ -818,9 +791,6 @@ async def process_generic_webhook_submission(
city = form_data.get("stadt", "") city = form_data.get("stadt", "")
country = form_data.get("land", "") country = form_data.get("land", "")
# Normalize country input (convert names to codes, handle case)
country = normalize_country_input(country)
# Parse dates - handle DD.MM.YYYY format # Parse dates - handle DD.MM.YYYY format
start_date_str = form_data.get("anreise") start_date_str = form_data.get("anreise")
end_date_str = form_data.get("abreise") end_date_str = form_data.get("abreise")
@@ -912,6 +882,11 @@ async def process_generic_webhook_submission(
# Create/update customer # Create/update customer
db_customer = await customer_service.get_or_create_customer(customer_data) db_customer = await customer_service.get_or_create_customer(customer_data)
# Get advertising account IDs conditionally based on fbclid/gclid presence
meta_account_id, google_account_id = get_advertising_account_ids(
request.app.state.config, hotel_code, fbclid, gclid
)
# Create reservation # Create reservation
reservation_kwargs = { reservation_kwargs = {
"unique_id": unique_id, "unique_id": unique_id,
@@ -931,6 +906,8 @@ async def process_generic_webhook_submission(
"user_comment": user_comment, "user_comment": user_comment,
"fbclid": fbclid, "fbclid": fbclid,
"gclid": gclid, "gclid": gclid,
"meta_account_id": meta_account_id,
"google_account_id": google_account_id,
} }
# Only include created_at if we have a valid submission_time # Only include created_at if we have a valid submission_time

View File

@@ -19,11 +19,13 @@ from voluptuous import (
from alpine_bits_python.const import ( from alpine_bits_python.const import (
CONF_ALPINE_BITS_AUTH, CONF_ALPINE_BITS_AUTH,
CONF_DATABASE, CONF_DATABASE,
CONF_GOOGLE_ACCOUNT,
CONF_HOTEL_ID, CONF_HOTEL_ID,
CONF_HOTEL_NAME, CONF_HOTEL_NAME,
CONF_LOGGING, CONF_LOGGING,
CONF_LOGGING_FILE, CONF_LOGGING_FILE,
CONF_LOGGING_LEVEL, CONF_LOGGING_LEVEL,
CONF_META_ACCOUNT,
CONF_PASSWORD, CONF_PASSWORD,
CONF_PUSH_ENDPOINT, CONF_PUSH_ENDPOINT,
CONF_PUSH_TOKEN, CONF_PUSH_TOKEN,
@@ -74,6 +76,8 @@ hotel_auth_schema = Schema(
Required(CONF_HOTEL_NAME): str, Required(CONF_HOTEL_NAME): str,
Required(CONF_USERNAME): str, Required(CONF_USERNAME): str,
Required(CONF_PASSWORD): str, Required(CONF_PASSWORD): str,
Optional(CONF_META_ACCOUNT): str,
Optional(CONF_GOOGLE_ACCOUNT): str,
Optional(CONF_PUSH_ENDPOINT): { Optional(CONF_PUSH_ENDPOINT): {
Required(CONF_PUSH_URL): str, Required(CONF_PUSH_URL): str,
Required(CONF_PUSH_TOKEN): str, Required(CONF_PUSH_TOKEN): str,

View File

@@ -37,6 +37,8 @@ CONF_HOTEL_ID: Final[str] = "hotel_id"
CONF_HOTEL_NAME: Final[str] = "hotel_name" CONF_HOTEL_NAME: Final[str] = "hotel_name"
CONF_USERNAME: Final[str] = "username" CONF_USERNAME: Final[str] = "username"
CONF_PASSWORD: Final[str] = "password" CONF_PASSWORD: Final[str] = "password"
CONF_META_ACCOUNT: Final[str] = "meta_account"
CONF_GOOGLE_ACCOUNT: Final[str] = "google_account"
CONF_PUSH_ENDPOINT: Final[str] = "push_endpoint" CONF_PUSH_ENDPOINT: Final[str] = "push_endpoint"
CONF_PUSH_URL: Final[str] = "url" CONF_PUSH_URL: Final[str] = "url"
CONF_PUSH_TOKEN: Final[str] = "token" CONF_PUSH_TOKEN: Final[str] = "token"

View File

@@ -124,6 +124,9 @@ class Reservation(Base):
user_comment = Column(String) user_comment = Column(String)
fbclid = Column(String) fbclid = Column(String)
gclid = Column(String) gclid = Column(String)
# Advertising account IDs (stored conditionally based on fbclid/gclid presence)
meta_account_id = Column(String)
google_account_id = Column(String)
# Add hotel_code and hotel_name for XML # Add hotel_code and hotel_name for XML
hotel_code = Column(String) hotel_code = Column(String)
hotel_name = Column(String) hotel_name = Column(String)

View File

@@ -4,9 +4,12 @@ This module contains migration functions that are automatically run at app start
to update existing database schemas without losing data. to update existing database schemas without losing data.
""" """
from typing import Any
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
from sqlalchemy.ext.asyncio import AsyncEngine from sqlalchemy.ext.asyncio import AsyncEngine
from .const import CONF_GOOGLE_ACCOUNT, CONF_HOTEL_ID, CONF_META_ACCOUNT
from .logging_config import get_logger from .logging_config import get_logger
_LOGGER = get_logger(__name__) _LOGGER = get_logger(__name__)
@@ -96,17 +99,146 @@ async def migrate_add_room_types(engine: AsyncEngine) -> None:
_LOGGER.info("Migration add_room_types: No changes needed (already applied)") _LOGGER.info("Migration add_room_types: No changes needed (already applied)")
async def run_all_migrations(engine: AsyncEngine) -> None: async def migrate_add_advertising_account_ids(engine: AsyncEngine, config: dict[str, Any] | None = None) -> None:
"""Migration: Add advertising account ID fields to reservations table.
This migration adds two optional fields:
- meta_account_id: String (Meta/Facebook advertising account ID)
- google_account_id: String (Google advertising account ID)
These fields are populated conditionally based on fbclid/gclid presence.
For existing reservations, backfills account IDs from config based on hotel_code and fbclid/gclid.
Safe to run multiple times - will skip if columns already exist.
Args:
engine: SQLAlchemy async engine
config: Application configuration dict containing hotel account IDs
"""
_LOGGER.info("Running migration: add_advertising_account_ids")
added_count = 0
# Add each column if it doesn't exist
if await add_column_if_not_exists(engine, "reservations", "meta_account_id", "VARCHAR"):
added_count += 1
if await add_column_if_not_exists(engine, "reservations", "google_account_id", "VARCHAR"):
added_count += 1
if added_count > 0:
_LOGGER.info("Migration add_advertising_account_ids: Added %d columns", added_count)
else:
_LOGGER.info("Migration add_advertising_account_ids: Columns already exist")
# Backfill existing reservations with account IDs based on config and fbclid/gclid presence
if config:
await _backfill_advertising_account_ids(engine, config)
else:
_LOGGER.warning("No config provided, skipping backfill of advertising account IDs")
async def _backfill_advertising_account_ids(engine: AsyncEngine, config: dict[str, Any]) -> None:
"""Backfill advertising account IDs for existing reservations.
Updates existing reservations to populate meta_account_id and google_account_id
based on the conditional logic:
- If fbclid is present, set meta_account_id from hotel config
- If gclid is present, set google_account_id from hotel config
Args:
engine: SQLAlchemy async engine
config: Application configuration dict
"""
_LOGGER.info("Backfilling advertising account IDs for existing reservations...")
# Build a mapping of hotel_id -> account IDs from config
hotel_accounts = {}
alpine_bits_auth = config.get("alpine_bits_auth", [])
for hotel in alpine_bits_auth:
hotel_id = hotel.get(CONF_HOTEL_ID)
meta_account = hotel.get(CONF_META_ACCOUNT)
google_account = hotel.get(CONF_GOOGLE_ACCOUNT)
if hotel_id:
hotel_accounts[hotel_id] = {
"meta_account": meta_account,
"google_account": google_account
}
if not hotel_accounts:
_LOGGER.info("No hotel accounts found in config, skipping backfill")
return
_LOGGER.info("Found %d hotel(s) with account configurations", len(hotel_accounts))
# Update reservations with meta_account_id where fbclid is present
meta_updated = 0
for hotel_id, accounts in hotel_accounts.items():
if accounts["meta_account"]:
async with engine.begin() as conn:
sql = text(
"UPDATE reservations "
"SET meta_account_id = :meta_account "
"WHERE hotel_code = :hotel_id "
"AND fbclid IS NOT NULL "
"AND fbclid != '' "
"AND (meta_account_id IS NULL OR meta_account_id = '')"
)
result = await conn.execute(
sql,
{"meta_account": accounts["meta_account"], "hotel_id": hotel_id}
)
count = result.rowcount
if count > 0:
_LOGGER.info("Updated %d reservations with meta_account_id for hotel %s", count, hotel_id)
meta_updated += count
# Update reservations with google_account_id where gclid is present
google_updated = 0
for hotel_id, accounts in hotel_accounts.items():
if accounts["google_account"]:
async with engine.begin() as conn:
sql = text(
"UPDATE reservations "
"SET google_account_id = :google_account "
"WHERE hotel_code = :hotel_id "
"AND gclid IS NOT NULL "
"AND gclid != '' "
"AND (google_account_id IS NULL OR google_account_id = '')"
)
result = await conn.execute(
sql,
{"google_account": accounts["google_account"], "hotel_id": hotel_id}
)
count = result.rowcount
if count > 0:
_LOGGER.info("Updated %d reservations with google_account_id for hotel %s", count, hotel_id)
google_updated += count
_LOGGER.info(
"Backfill complete: %d reservations updated with meta_account_id, %d with google_account_id",
meta_updated,
google_updated
)
async def run_all_migrations(engine: AsyncEngine, config: dict[str, Any] | None = None) -> None:
"""Run all pending migrations. """Run all pending migrations.
This function should be called at app startup, after Base.metadata.create_all. This function should be called at app startup, after Base.metadata.create_all.
Each migration function should be idempotent (safe to run multiple times). Each migration function should be idempotent (safe to run multiple times).
Args:
engine: SQLAlchemy async engine
config: Application configuration dict (optional, but required for some migrations)
""" """
_LOGGER.info("Starting database migrations...") _LOGGER.info("Starting database migrations...")
try: try:
# Add new migrations here in chronological order # Add new migrations here in chronological order
await migrate_add_room_types(engine) await migrate_add_room_types(engine)
await migrate_add_advertising_account_ids(engine, config)
_LOGGER.info("Database migrations completed successfully") _LOGGER.info("Database migrations completed successfully")

View File

@@ -16,6 +16,57 @@ from enum import Enum
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
# Country name to ISO 3166-1 alpha-2 code mapping
COUNTRY_NAME_TO_CODE = {
# English names
"germany": "DE",
"italy": "IT",
"austria": "AT",
"switzerland": "CH",
"france": "FR",
"netherlands": "NL",
"belgium": "BE",
"spain": "ES",
"portugal": "PT",
"united kingdom": "GB",
"uk": "GB",
"czech republic": "CZ",
"poland": "PL",
"hungary": "HU",
"croatia": "HR",
"slovenia": "SI",
# German names
"deutschland": "DE",
"italien": "IT",
"österreich": "AT",
"schweiz": "CH",
"frankreich": "FR",
"niederlande": "NL",
"belgien": "BE",
"spanien": "ES",
"vereinigtes königreich": "GB",
"tschechien": "CZ",
"polen": "PL",
"ungarn": "HU",
"kroatien": "HR",
"slowenien": "SI",
# Italian names
"germania": "DE",
"italia": "IT",
"svizzera": "CH",
"francia": "FR",
"paesi bassi": "NL",
"belgio": "BE",
"spagna": "ES",
"portogallo": "PT",
"regno unito": "GB",
"repubblica ceca": "CZ",
"polonia": "PL",
"ungheria": "HU",
"croazia": "HR",
}
# phonetechtype enum 1,3,5 voice, fax, mobile # phonetechtype enum 1,3,5 voice, fax, mobile
class PhoneTechType(Enum): class PhoneTechType(Enum):
VOICE = "1" VOICE = "1"
@@ -53,6 +104,9 @@ class ReservationData(BaseModel):
user_comment: str | None = Field(None, max_length=2000) user_comment: str | None = Field(None, max_length=2000)
fbclid: str | None = Field(None, max_length=300) fbclid: str | None = Field(None, max_length=300)
gclid: str | None = Field(None, max_length=300) gclid: str | None = Field(None, max_length=300)
# Advertising account IDs (populated conditionally based on fbclid/gclid)
meta_account_id: str | None = Field(None, max_length=200)
google_account_id: str | None = Field(None, max_length=200)
utm_source: str | None = Field(None, max_length=150) utm_source: str | None = Field(None, max_length=150)
utm_medium: str | None = Field(None, max_length=150) utm_medium: str | None = Field(None, max_length=150)
utm_campaign: str | None = Field(None, max_length=150) utm_campaign: str | None = Field(None, max_length=150)
@@ -120,26 +174,41 @@ class CustomerData(BaseModel):
@field_validator("country_code", mode="before") @field_validator("country_code", mode="before")
@classmethod @classmethod
def normalize_country_code(cls, v: str | None) -> str | None: def normalize_country_code(cls, v: str | None) -> str | None:
"""Normalize country code to uppercase and validate format. """Normalize country input to ISO 3166-1 alpha-2 code.
Handles:
- Country names in English, German, and Italian
- Already valid 2-letter codes (case-insensitive)
- None/empty values
Runs in 'before' mode to normalize before other validations. Runs in 'before' mode to normalize before other validations.
Accepts 2-letter country codes (case-insensitive) and normalizes This ensures that old data saved incorrectly in the database is
to uppercase ISO 3166-1 alpha-2 format. transformed into the correct format when retrieved, and that new
data is always normalized regardless of the source.
Args:
v: Country name or code (case-insensitive)
Returns:
2-letter ISO country code (uppercase) or None if input is None/empty
""" """
if v is None or v == "": if not v:
return None return None
# Convert to string and strip whitespace # Convert to string and strip whitespace
v = str(v).strip().upper() country_input = str(v).strip()
# Validate it's exactly 2 letters if not country_input:
if len(v) != 2 or not v.isalpha(): return None
raise ValueError(
f"Country code must be exactly 2 letters (ISO 3166-1 alpha-2), "
f"got '{v}'"
)
return v # If already 2 letters, assume it's a country code (ISO 3166-1 alpha-2)
iso_country_code_length = 2
if len(country_input) == iso_country_code_length and country_input.isalpha():
return country_input.upper()
# Try to match as country name (case-insensitive)
country_lower = country_input.lower()
return COUNTRY_NAME_TO_CODE.get(country_lower, country_input)
@field_validator("language") @field_validator("language")
@classmethod @classmethod

View File

@@ -131,7 +131,7 @@ def sample_wix_form_data():
"contactId": f"contact-{unique_id}", "contactId": f"contact-{unique_id}",
}, },
"field:anrede": "Mr.", "field:anrede": "Mr.",
"field:form_field_5a7b": "Checked", "field:form_field_5a7b": True,
"field:date_picker_a7c8": "2024-12-25", "field:date_picker_a7c8": "2024-12-25",
"field:date_picker_7e65": "2024-12-31", "field:date_picker_7e65": "2024-12-31",
"field:number_7cf5": "2", "field:number_7cf5": "2",

View File

@@ -249,27 +249,30 @@ async def test_hash_existing_customers_normalizes_country_code(
hashed = result.scalar_one_or_none() hashed = result.scalar_one_or_none()
assert hashed is None assert hashed is None
# Verify the customer has the invalid country code # Verify the customer has the invalid country code stored in the DB
assert customer.country_code == "Italy" assert customer.country_code == "Italy"
# Run hash_existing_customers - this should fail to validate "Italy" # Run hash_existing_customers - this should normalize "Italy" to "IT"
# because it's not a 2-letter code, so the customer should be skipped # during validation and successfully create a hashed customer
service = CustomerService(async_session) service = CustomerService(async_session)
count = await service.hash_existing_customers() count = await service.hash_existing_customers()
# Should skip this customer due to validation error # Should successfully hash this customer (country code normalized during validation)
assert count == 0 assert count == 1
# Verify hashed version was NOT created # Verify hashed version was created
await async_session.refresh(customer) await async_session.refresh(customer)
result = await async_session.execute( result = await async_session.execute(
select(HashedCustomer).where(HashedCustomer.customer_id == customer.id) select(HashedCustomer).where(HashedCustomer.customer_id == customer.id)
) )
hashed = result.scalar_one_or_none() hashed = result.scalar_one_or_none()
assert hashed is None assert hashed is not None
# The customer's country_code should still be "Italy" (unchanged) # The hashed customer should have the hashed version of normalized country code "IT"
assert customer.country_code == "Italy" # "IT" -> lowercase "it" -> sha256 hash
expected_hash = "2ad8a7049d7c5511ac254f5f51fe70a046ebd884729056f0fe57f5160d467153"
assert hashed.hashed_country_code == expected_hash
# Note: The original customer's country_code in DB remains "Italy" unchanged
# Now let's test the case where we have a valid 2-letter code # Now let's test the case where we have a valid 2-letter code
# but in the wrong case (should be normalized to uppercase) # but in the wrong case (should be normalized to uppercase)
@@ -290,7 +293,7 @@ async def test_hash_existing_customers_normalizes_country_code(
# Run hash_existing_customers again # Run hash_existing_customers again
count = await service.hash_existing_customers() count = await service.hash_existing_customers()
# Should hash this customer (country code will be normalized) # Should hash only the second customer (first one already hashed)
assert count == 1 assert count == 1
# Verify the customer's country_code was normalized to uppercase # Verify the customer's country_code was normalized to uppercase