Merge branch 'db_fixes_plus_free_rooms' of https://gitea.99tales.net/jonas/alpinebits_python into db_fixes_plus_free_rooms
This commit is contained in:
@@ -768,9 +768,9 @@ def _process_single_reservation(
|
||||
hotel_reservation_id=[hotel_res_id]
|
||||
)
|
||||
|
||||
if reservation.hotel_code is None:
|
||||
if reservation.hotel_id is None:
|
||||
raise ValueError("Reservation hotel_code is None")
|
||||
hotel_code = str(reservation.hotel_code)
|
||||
hotel_code = str(reservation.hotel_id)
|
||||
hotel_name = None if reservation.hotel_name is None else str(reservation.hotel_name)
|
||||
|
||||
basic_property_info = HotelReservation.ResGlobalInfo.BasicPropertyInfo(
|
||||
|
||||
@@ -28,7 +28,7 @@ from fastapi.security import (
|
||||
from pydantic import BaseModel
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from sqlalchemy import and_, select, update
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from alpine_bits_python.hotel_service import HotelService
|
||||
@@ -138,7 +138,7 @@ async def push_listener(customer: DBCustomer, reservation: DBReservation, hotel)
|
||||
|
||||
server: AlpineBitsServer = app.state.alpine_bits_server
|
||||
hotel_id = hotel["hotel_id"]
|
||||
reservation_hotel_id = reservation.hotel_code
|
||||
reservation_hotel_id = reservation.hotel_id
|
||||
|
||||
# Double-check hotel matching (should be guaranteed by dispatcher)
|
||||
if hotel_id != reservation_hotel_id:
|
||||
@@ -719,7 +719,7 @@ async def validate_basic_auth(
|
||||
async def handle_webhook_unified(
|
||||
request: Request,
|
||||
webhook_secret: str,
|
||||
db_session=Depends(get_async_session),
|
||||
db_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Unified webhook handler with deduplication and routing.
|
||||
|
||||
@@ -846,6 +846,9 @@ async def handle_webhook_unified(
|
||||
if not webhook_endpoint:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
|
||||
webhook_endpoint_id = webhook_endpoint.id
|
||||
webhook_hotel_id = webhook_endpoint.hotel_id
|
||||
|
||||
# Verify hotel is active
|
||||
if not webhook_endpoint.hotel.is_active:
|
||||
raise HTTPException(status_code=404, detail="Hotel is not active")
|
||||
@@ -860,8 +863,8 @@ async def handle_webhook_unified(
|
||||
|
||||
webhook_request_data = WebhookRequestData(
|
||||
payload_json=payload,
|
||||
webhook_endpoint_id=webhook_endpoint.id,
|
||||
hotel_id=webhook_endpoint.hotel_id,
|
||||
webhook_endpoint_id=webhook_endpoint_id,
|
||||
hotel_id=webhook_hotel_id,
|
||||
status=WebhookStatus.PROCESSING,
|
||||
processing_started_at=timestamp,
|
||||
created_at=timestamp,
|
||||
@@ -923,12 +926,17 @@ async def handle_webhook_unified(
|
||||
db_session.add(webhook_request)
|
||||
await db_session.flush()
|
||||
|
||||
webhook_request_id = webhook_request.id
|
||||
|
||||
try:
|
||||
# 6. Get processor for webhook_type
|
||||
processor = webhook_registry.get_processor(webhook_endpoint.webhook_type)
|
||||
if not processor:
|
||||
raise ValueError(f"No processor for type: {webhook_endpoint.webhook_type}")
|
||||
|
||||
# Persist the webhook row before handing off to processors
|
||||
await db_session.commit()
|
||||
|
||||
# 7. Process webhook with simplified interface
|
||||
result = await processor.process(
|
||||
webhook_request=webhook_request,
|
||||
@@ -937,34 +945,50 @@ async def handle_webhook_unified(
|
||||
event_dispatcher=request.app.state.event_dispatcher,
|
||||
)
|
||||
|
||||
# 8. Update status and link created entities when available
|
||||
webhook_request.status = WebhookStatus.COMPLETED
|
||||
webhook_request.processing_completed_at = datetime.now(UTC)
|
||||
if not db_session.in_transaction():
|
||||
await db_session.begin()
|
||||
|
||||
created_customer_id = result.get("customer_id") if isinstance(result, dict) else None
|
||||
created_reservation_id = (
|
||||
result.get("reservation_id") if isinstance(result, dict) else None
|
||||
completion_values = {
|
||||
"status": WebhookStatus.COMPLETED,
|
||||
"processing_completed_at": datetime.now(UTC),
|
||||
}
|
||||
|
||||
if isinstance(result, dict):
|
||||
created_customer_id = result.get("customer_id")
|
||||
created_reservation_id = result.get("reservation_id")
|
||||
if created_customer_id:
|
||||
completion_values["created_customer_id"] = created_customer_id
|
||||
if created_reservation_id:
|
||||
completion_values["created_reservation_id"] = created_reservation_id
|
||||
|
||||
await db_session.execute(
|
||||
update(WebhookRequest)
|
||||
.where(WebhookRequest.id == webhook_request_id)
|
||||
.values(**completion_values)
|
||||
)
|
||||
|
||||
if created_customer_id:
|
||||
webhook_request.created_customer_id = created_customer_id
|
||||
if created_reservation_id:
|
||||
webhook_request.created_reservation_id = created_reservation_id
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
return {
|
||||
**result,
|
||||
"webhook_id": webhook_request.id,
|
||||
"hotel_id": webhook_endpoint.hotel_id,
|
||||
"webhook_id": webhook_request_id,
|
||||
"hotel_id": webhook_hotel_id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
_LOGGER.exception("Error processing webhook: %s", e)
|
||||
|
||||
webhook_request.status = WebhookStatus.FAILED
|
||||
webhook_request.last_error = str(e)[:2000]
|
||||
webhook_request.processing_completed_at = datetime.now(UTC)
|
||||
await db_session.rollback()
|
||||
if not db_session.in_transaction():
|
||||
await db_session.begin()
|
||||
await db_session.execute(
|
||||
update(WebhookRequest)
|
||||
.where(WebhookRequest.id == webhook_request_id)
|
||||
.values(
|
||||
status=WebhookStatus.FAILED,
|
||||
last_error=str(e)[:2000],
|
||||
processing_completed_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
raise HTTPException(status_code=500, detail="Error processing webhook")
|
||||
@@ -1188,7 +1212,9 @@ async def _process_conversion_xml_background(
|
||||
# Now process the conversion XML
|
||||
_LOGGER.info("Starting database processing of %s", filename)
|
||||
conversion_service = ConversionService(session_maker, hotel.hotel_id)
|
||||
processing_stats = await conversion_service.process_conversion_xml(xml_content)
|
||||
processing_stats = await conversion_service.process_conversion_xml(xml_content, run_full_guest_matching=True)
|
||||
|
||||
await conversion_service.classify_regular_guests(24)
|
||||
|
||||
_LOGGER.info(
|
||||
"Conversion processing complete for %s: %s", filename, processing_stats
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -472,7 +472,7 @@ class CSVImporter:
|
||||
num_adults=num_adults,
|
||||
num_children=num_children,
|
||||
children_ages=children_ages,
|
||||
hotel_code=final_hotel_code,
|
||||
hotel_id=final_hotel_code,
|
||||
hotel_name=final_hotel_name,
|
||||
offer=str(row.get("room_offer", "")).strip() or None,
|
||||
user_comment=str(row.get("message", "")).strip() or None,
|
||||
|
||||
@@ -6,7 +6,7 @@ from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .db import Customer, HashedCustomer
|
||||
from .db import Customer
|
||||
from .logging_config import get_logger
|
||||
from .schemas import CustomerData
|
||||
|
||||
@@ -53,13 +53,13 @@ class CustomerService:
|
||||
if "phone" in customer_data:
|
||||
customer.phone = customer_data["phone"]
|
||||
|
||||
self.session.add(customer)
|
||||
await self.session.flush() # Flush to get the customer.id
|
||||
# Set creation timestamp
|
||||
customer.created_at = datetime.now(UTC)
|
||||
|
||||
# Create hashed version
|
||||
hashed_customer = customer.create_hashed_customer()
|
||||
hashed_customer.created_at = datetime.now(UTC)
|
||||
self.session.add(hashed_customer)
|
||||
# Update hashed fields
|
||||
customer.update_hashed_fields()
|
||||
|
||||
self.session.add(customer)
|
||||
|
||||
if auto_commit:
|
||||
await self.session.commit()
|
||||
@@ -130,29 +130,8 @@ class CustomerService:
|
||||
if "phone" in update_data:
|
||||
customer.phone = update_data["phone"]
|
||||
|
||||
# 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)
|
||||
# Update hashed fields
|
||||
customer.update_hashed_fields()
|
||||
|
||||
if auto_commit:
|
||||
await self.session.commit()
|
||||
@@ -200,26 +179,27 @@ class CustomerService:
|
||||
# Create new customer (either no contact_id or customer doesn't exist)
|
||||
return await self.create_customer(customer_data, auto_commit=auto_commit)
|
||||
|
||||
async def get_hashed_customer(self, customer_id: int) -> HashedCustomer | None:
|
||||
async def get_customer(self, customer_id: int) -> Customer | None:
|
||||
"""Get the hashed version of a customer.
|
||||
|
||||
Args:
|
||||
customer_id: The customer ID
|
||||
|
||||
Returns:
|
||||
HashedCustomer instance if found, None otherwise
|
||||
Customer instance if found, None otherwise
|
||||
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(HashedCustomer).where(HashedCustomer.customer_id == customer_id)
|
||||
select(Customer).where(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.
|
||||
"""Hash all existing customers that don't have hashed fields populated yet.
|
||||
|
||||
This is useful for backfilling hashed data for customers created
|
||||
before the hashing system was implemented.
|
||||
before the hashing system was implemented, or after migrating from
|
||||
the separate hashed_customers table.
|
||||
|
||||
Also validates and sanitizes customer data (e.g., normalizes country
|
||||
codes to uppercase). Customers with invalid data that cannot be fixed
|
||||
@@ -229,62 +209,64 @@ class CustomerService:
|
||||
Number of customers that were hashed
|
||||
|
||||
"""
|
||||
# Get all customers
|
||||
result = await self.session.execute(select(Customer))
|
||||
# Get all customers without hashed data
|
||||
result = await self.session.execute(
|
||||
select(Customer).where(Customer.hashed_email.is_(None))
|
||||
)
|
||||
customers = result.scalars().all()
|
||||
|
||||
hashed_count = 0
|
||||
skipped_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:
|
||||
# Validate and sanitize customer data before hashing
|
||||
customer_dict = {
|
||||
"given_name": customer.given_name,
|
||||
"surname": customer.surname,
|
||||
"name_prefix": customer.name_prefix,
|
||||
"email_address": customer.email_address,
|
||||
"phone": customer.phone,
|
||||
"email_newsletter": customer.email_newsletter,
|
||||
"address_line": customer.address_line,
|
||||
"city_name": customer.city_name,
|
||||
"postal_code": customer.postal_code,
|
||||
"country_code": customer.country_code,
|
||||
"gender": customer.gender,
|
||||
"birth_date": customer.birth_date,
|
||||
"language": customer.language,
|
||||
"address_catalog": customer.address_catalog,
|
||||
"name_title": customer.name_title,
|
||||
}
|
||||
# Validate and sanitize customer data before hashing
|
||||
customer_dict = {
|
||||
"given_name": customer.given_name,
|
||||
"surname": customer.surname,
|
||||
"name_prefix": customer.name_prefix,
|
||||
"email_address": customer.email_address,
|
||||
"phone": customer.phone,
|
||||
"email_newsletter": customer.email_newsletter,
|
||||
"address_line": customer.address_line,
|
||||
"city_name": customer.city_name,
|
||||
"postal_code": customer.postal_code,
|
||||
"country_code": customer.country_code,
|
||||
"gender": customer.gender,
|
||||
"birth_date": customer.birth_date,
|
||||
"language": customer.language,
|
||||
"address_catalog": customer.address_catalog,
|
||||
"name_title": customer.name_title,
|
||||
}
|
||||
|
||||
try:
|
||||
# Validate through Pydantic (normalizes country code)
|
||||
validated = CustomerData(**customer_dict)
|
||||
try:
|
||||
# Validate through Pydantic (normalizes country code)
|
||||
validated = CustomerData(**customer_dict)
|
||||
|
||||
# Update customer with sanitized data
|
||||
# Exclude 'phone_numbers' as Customer model uses 'phone' field
|
||||
for key, value in validated.model_dump(
|
||||
exclude_none=True, exclude={"phone_numbers"}
|
||||
).items():
|
||||
if hasattr(customer, key):
|
||||
setattr(customer, key, value)
|
||||
# Update customer with sanitized data
|
||||
# Exclude 'phone_numbers' as Customer model uses 'phone' field
|
||||
for key, value in validated.model_dump(
|
||||
exclude_none=True, exclude={"phone_numbers"}
|
||||
).items():
|
||||
if hasattr(customer, key):
|
||||
setattr(customer, key, value)
|
||||
|
||||
# Create hashed version with sanitized data
|
||||
hashed_customer = customer.create_hashed_customer()
|
||||
hashed_customer.created_at = datetime.now(UTC)
|
||||
self.session.add(hashed_customer)
|
||||
hashed_count += 1
|
||||
# Update hashed fields with sanitized data
|
||||
customer.update_hashed_fields()
|
||||
|
||||
except ValidationError as e:
|
||||
# Skip customers with invalid data and log
|
||||
skipped_count += 1
|
||||
_LOGGER.warning(
|
||||
"Skipping customer ID %s due to validation error: %s",
|
||||
customer.id,
|
||||
e,
|
||||
)
|
||||
# Set created_at if not already set
|
||||
if not customer.created_at:
|
||||
customer.created_at = datetime.now(UTC)
|
||||
|
||||
hashed_count += 1
|
||||
|
||||
except ValidationError as e:
|
||||
# Skip customers with invalid data and log
|
||||
skipped_count += 1
|
||||
_LOGGER.warning(
|
||||
"Skipping customer ID %s due to validation error: %s",
|
||||
customer.id,
|
||||
e,
|
||||
)
|
||||
|
||||
if hashed_count > 0:
|
||||
await self.session.commit()
|
||||
|
||||
@@ -27,7 +27,7 @@ from sqlalchemy.ext.asyncio import (
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import backref, declarative_base, relationship
|
||||
from sqlalchemy.orm import backref, declarative_base, foreign, relationship
|
||||
|
||||
from .const import WebhookStatus
|
||||
from .logging_config import get_logger
|
||||
@@ -311,6 +311,20 @@ class Customer(Base):
|
||||
language = Column(String)
|
||||
address_catalog = Column(Boolean) # Added for XML
|
||||
name_title = Column(String) # Added for XML
|
||||
|
||||
# Hashed fields for Meta Conversion API (SHA256)
|
||||
hashed_email = Column(String(64))
|
||||
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(timezone=True))
|
||||
|
||||
reservations = relationship("Reservation", back_populates="customer")
|
||||
|
||||
def __repr__(self):
|
||||
@@ -335,53 +349,19 @@ class Customer(Base):
|
||||
# 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),
|
||||
)
|
||||
def update_hashed_fields(self):
|
||||
"""Update the hashed fields based on current plaintext values."""
|
||||
self.hashed_email = self._normalize_and_hash(self.email_address)
|
||||
self.hashed_phone = self._normalize_and_hash(self.phone)
|
||||
self.hashed_given_name = self._normalize_and_hash(self.given_name)
|
||||
self.hashed_surname = self._normalize_and_hash(self.surname)
|
||||
self.hashed_city = self._normalize_and_hash(self.city_name)
|
||||
self.hashed_postal_code = self._normalize_and_hash(self.postal_code)
|
||||
self.hashed_country_code = self._normalize_and_hash(self.country_code)
|
||||
self.hashed_gender = self._normalize_and_hash(self.gender)
|
||||
self.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", ondelete="SET NULL"),
|
||||
unique=True,
|
||||
nullable=True,
|
||||
)
|
||||
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(timezone=True))
|
||||
|
||||
customer = relationship(
|
||||
"Customer", backref=backref("hashed_version", uselist=False, lazy="joined")
|
||||
)
|
||||
|
||||
|
||||
class ConversionGuest(Base):
|
||||
@@ -420,23 +400,30 @@ class ConversionGuest(Base):
|
||||
hashed_country_code = Column(String(64))
|
||||
hashed_birth_date = Column(String(64))
|
||||
|
||||
# Matched customer reference (nullable, filled after matching)
|
||||
hashed_customer_id = Column(
|
||||
Integer, ForeignKey("hashed_customers.id"), nullable=True, index=True
|
||||
)
|
||||
|
||||
|
||||
# Guest classification
|
||||
is_regular = Column(
|
||||
Boolean, default=False
|
||||
) # True if guest has many prior stays before appearing in our reservations
|
||||
# Guest classification
|
||||
is_awareness_guest = Column(
|
||||
Boolean, default=False
|
||||
) # True if guests first stay was from our campaigns
|
||||
|
||||
|
||||
# Metadata
|
||||
first_seen = Column(DateTime(timezone=True))
|
||||
last_seen = Column(DateTime(timezone=True))
|
||||
|
||||
# Relationships
|
||||
conversions = relationship("Conversion", back_populates="guest")
|
||||
hashed_customer = relationship("HashedCustomer", backref="conversion_guests")
|
||||
conversions = relationship(
|
||||
"Conversion",
|
||||
back_populates="guest",
|
||||
foreign_keys="[Conversion.hotel_id, Conversion.guest_id]",
|
||||
primaryjoin="and_(ConversionGuest.hotel_id == foreign(Conversion.hotel_id), "
|
||||
"ConversionGuest.guest_id == foreign(Conversion.guest_id))",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_and_hash(value):
|
||||
@@ -517,9 +504,6 @@ class Reservation(Base):
|
||||
__tablename__ = "reservations"
|
||||
id = Column(Integer, primary_key=True)
|
||||
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL"))
|
||||
hashed_customer_id = Column(
|
||||
Integer, ForeignKey("hashed_customers.id", ondelete="CASCADE")
|
||||
)
|
||||
unique_id = Column(String, unique=True)
|
||||
md5_unique_id = Column(String(32), unique=True) # max length 32 guaranteed
|
||||
start_date = Column(Date)
|
||||
@@ -541,15 +525,14 @@ class Reservation(Base):
|
||||
# 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
|
||||
hotel_code = Column(String)
|
||||
# Add hotel_id and hotel_name for XML
|
||||
hotel_id = Column(String, ForeignKey("hotels.hotel_id", ondelete="CASCADE"))
|
||||
hotel_name = Column(String)
|
||||
# RoomTypes fields (optional)
|
||||
room_type_code = Column(String)
|
||||
room_classification_code = Column(String)
|
||||
room_type = Column(String)
|
||||
customer = relationship("Customer", back_populates="reservations")
|
||||
hashed_customer = relationship("HashedCustomer", backref="reservations")
|
||||
|
||||
|
||||
# Table for tracking acknowledged requests by client
|
||||
@@ -569,7 +552,7 @@ class AckedRequest(Base):
|
||||
) # Username of the client making the request
|
||||
unique_id = Column(
|
||||
String, index=True
|
||||
) # Should match Reservation.form_id or another unique field
|
||||
) # Matches the md5_unique_id in Reservation
|
||||
timestamp = Column(DateTime(timezone=True))
|
||||
|
||||
|
||||
@@ -601,9 +584,6 @@ class Conversion(Base):
|
||||
Integer, ForeignKey("reservations.id"), nullable=True, index=True
|
||||
)
|
||||
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=True, index=True)
|
||||
hashed_customer_id = Column(
|
||||
Integer, ForeignKey("hashed_customers.id"), nullable=True, index=True
|
||||
)
|
||||
|
||||
# Reservation metadata from XML
|
||||
hotel_id = Column(
|
||||
@@ -646,23 +626,28 @@ class Conversion(Base):
|
||||
created_at = Column(DateTime(timezone=True)) # When this record was imported
|
||||
updated_at = Column(DateTime(timezone=True)) # When this record was last updated
|
||||
|
||||
# Composite foreign key constraint for ConversionGuest (hotel_id, guest_id)
|
||||
# Table constraints
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"hotel_id", "pms_reservation_id", name="uq_conversion_hotel_reservation"
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["hotel_id", "guest_id"],
|
||||
["conversion_guests.hotel_id", "conversion_guests.guest_id"],
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"hotel_id", "pms_reservation_id", name="uq_conversion_hotel_reservation"
|
||||
name="fk_conversions_guest",
|
||||
),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
reservation = relationship("Reservation", backref="conversions")
|
||||
customer = relationship("Customer", backref="conversions")
|
||||
hashed_customer = relationship("HashedCustomer", backref="conversions")
|
||||
guest = relationship("ConversionGuest", back_populates="conversions")
|
||||
guest = relationship(
|
||||
"ConversionGuest",
|
||||
back_populates="conversions",
|
||||
foreign_keys="[Conversion.hotel_id, Conversion.guest_id]",
|
||||
primaryjoin="and_(Conversion.hotel_id == ConversionGuest.hotel_id, "
|
||||
"Conversion.guest_id == ConversionGuest.guest_id)",
|
||||
)
|
||||
conversion_rooms = relationship(
|
||||
"ConversionRoom", back_populates="conversion", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
@@ -115,7 +115,7 @@ async def backfill_advertising_account_ids(
|
||||
sql = text(
|
||||
"UPDATE reservations "
|
||||
"SET meta_account_id = :meta_account "
|
||||
"WHERE hotel_code = :hotel_id "
|
||||
"WHERE hotel_id = :hotel_id "
|
||||
"AND fbclid IS NOT NULL "
|
||||
"AND fbclid != '' "
|
||||
"AND (meta_account_id IS NULL OR meta_account_id = '')"
|
||||
@@ -141,7 +141,7 @@ async def backfill_advertising_account_ids(
|
||||
sql = text(
|
||||
"UPDATE reservations "
|
||||
"SET google_account_id = :google_account "
|
||||
"WHERE hotel_code = :hotel_id "
|
||||
"WHERE hotel_id = :hotel_id "
|
||||
"AND gclid IS NOT NULL "
|
||||
"AND gclid != '' "
|
||||
"AND (google_account_id IS NULL OR google_account_id = '')"
|
||||
@@ -215,7 +215,7 @@ async def backfill_acked_requests_username(
|
||||
UPDATE acked_requests
|
||||
SET username = :username
|
||||
WHERE unique_id IN (
|
||||
SELECT md5_unique_id FROM reservations WHERE hotel_code = :hotel_id
|
||||
SELECT md5_unique_id FROM reservations WHERE hotel_id = :hotel_id
|
||||
)
|
||||
AND username IS NULL
|
||||
"""
|
||||
|
||||
@@ -523,10 +523,10 @@ class ReservationStatsCollector:
|
||||
async with self.async_sessionmaker() as session:
|
||||
# Query reservations created in the reporting period
|
||||
result = await session.execute(
|
||||
select(Reservation.hotel_code, func.count(Reservation.id))
|
||||
select(Reservation.hotel_id, func.count(Reservation.id))
|
||||
.where(Reservation.created_at >= period_start)
|
||||
.where(Reservation.created_at < period_end)
|
||||
.group_by(Reservation.hotel_code)
|
||||
.group_by(Reservation.hotel_id)
|
||||
)
|
||||
|
||||
hotel_counts = dict(result.all())
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Optional
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .db import AckedRequest, Customer, HashedCustomer, Reservation
|
||||
from .db import AckedRequest, Customer, Reservation
|
||||
from .schemas import ReservationData
|
||||
|
||||
|
||||
@@ -64,17 +64,6 @@ class ReservationService:
|
||||
reservation_data, customer_id
|
||||
)
|
||||
|
||||
# Automatically populate hashed_customer_id from the customer
|
||||
# Since hashed_customer is always created when a customer is created,
|
||||
# we can get it by querying for the hashed_customer with matching customer_id
|
||||
hashed_customer_result = await self.session.execute(
|
||||
select(HashedCustomer).where(
|
||||
HashedCustomer.customer_id == customer_id
|
||||
)
|
||||
)
|
||||
hashed_customer = hashed_customer_result.scalar_one_or_none()
|
||||
if hashed_customer:
|
||||
reservation.hashed_customer_id = hashed_customer.id
|
||||
|
||||
self.session.add(reservation)
|
||||
|
||||
@@ -181,7 +170,7 @@ class ReservationService:
|
||||
if end_date:
|
||||
filters.append(Reservation.created_at <= end_date)
|
||||
if hotel_code:
|
||||
filters.append(Reservation.hotel_code == hotel_code)
|
||||
filters.append(Reservation.hotel_id == hotel_code)
|
||||
|
||||
if filters:
|
||||
query = query.where(and_(*filters))
|
||||
|
||||
@@ -131,7 +131,7 @@ class ReservationData(BaseModel):
|
||||
num_adults: int = Field(..., ge=1)
|
||||
num_children: int = Field(0, ge=0, le=10)
|
||||
children_ages: list[int] = Field(default_factory=list)
|
||||
hotel_code: str = Field(..., min_length=1, max_length=50)
|
||||
hotel_id: str = Field(..., min_length=1, max_length=50)
|
||||
hotel_name: str | None = Field(None, max_length=200)
|
||||
offer: str | None = Field(None, max_length=500)
|
||||
user_comment: str | None = Field(None, max_length=2000)
|
||||
@@ -562,7 +562,6 @@ class ConversionData(BaseModel):
|
||||
# Foreign key references (nullable - matched after creation)
|
||||
reservation_id: int | None = Field(None, gt=0)
|
||||
customer_id: int | None = Field(None, gt=0)
|
||||
hashed_customer_id: int | None = Field(None, gt=0)
|
||||
|
||||
# Required reservation metadata from PMS
|
||||
hotel_id: str = Field(..., min_length=1, max_length=50)
|
||||
@@ -591,7 +590,7 @@ class ConversionData(BaseModel):
|
||||
|
||||
@field_validator(
|
||||
"pms_reservation_id", "guest_id", "reservation_id", "customer_id",
|
||||
"hashed_customer_id", mode="before"
|
||||
mode="before"
|
||||
)
|
||||
@classmethod
|
||||
def convert_int_fields(cls, v: Any) -> int | None:
|
||||
|
||||
@@ -51,7 +51,6 @@ from alpine_bits_python.db import (
|
||||
AckedRequest,
|
||||
Base,
|
||||
Customer,
|
||||
HashedCustomer,
|
||||
Reservation,
|
||||
get_database_url,
|
||||
)
|
||||
@@ -306,7 +305,7 @@ async def migrate_data(
|
||||
user_comment=reservation.user_comment,
|
||||
fbclid=reservation.fbclid,
|
||||
gclid=reservation.gclid,
|
||||
hotel_code=reservation.hotel_code,
|
||||
hotel_code=reservation.hotel_id,
|
||||
hotel_name=reservation.hotel_name,
|
||||
room_type_code=reservation.room_type_code,
|
||||
room_classification_code=reservation.room_classification_code,
|
||||
|
||||
@@ -203,7 +203,7 @@ async def process_wix_form_submission(
|
||||
"name_title": None,
|
||||
}
|
||||
|
||||
# This automatically creates/updates both Customer and HashedCustomer
|
||||
# This automatically creates/updates Customer
|
||||
db_customer = await customer_service.get_or_create_customer(customer_data)
|
||||
|
||||
# Determine hotel_code and hotel_name
|
||||
@@ -247,7 +247,7 @@ async def process_wix_form_submission(
|
||||
num_adults=num_adults,
|
||||
num_children=num_children,
|
||||
children_ages=children_ages,
|
||||
hotel_code=hotel_code,
|
||||
hotel_id=hotel_code,
|
||||
hotel_name=hotel_name,
|
||||
offer=offer,
|
||||
created_at=submissionTime,
|
||||
@@ -575,7 +575,7 @@ async def process_generic_webhook_submission(
|
||||
"num_adults": num_adults,
|
||||
"num_children": num_children,
|
||||
"children_ages": children_ages,
|
||||
"hotel_code": hotel_code,
|
||||
"hotel_id": hotel_code,
|
||||
"hotel_name": hotel_name,
|
||||
"offer": selected_offers_str,
|
||||
"utm_source": utm_source,
|
||||
|
||||
Reference in New Issue
Block a user