Created hashed customers. migrated to service instead of using db logic directly

This commit is contained in:
Jonas Linter
2025-10-13 10:51:56 +02:00
parent 2560f61ee8
commit b045c62cee
4 changed files with 490 additions and 52 deletions

View File

@@ -15,7 +15,6 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, Response from fastapi.responses import HTMLResponse, Response
from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.security import HTTPBasic, HTTPBasicCredentials
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from alpine_bits_python.schemas import ReservationData from alpine_bits_python.schemas import ReservationData
@@ -28,6 +27,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 .customer_service import CustomerService
from .db import Base, get_database_url from .db import Base, get_database_url
from .db import Customer as DBCustomer from .db import Customer as DBCustomer
from .db import Reservation as DBReservation from .db import Reservation as DBReservation
@@ -222,6 +222,17 @@ async def lifespan(app: FastAPI):
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
_LOGGER.info("Database tables checked/created at startup.") _LOGGER.info("Database tables checked/created at startup.")
# Hash any existing customers that don't have hashed versions yet
async with AsyncSessionLocal() as session:
customer_service = CustomerService(session)
hashed_count = await customer_service.hash_existing_customers()
if hashed_count > 0:
_LOGGER.info(
"Backfilled hashed data for %d existing customers", hashed_count
)
else:
_LOGGER.info("All existing customers already have hashed data")
yield yield
# Optional: Dispose engine on shutdown # Optional: Dispose engine on shutdown
@@ -372,59 +383,30 @@ async def process_wix_form_submission(request: Request, data: dict[str, Any], db
unique_id = data.get("submissionId", generate_unique_id()) unique_id = data.get("submissionId", generate_unique_id())
# use database session # Use CustomerService to handle customer creation/update with hashing
customer_service = CustomerService(db)
# Check if customer with this contact_id already exists customer_data = {
existing_customer = None "given_name": first_name,
if contact_id: "surname": last_name,
result = await db.execute( "contact_id": contact_id,
select(DBCustomer).where(DBCustomer.contact_id == contact_id) "name_prefix": name_prefix,
) "email_address": email,
existing_customer = result.scalar_one_or_none() "phone": phone_number,
"email_newsletter": email_newsletter,
"address_line": address_line,
"city_name": city_name,
"postal_code": postal_code,
"country_code": country_code,
"gender": gender,
"birth_date": birth_date,
"language": language,
"address_catalog": False,
"name_title": None,
}
if existing_customer: # This automatically creates/updates both Customer and HashedCustomer
# Update existing customer with new information db_customer = await customer_service.get_or_create_customer(customer_data)
_LOGGER.info("Updating existing customer with contact_id: %s", contact_id)
existing_customer.given_name = first_name
existing_customer.surname = last_name
existing_customer.name_prefix = name_prefix
existing_customer.email_address = email
existing_customer.phone = phone_number
existing_customer.email_newsletter = email_newsletter
existing_customer.address_line = address_line
existing_customer.city_name = city_name
existing_customer.postal_code = postal_code
existing_customer.country_code = country_code
existing_customer.gender = gender
existing_customer.birth_date = birth_date
existing_customer.language = language
existing_customer.address_catalog = False
existing_customer.name_title = None
db_customer = existing_customer
await db.flush()
else:
# Create new customer
_LOGGER.info("Creating new customer with contact_id: %s", contact_id)
db_customer = DBCustomer(
given_name=first_name,
surname=last_name,
contact_id=contact_id,
name_prefix=name_prefix,
email_address=email,
phone=phone_number,
email_newsletter=email_newsletter,
address_line=address_line,
city_name=city_name,
postal_code=postal_code,
country_code=country_code,
gender=gender,
birth_date=birth_date,
language=language,
address_catalog=False,
name_title=None,
)
db.add(db_customer)
await db.flush() # This assigns db_customer.id without committing
# Determine hotel_code and hotel_name # Determine hotel_code and hotel_name
# Priority: 1) Form field, 2) Configuration default, 3) Hardcoded fallback # Priority: 1) Form field, 2) Configuration default, 3) Hardcoded fallback

View File

