diff --git a/alembic/versions/r6f7a8b9c0d1_add_country_iso_to_addresses.py b/alembic/versions/r6f7a8b9c0d1_add_country_iso_to_addresses.py new file mode 100644 index 00000000..5ba7916e --- /dev/null +++ b/alembic/versions/r6f7a8b9c0d1_add_country_iso_to_addresses.py @@ -0,0 +1,141 @@ +"""Add country_iso to customer_addresses + +Revision ID: r6f7a8b9c0d1 +Revises: q5e6f7a8b9c0 +Create Date: 2026-01-02 + +Adds country_iso field to customer_addresses table and renames +country to country_name for clarity. + +This migration is idempotent - it checks for existing columns before +making changes. +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "r6f7a8b9c0d1" +down_revision = "q5e6f7a8b9c0" +branch_labels = None +depends_on = None + + +# Country name to ISO code mapping for backfill +COUNTRY_ISO_MAP = { + "Luxembourg": "LU", + "Germany": "DE", + "France": "FR", + "Belgium": "BE", + "Netherlands": "NL", + "Austria": "AT", + "Italy": "IT", + "Spain": "ES", + "Portugal": "PT", + "Poland": "PL", + "Czech Republic": "CZ", + "Czechia": "CZ", + "Slovakia": "SK", + "Hungary": "HU", + "Romania": "RO", + "Bulgaria": "BG", + "Greece": "GR", + "Croatia": "HR", + "Slovenia": "SI", + "Estonia": "EE", + "Latvia": "LV", + "Lithuania": "LT", + "Finland": "FI", + "Sweden": "SE", + "Denmark": "DK", + "Ireland": "IE", + "Cyprus": "CY", + "Malta": "MT", + "United Kingdom": "GB", + "Switzerland": "CH", + "United States": "US", +} + + +def get_column_names(connection, table_name): + """Get list of column names for a table.""" + result = connection.execute(sa.text(f"PRAGMA table_info({table_name})")) + return [row[1] for row in result] + + +def upgrade() -> None: + connection = op.get_bind() + columns = get_column_names(connection, "customer_addresses") + + # Check if we need to do anything (idempotent check) + has_country = "country" in columns + has_country_name = "country_name" in columns + has_country_iso = "country_iso" in columns + + # If already has new columns, nothing to do + if has_country_name and has_country_iso: + print(" Columns country_name and country_iso already exist, skipping") + return + + # If has old 'country' column, rename it and add country_iso + if has_country and not has_country_name: + with op.batch_alter_table("customer_addresses") as batch_op: + batch_op.alter_column( + "country", + new_column_name="country_name", + ) + + # Add country_iso if it doesn't exist + if not has_country_iso: + with op.batch_alter_table("customer_addresses") as batch_op: + batch_op.add_column( + sa.Column("country_iso", sa.String(5), nullable=True) + ) + + # Backfill country_iso from country_name + for country_name, iso_code in COUNTRY_ISO_MAP.items(): + connection.execute( + sa.text( + "UPDATE customer_addresses SET country_iso = :iso " + "WHERE country_name = :name" + ), + {"iso": iso_code, "name": country_name}, + ) + + # Set default for any remaining NULL values + connection.execute( + sa.text( + "UPDATE customer_addresses SET country_iso = 'LU' " + "WHERE country_iso IS NULL" + ) + ) + + # Make country_iso NOT NULL using batch operation + with op.batch_alter_table("customer_addresses") as batch_op: + batch_op.alter_column( + "country_iso", + existing_type=sa.String(5), + nullable=False, + ) + + +def downgrade() -> None: + connection = op.get_bind() + columns = get_column_names(connection, "customer_addresses") + + has_country_name = "country_name" in columns + has_country_iso = "country_iso" in columns + has_country = "country" in columns + + # Only downgrade if in the new state + if has_country_name and not has_country: + with op.batch_alter_table("customer_addresses") as batch_op: + batch_op.alter_column( + "country_name", + new_column_name="country", + ) + + if has_country_iso: + with op.batch_alter_table("customer_addresses") as batch_op: + batch_op.drop_column("country_iso") diff --git a/app/api/v1/shop/__init__.py b/app/api/v1/shop/__init__.py index be4259b2..60d7ce84 100644 --- a/app/api/v1/shop/__init__.py +++ b/app/api/v1/shop/__init__.py @@ -21,7 +21,7 @@ Authentication: from fastapi import APIRouter # Import shop routers -from . import auth, carts, content_pages, messages, orders, products +from . import addresses, auth, carts, content_pages, messages, orders, products # Create shop router router = APIRouter() @@ -30,6 +30,9 @@ router = APIRouter() # SHOP API ROUTES (All vendor-context aware via middleware) # ============================================================================ +# Addresses (authenticated) +router.include_router(addresses.router, tags=["shop-addresses"]) + # Authentication (public) router.include_router(auth.router, tags=["shop-auth"]) diff --git a/app/api/v1/shop/addresses.py b/app/api/v1/shop/addresses.py new file mode 100644 index 00000000..76a78adc --- /dev/null +++ b/app/api/v1/shop/addresses.py @@ -0,0 +1,269 @@ +# app/api/v1/shop/addresses.py +""" +Shop Addresses API (Customer authenticated) + +Endpoints for managing customer addresses in shop frontend. +Uses vendor from request.state (injected by VendorContextMiddleware). +Requires customer authentication. +""" + +import logging + +from fastapi import APIRouter, Depends, Path, Request +from sqlalchemy.orm import Session + +from app.api.deps import get_current_customer_api +from app.core.database import get_db +from app.exceptions import VendorNotFoundException +from app.services.customer_address_service import customer_address_service +from models.database.customer import Customer +from models.schema.customer import ( + CustomerAddressCreate, + CustomerAddressListResponse, + CustomerAddressResponse, + CustomerAddressUpdate, +) + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.get("/addresses", response_model=CustomerAddressListResponse) +def list_addresses( + request: Request, + customer: Customer = Depends(get_current_customer_api), + db: Session = Depends(get_db), +): + """ + List all addresses for authenticated customer. + + Vendor is automatically determined from request context. + Returns all addresses sorted by default first, then by creation date. + """ + vendor = getattr(request.state, "vendor", None) + if not vendor: + raise VendorNotFoundException("context", identifier_type="subdomain") + + logger.debug( + f"[SHOP_API] list_addresses for customer {customer.id}", + extra={ + "vendor_id": vendor.id, + "vendor_code": vendor.subdomain, + "customer_id": customer.id, + }, + ) + + addresses = customer_address_service.list_addresses( + db=db, vendor_id=vendor.id, customer_id=customer.id + ) + + return CustomerAddressListResponse( + addresses=[CustomerAddressResponse.model_validate(a) for a in addresses], + total=len(addresses), + ) + + +@router.get("/addresses/{address_id}", response_model=CustomerAddressResponse) +def get_address( + request: Request, + address_id: int = Path(..., description="Address ID", gt=0), + customer: Customer = Depends(get_current_customer_api), + db: Session = Depends(get_db), +): + """ + Get specific address by ID. + + Vendor is automatically determined from request context. + Customer can only access their own addresses. + """ + vendor = getattr(request.state, "vendor", None) + if not vendor: + raise VendorNotFoundException("context", identifier_type="subdomain") + + logger.debug( + f"[SHOP_API] get_address {address_id} for customer {customer.id}", + extra={ + "vendor_id": vendor.id, + "customer_id": customer.id, + "address_id": address_id, + }, + ) + + address = customer_address_service.get_address( + db=db, vendor_id=vendor.id, customer_id=customer.id, address_id=address_id + ) + + return CustomerAddressResponse.model_validate(address) + + +@router.post("/addresses", response_model=CustomerAddressResponse, status_code=201) +def create_address( + request: Request, + address_data: CustomerAddressCreate, + customer: Customer = Depends(get_current_customer_api), + db: Session = Depends(get_db), +): + """ + Create new address for authenticated customer. + + Vendor is automatically determined from request context. + Maximum 10 addresses per customer. + If is_default=True, clears default flag on other addresses of same type. + """ + vendor = getattr(request.state, "vendor", None) + if not vendor: + raise VendorNotFoundException("context", identifier_type="subdomain") + + logger.debug( + f"[SHOP_API] create_address for customer {customer.id}", + extra={ + "vendor_id": vendor.id, + "customer_id": customer.id, + "address_type": address_data.address_type, + }, + ) + + address = customer_address_service.create_address( + db=db, + vendor_id=vendor.id, + customer_id=customer.id, + address_data=address_data, + ) + db.commit() + + logger.info( + f"Created address {address.id} for customer {customer.id} " + f"(type={address_data.address_type})", + extra={ + "address_id": address.id, + "customer_id": customer.id, + "address_type": address_data.address_type, + }, + ) + + return CustomerAddressResponse.model_validate(address) + + +@router.put("/addresses/{address_id}", response_model=CustomerAddressResponse) +def update_address( + request: Request, + address_data: CustomerAddressUpdate, + address_id: int = Path(..., description="Address ID", gt=0), + customer: Customer = Depends(get_current_customer_api), + db: Session = Depends(get_db), +): + """ + Update existing address. + + Vendor is automatically determined from request context. + Customer can only update their own addresses. + If is_default=True, clears default flag on other addresses of same type. + """ + vendor = getattr(request.state, "vendor", None) + if not vendor: + raise VendorNotFoundException("context", identifier_type="subdomain") + + logger.debug( + f"[SHOP_API] update_address {address_id} for customer {customer.id}", + extra={ + "vendor_id": vendor.id, + "customer_id": customer.id, + "address_id": address_id, + }, + ) + + address = customer_address_service.update_address( + db=db, + vendor_id=vendor.id, + customer_id=customer.id, + address_id=address_id, + address_data=address_data, + ) + db.commit() + + logger.info( + f"Updated address {address_id} for customer {customer.id}", + extra={"address_id": address_id, "customer_id": customer.id}, + ) + + return CustomerAddressResponse.model_validate(address) + + +@router.delete("/addresses/{address_id}", status_code=204) +def delete_address( + request: Request, + address_id: int = Path(..., description="Address ID", gt=0), + customer: Customer = Depends(get_current_customer_api), + db: Session = Depends(get_db), +): + """ + Delete address. + + Vendor is automatically determined from request context. + Customer can only delete their own addresses. + """ + vendor = getattr(request.state, "vendor", None) + if not vendor: + raise VendorNotFoundException("context", identifier_type="subdomain") + + logger.debug( + f"[SHOP_API] delete_address {address_id} for customer {customer.id}", + extra={ + "vendor_id": vendor.id, + "customer_id": customer.id, + "address_id": address_id, + }, + ) + + customer_address_service.delete_address( + db=db, vendor_id=vendor.id, customer_id=customer.id, address_id=address_id + ) + db.commit() + + logger.info( + f"Deleted address {address_id} for customer {customer.id}", + extra={"address_id": address_id, "customer_id": customer.id}, + ) + + +@router.put("/addresses/{address_id}/default", response_model=CustomerAddressResponse) +def set_address_default( + request: Request, + address_id: int = Path(..., description="Address ID", gt=0), + customer: Customer = Depends(get_current_customer_api), + db: Session = Depends(get_db), +): + """ + Set address as default for its type. + + Vendor is automatically determined from request context. + Clears default flag on other addresses of the same type. + """ + vendor = getattr(request.state, "vendor", None) + if not vendor: + raise VendorNotFoundException("context", identifier_type="subdomain") + + logger.debug( + f"[SHOP_API] set_address_default {address_id} for customer {customer.id}", + extra={ + "vendor_id": vendor.id, + "customer_id": customer.id, + "address_id": address_id, + }, + ) + + address = customer_address_service.set_default( + db=db, vendor_id=vendor.id, customer_id=customer.id, address_id=address_id + ) + db.commit() + + logger.info( + f"Set address {address_id} as default for customer {customer.id}", + extra={ + "address_id": address_id, + "customer_id": customer.id, + "address_type": address.address_type, + }, + ) + + return CustomerAddressResponse.model_validate(address) diff --git a/app/exceptions/__init__.py b/app/exceptions/__init__.py index ebb4e8b7..8656b180 100644 --- a/app/exceptions/__init__.py +++ b/app/exceptions/__init__.py @@ -6,6 +6,13 @@ This module provides frontend-friendly exceptions with consistent error codes, messages, and HTTP status mappings. """ +# Address exceptions +from .address import ( + AddressLimitExceededException, + AddressNotFoundException, + InvalidAddressTypeException, +) + # Admin exceptions from .admin import ( AdminOperationException, diff --git a/app/exceptions/address.py b/app/exceptions/address.py new file mode 100644 index 00000000..188f8ef1 --- /dev/null +++ b/app/exceptions/address.py @@ -0,0 +1,38 @@ +# app/exceptions/address.py +""" +Address-related custom exceptions. + +Used for customer address management operations. +""" + +from .base import BusinessLogicException, ResourceNotFoundException + + +class AddressNotFoundException(ResourceNotFoundException): + """Raised when a customer address is not found.""" + + def __init__(self, address_id: str | int): + super().__init__( + resource_type="Address", + identifier=str(address_id), + ) + + +class AddressLimitExceededException(BusinessLogicException): + """Raised when customer exceeds maximum number of addresses.""" + + def __init__(self, max_addresses: int = 10): + super().__init__( + message=f"Maximum number of addresses ({max_addresses}) reached", + error_code="ADDRESS_LIMIT_EXCEEDED", + ) + + +class InvalidAddressTypeException(BusinessLogicException): + """Raised when an invalid address type is provided.""" + + def __init__(self, address_type: str): + super().__init__( + message=f"Invalid address type '{address_type}'. Must be 'shipping' or 'billing'", + error_code="INVALID_ADDRESS_TYPE", + ) diff --git a/app/services/customer_address_service.py b/app/services/customer_address_service.py new file mode 100644 index 00000000..a1548b7c --- /dev/null +++ b/app/services/customer_address_service.py @@ -0,0 +1,314 @@ +# app/services/customer_address_service.py +""" +Customer Address Service + +Business logic for managing customer addresses with vendor isolation. +""" + +import logging + +from sqlalchemy.orm import Session + +from app.exceptions import ( + AddressLimitExceededException, + AddressNotFoundException, +) +from models.database.customer import CustomerAddress +from models.schema.customer import CustomerAddressCreate, CustomerAddressUpdate + +logger = logging.getLogger(__name__) + + +class CustomerAddressService: + """Service for managing customer addresses with vendor isolation.""" + + MAX_ADDRESSES_PER_CUSTOMER = 10 + + def list_addresses( + self, db: Session, vendor_id: int, customer_id: int + ) -> list[CustomerAddress]: + """ + Get all addresses for a customer. + + Args: + db: Database session + vendor_id: Vendor ID for isolation + customer_id: Customer ID + + Returns: + List of customer addresses + """ + return ( + db.query(CustomerAddress) + .filter( + CustomerAddress.vendor_id == vendor_id, + CustomerAddress.customer_id == customer_id, + ) + .order_by(CustomerAddress.is_default.desc(), CustomerAddress.created_at.desc()) + .all() + ) + + def get_address( + self, db: Session, vendor_id: int, customer_id: int, address_id: int + ) -> CustomerAddress: + """ + Get a specific address with ownership validation. + + Args: + db: Database session + vendor_id: Vendor ID for isolation + customer_id: Customer ID + address_id: Address ID + + Returns: + Customer address + + Raises: + AddressNotFoundException: If address not found or doesn't belong to customer + """ + address = ( + db.query(CustomerAddress) + .filter( + CustomerAddress.id == address_id, + CustomerAddress.vendor_id == vendor_id, + CustomerAddress.customer_id == customer_id, + ) + .first() + ) + + if not address: + raise AddressNotFoundException(address_id) + + return address + + def get_default_address( + self, db: Session, vendor_id: int, customer_id: int, address_type: str + ) -> CustomerAddress | None: + """ + Get the default address for a specific type. + + Args: + db: Database session + vendor_id: Vendor ID for isolation + customer_id: Customer ID + address_type: 'shipping' or 'billing' + + Returns: + Default address or None if not set + """ + return ( + db.query(CustomerAddress) + .filter( + CustomerAddress.vendor_id == vendor_id, + CustomerAddress.customer_id == customer_id, + CustomerAddress.address_type == address_type, + CustomerAddress.is_default == True, # noqa: E712 + ) + .first() + ) + + def create_address( + self, + db: Session, + vendor_id: int, + customer_id: int, + address_data: CustomerAddressCreate, + ) -> CustomerAddress: + """ + Create a new address for a customer. + + Args: + db: Database session + vendor_id: Vendor ID for isolation + customer_id: Customer ID + address_data: Address creation data + + Returns: + Created customer address + + Raises: + AddressLimitExceededException: If max addresses reached + """ + # Check address limit + current_count = ( + db.query(CustomerAddress) + .filter( + CustomerAddress.vendor_id == vendor_id, + CustomerAddress.customer_id == customer_id, + ) + .count() + ) + + if current_count >= self.MAX_ADDRESSES_PER_CUSTOMER: + raise AddressLimitExceededException(self.MAX_ADDRESSES_PER_CUSTOMER) + + # If setting as default, clear other defaults of same type + if address_data.is_default: + self._clear_other_defaults( + db, vendor_id, customer_id, address_data.address_type + ) + + # Create the address + address = CustomerAddress( + vendor_id=vendor_id, + customer_id=customer_id, + address_type=address_data.address_type, + first_name=address_data.first_name, + last_name=address_data.last_name, + company=address_data.company, + address_line_1=address_data.address_line_1, + address_line_2=address_data.address_line_2, + city=address_data.city, + postal_code=address_data.postal_code, + country_name=address_data.country_name, + country_iso=address_data.country_iso, + is_default=address_data.is_default, + ) + + db.add(address) + db.flush() + + logger.info( + f"Created address {address.id} for customer {customer_id} " + f"(type={address_data.address_type}, default={address_data.is_default})" + ) + + return address + + def update_address( + self, + db: Session, + vendor_id: int, + customer_id: int, + address_id: int, + address_data: CustomerAddressUpdate, + ) -> CustomerAddress: + """ + Update an existing address. + + Args: + db: Database session + vendor_id: Vendor ID for isolation + customer_id: Customer ID + address_id: Address ID + address_data: Address update data + + Returns: + Updated customer address + + Raises: + AddressNotFoundException: If address not found + """ + address = self.get_address(db, vendor_id, customer_id, address_id) + + # Update only provided fields + update_data = address_data.model_dump(exclude_unset=True) + + # Handle default flag - clear others if setting to default + if update_data.get("is_default") is True: + # Use updated type if provided, otherwise current type + address_type = update_data.get("address_type", address.address_type) + self._clear_other_defaults( + db, vendor_id, customer_id, address_type, exclude_id=address_id + ) + + for field, value in update_data.items(): + setattr(address, field, value) + + db.flush() + + logger.info(f"Updated address {address_id} for customer {customer_id}") + + return address + + def delete_address( + self, db: Session, vendor_id: int, customer_id: int, address_id: int + ) -> None: + """ + Delete an address. + + Args: + db: Database session + vendor_id: Vendor ID for isolation + customer_id: Customer ID + address_id: Address ID + + Raises: + AddressNotFoundException: If address not found + """ + address = self.get_address(db, vendor_id, customer_id, address_id) + + db.delete(address) + db.flush() + + logger.info(f"Deleted address {address_id} for customer {customer_id}") + + def set_default( + self, db: Session, vendor_id: int, customer_id: int, address_id: int + ) -> CustomerAddress: + """ + Set an address as the default for its type. + + Args: + db: Database session + vendor_id: Vendor ID for isolation + customer_id: Customer ID + address_id: Address ID + + Returns: + Updated customer address + + Raises: + AddressNotFoundException: If address not found + """ + address = self.get_address(db, vendor_id, customer_id, address_id) + + # Clear other defaults of same type + self._clear_other_defaults( + db, vendor_id, customer_id, address.address_type, exclude_id=address_id + ) + + # Set this one as default + address.is_default = True + db.flush() + + logger.info( + f"Set address {address_id} as default {address.address_type} " + f"for customer {customer_id}" + ) + + return address + + def _clear_other_defaults( + self, + db: Session, + vendor_id: int, + customer_id: int, + address_type: str, + exclude_id: int | None = None, + ) -> None: + """ + Clear the default flag on other addresses of the same type. + + Args: + db: Database session + vendor_id: Vendor ID for isolation + customer_id: Customer ID + address_type: 'shipping' or 'billing' + exclude_id: Address ID to exclude from clearing + """ + query = db.query(CustomerAddress).filter( + CustomerAddress.vendor_id == vendor_id, + CustomerAddress.customer_id == customer_id, + CustomerAddress.address_type == address_type, + CustomerAddress.is_default == True, # noqa: E712 + ) + + if exclude_id: + query = query.filter(CustomerAddress.id != exclude_id) + + query.update({"is_default": False}, synchronize_session=False) + + +# Singleton instance +customer_address_service = CustomerAddressService() diff --git a/app/templates/shop/account/addresses.html b/app/templates/shop/account/addresses.html index 6c4d4326..4f060592 100644 --- a/app/templates/shop/account/addresses.html +++ b/app/templates/shop/account/addresses.html @@ -1,15 +1,562 @@ {# app/templates/shop/account/addresses.html #} {% extends "shop/base.html" %} -{% block title %}My Addresses{% endblock %} +{% block title %}My Addresses - {{ vendor.name }}{% endblock %} + +{% block alpine_data %}addressesPage(){% endblock %} {% block content %}
Manage your shipping and billing addresses
+Address management coming soon...
+ +Add your first address to speed up checkout.
+ ++ Are you sure you want to delete this address? This action cannot be undone. +
+Checkout process coming soon...
+ {# Breadcrumbs #} + + + {# Page Header #} +