@@ -0,0 +1,178 @@
"""Customer service layer for handling customer and hashed customer operations."""
from datetime import UTC, datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .db import Customer, HashedCustomer
class CustomerService:
"""Service for managing customers and their hashed versions.
Automatically maintains hashed customer data whenever customers are
created or updated, ensuring data is always in sync for Meta Conversion API.
"""
def __init__(self, session: AsyncSession):
self.session = session
async def create_customer(self, customer_data: dict) -> Customer:
"""Create a new customer and automatically create its hashed version.
Args:
customer_data: Dictionary containing customer fields
Returns:
The created Customer instance (with hashed_version relationship populated)
"""
# Create the customer
customer = Customer(**customer_data)
self.session.add(customer)
await self.session.flush() # Flush to get the customer.id
# Create hashed version
hashed_customer = customer.create_hashed_customer()
hashed_customer.created_at = datetime.now(UTC)
self.session.add(hashed_customer)
await self.session.commit()
await self.session.refresh(customer)
return customer
async def update_customer(
self, customer: Customer, update_data: dict
) -> Customer:
"""Update an existing customer and sync its hashed version.
Args:
customer: The customer to update
update_data: Dictionary of fields to update
Returns:
The updated Customer instance
"""
# Update customer fields
for key, value in update_data.items():
if hasattr(customer, key):
setattr(customer, key, value)
# Update or create hashed version
result = await self.session.execute(
select(HashedCustomer).where(
HashedCustomer.customer_id == customer.id
)
)
hashed_customer = result.scalar_one_or_none()
if hashed_customer:
# Update existing hashed customer
new_hashed = customer.create_hashed_customer()
hashed_customer.hashed_email = new_hashed.hashed_email
hashed_customer.hashed_phone = new_hashed.hashed_phone
hashed_customer.hashed_given_name = new_hashed.hashed_given_name
hashed_customer.hashed_surname = new_hashed.hashed_surname
hashed_customer.hashed_city = new_hashed.hashed_city
hashed_customer.hashed_postal_code = new_hashed.hashed_postal_code
hashed_customer.hashed_country_code = new_hashed.hashed_country_code
hashed_customer.hashed_gender = new_hashed.hashed_gender
hashed_customer.hashed_birth_date = new_hashed.hashed_birth_date
else:
# Create new hashed customer if it doesn't exist
hashed_customer = customer.create_hashed_customer()
hashed_customer.created_at = datetime.now(UTC)
self.session.add(hashed_customer)
await self.session.commit()
await self.session.refresh(customer)
return customer
async def get_customer_by_contact_id(
self, contact_id: str
) -> Optional[Customer]:
"""Get a customer by contact_id.
Args:
contact_id: The contact_id to search for
Returns:
Customer instance if found, None otherwise
"""
result = await self.session.execute(
select(Customer).where(Customer.contact_id == contact_id)
)
return result.scalar_one_or_none()
async def get_or_create_customer(self, customer_data: dict) -> Customer:
"""Get existing customer or create new one if not found.
Uses contact_id to identify existing customers if provided.
Args:
customer_data: Dictionary containing customer fields
(contact_id is optional)
Returns:
Existing or newly created Customer instance
"""
contact_id = customer_data.get("contact_id")
if contact_id:
existing = await self.get_customer_by_contact_id(contact_id)
if existing:
# Update existing customer
return await self.update_customer(existing, customer_data)
# Create new customer (either no contact_id or customer doesn't exist)
return await self.create_customer(customer_data)
async def get_hashed_customer(
self, customer_id: int
) -> Optional[HashedCustomer]:
"""Get the hashed version of a customer.
Args:
customer_id: The customer ID
Returns:
HashedCustomer instance if found, None otherwise
"""
result = await self.session.execute(
select(HashedCustomer).where(
HashedCustomer.customer_id == customer_id
)
)
return result.scalar_one_or_none()
async def hash_existing_customers(self) -> int:
"""Hash all existing customers that don't have a hashed version yet.
This is useful for backfilling hashed data for customers created
before the hashing system was implemented.
Returns:
Number of customers that were hashed
"""
# Get all customers
result = await self.session.execute(select(Customer))
customers = result.scalars().all()
hashed_count = 0
for customer in customers:
# Check if this customer already has a hashed version
existing_hashed = await self.get_hashed_customer(customer.id)
if not existing_hashed:
# Create hashed version
hashed_customer = customer.create_hashed_customer()
hashed_customer.created_at = datetime.now(UTC)
self.session.add(hashed_customer)
hashed_count += 1
if hashed_count > 0:
await self.session.commit()
return hashed_count

View File

@@ -1,3 +1,4 @@
import hashlib
import os import os
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Integer, String from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Integer, String
@@ -39,6 +40,67 @@ class Customer(Base):
name_title = Column(String) # Added for XML name_title = Column(String) # Added for XML
reservations = relationship("Reservation", back_populates="customer") reservations = relationship("Reservation", back_populates="customer")
@staticmethod
def _normalize_and_hash(value):
"""Normalize and hash a value according to Meta Conversion API requirements."""
if not value:
return None
# Normalize: lowercase, strip whitespace
normalized = str(value).lower().strip()
# Remove spaces for phone numbers
is_phone = normalized.startswith("+") or normalized.replace(
"-", ""
).replace(" ", "").isdigit()
if is_phone:
chars_to_remove = [" ", "-", "(", ")"]
for char in chars_to_remove:
normalized = normalized.replace(char, "")
# SHA256 hash
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
def create_hashed_customer(self):
"""Create a HashedCustomer instance from this Customer."""
return HashedCustomer(
customer_id=self.id,
contact_id=self.contact_id,
hashed_email=self._normalize_and_hash(self.email_address),
hashed_phone=self._normalize_and_hash(self.phone),
hashed_given_name=self._normalize_and_hash(self.given_name),
hashed_surname=self._normalize_and_hash(self.surname),
hashed_city=self._normalize_and_hash(self.city_name),
hashed_postal_code=self._normalize_and_hash(self.postal_code),
hashed_country_code=self._normalize_and_hash(self.country_code),
hashed_gender=self._normalize_and_hash(self.gender),
hashed_birth_date=self._normalize_and_hash(self.birth_date),
)
class HashedCustomer(Base):
"""Hashed customer data for Meta Conversion API.
Stores SHA256 hashed versions of customer PII according to Meta's requirements.
This allows sending conversion events without exposing raw customer data.
"""
__tablename__ = "hashed_customers"
id = Column(Integer, primary_key=True)
customer_id = Column(
Integer, ForeignKey("customers.id"), unique=True, nullable=False
)
contact_id = Column(String, unique=True) # Keep unhashed for reference
hashed_email = Column(String(64)) # SHA256 produces 64 hex chars
hashed_phone = Column(String(64))
hashed_given_name = Column(String(64))
hashed_surname = Column(String(64))
hashed_city = Column(String(64))
hashed_postal_code = Column(String(64))
hashed_country_code = Column(String(64))
hashed_gender = Column(String(64))
hashed_birth_date = Column(String(64))
created_at = Column(DateTime)
customer = relationship("Customer", backref="hashed_version")
class Reservation(Base): class Reservation(Base):
__tablename__ = "reservations" __tablename__ = "reservations"

View File

@@ -0,0 +1,216 @@
"""Tests for CustomerService functionality."""
import pytest
import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from alpine_bits_python.customer_service import CustomerService
from alpine_bits_python.db import Base, Customer, HashedCustomer
@pytest_asyncio.fixture
async def async_session():
"""Create an async session for testing."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
async with async_session_maker() as session:
yield session
await engine.dispose()
@pytest.mark.asyncio
async def test_create_customer_creates_hashed_version(async_session: AsyncSession):
"""Test that creating a customer automatically creates hashed version."""
service = CustomerService(async_session)
customer_data = {
"given_name": "John",
"surname": "Doe",
"contact_id": "test123",
"email_address": "john@example.com",
"phone": "+1234567890",
}
customer = await service.create_customer(customer_data)
assert customer.id is not None
assert customer.given_name == "John"
# Check that hashed version was created
hashed = await service.get_hashed_customer(customer.id)
assert hashed is not None
assert hashed.customer_id == customer.id
assert hashed.hashed_email is not None
assert hashed.hashed_phone is not None
assert hashed.hashed_given_name is not None
assert hashed.hashed_surname is not None
@pytest.mark.asyncio
async def test_update_customer_updates_hashed_version(async_session: AsyncSession):
"""Test that updating a customer updates the hashed version."""
service = CustomerService(async_session)
# Create initial customer
customer_data = {
"given_name": "John",
"surname": "Doe",
"contact_id": "test123",
"email_address": "john@example.com",
}
customer = await service.create_customer(customer_data)
# Get initial hashed email
hashed = await service.get_hashed_customer(customer.id)
original_hashed_email = hashed.hashed_email
# Update customer email
update_data = {"email_address": "newemail@example.com"}
updated_customer = await service.update_customer(customer, update_data)
# Check that hashed version was updated
updated_hashed = await service.get_hashed_customer(updated_customer.id)
assert updated_hashed.hashed_email != original_hashed_email
@pytest.mark.asyncio
async def test_get_or_create_customer_creates_new(async_session: AsyncSession):
"""Test get_or_create creates new customer when not found."""
service = CustomerService(async_session)
customer_data = {
"given_name": "Jane",
"surname": "Smith",
"contact_id": "new123",
"email_address": "jane@example.com",
}
customer = await service.get_or_create_customer(customer_data)
assert customer.id is not None
assert customer.contact_id == "new123"
# Verify hashed version exists
hashed = await service.get_hashed_customer(customer.id)
assert hashed is not None
@pytest.mark.asyncio
async def test_get_or_create_customer_updates_existing(async_session: AsyncSession):
"""Test get_or_create updates existing customer when found."""
service = CustomerService(async_session)
# Create initial customer
customer_data = {
"given_name": "Jane",
"surname": "Smith",
"contact_id": "existing123",
"email_address": "jane@example.com",
}
original_customer = await service.create_customer(customer_data)
# Try to create again with same contact_id but different data
updated_data = {
"given_name": "Janet",
"surname": "Smith",
"contact_id": "existing123",
"email_address": "janet@example.com",
}
customer = await service.get_or_create_customer(updated_data)
# Should be same customer ID but updated data
assert customer.id == original_customer.id
assert customer.given_name == "Janet"
assert customer.email_address == "janet@example.com"
@pytest.mark.asyncio
async def test_hash_existing_customers_backfills(async_session: AsyncSession):
"""Test that hash_existing_customers backfills missing hashed data."""
# Create a customer directly in DB without using service
customer = Customer(
given_name="Bob",
surname="Builder",
contact_id="bob123",
email_address="bob@example.com",
phone="+9876543210",
)
async_session.add(customer)
await async_session.commit()
await async_session.refresh(customer)
# Verify no hashed version exists
result = await async_session.execute(
select(HashedCustomer).where(HashedCustomer.customer_id == customer.id)
)
hashed = result.scalar_one_or_none()
assert hashed is None
# Run backfill
service = CustomerService(async_session)
count = await service.hash_existing_customers()
assert count == 1
# Verify hashed version now exists
result = await async_session.execute(
select(HashedCustomer).where(HashedCustomer.customer_id == customer.id)
)
hashed = result.scalar_one_or_none()
assert hashed is not None
assert hashed.hashed_email is not None
@pytest.mark.asyncio
async def test_hash_existing_customers_skips_already_hashed(
async_session: AsyncSession,
):
"""Test that hash_existing_customers skips customers already hashed."""
service = CustomerService(async_session)
# Create customer with service (creates hashed version)
customer_data = {
"given_name": "Alice",
"surname": "Wonder",
"contact_id": "alice123",
"email_address": "alice@example.com",
}
await service.create_customer(customer_data)
# Run backfill - should return 0 since customer is already hashed
count = await service.hash_existing_customers()
assert count == 0
@pytest.mark.asyncio
async def test_hashing_normalization(async_session: AsyncSession):
"""Test that hashing properly normalizes data."""
service = CustomerService(async_session)
# Create customer with mixed case and spaces
customer_data = {
"given_name": " John ",
"surname": "DOE",
"contact_id": "test123",
"email_address": " John.Doe@Example.COM ",
"phone": "+1 (234) 567-8900",
}
customer = await service.create_customer(customer_data)
hashed = await service.get_hashed_customer(customer.id)
# Verify hashes exist (normalization should have occurred)
assert hashed.hashed_email is not None
assert hashed.hashed_phone is not None
# Hash should be consistent for same normalized value
from alpine_bits_python.db import Customer as CustomerModel
normalized_email_hash = CustomerModel._normalize_and_hash(
"john.doe@example.com"
)
assert hashed.hashed_email == normalized_email_hash