feat(vendor): add contact info inheritance from company

Vendors can now override company contact information for specific branding.
Fields are nullable - if null, value is inherited from parent company.

Database changes:
- Add vendor.contact_email, contact_phone, website, business_address, tax_number
- All nullable (null = inherit from company)
- Alembic migration: 28d44d503cac

Model changes:
- Add effective_* properties for resolved values
- Add get_contact_info_with_inheritance() helper

Schema changes:
- VendorCreate: Optional contact fields for override at creation
- VendorUpdate: Contact fields + reset_contact_to_company flag
- VendorDetailResponse: Resolved values + *_inherited flags

API changes:
- GET/PUT vendor endpoints return resolved contact info
- PUT accepts contact overrides (empty string = reset to inherit)
- _build_vendor_detail_response helper for consistent responses

Service changes:
- admin_service.update_vendor handles reset_contact_to_company flag
- Empty strings converted to None for inheritance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-03 22:30:31 +01:00
parent dd51df7b31
commit 846f92e7e4
6 changed files with 506 additions and 96 deletions

View File

@@ -0,0 +1,37 @@
"""add contact fields to vendor
Revision ID: 28d44d503cac
Revises: 9f3a25ea4991
Create Date: 2025-12-03 22:26:02.161087
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '28d44d503cac'
down_revision: Union[str, None] = '9f3a25ea4991'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add nullable contact fields to vendor table
# These allow vendor-specific branding/identity, overriding company defaults
op.add_column('vendors', sa.Column('contact_email', sa.String(255), nullable=True))
op.add_column('vendors', sa.Column('contact_phone', sa.String(50), nullable=True))
op.add_column('vendors', sa.Column('website', sa.String(255), nullable=True))
op.add_column('vendors', sa.Column('business_address', sa.Text(), nullable=True))
op.add_column('vendors', sa.Column('tax_number', sa.String(100), nullable=True))
def downgrade() -> None:
# Remove contact fields from vendor table
op.drop_column('vendors', 'tax_number')
op.drop_column('vendors', 'business_address')
op.drop_column('vendors', 'website')
op.drop_column('vendors', 'contact_phone')
op.drop_column('vendors', 'contact_email')

View File

@@ -116,23 +116,14 @@ def get_vendor_statistics_endpoint(
)
@router.get("/{vendor_identifier}", response_model=VendorDetailResponse)
def get_vendor_details(
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
def _build_vendor_detail_response(vendor) -> VendorDetailResponse:
"""
Get detailed vendor information including company and owner details (Admin only).
Helper to build VendorDetailResponse with resolved contact info.
Accepts either vendor ID (integer) or vendor_code (string).
Returns vendor info with company contact details and owner info.
Raises:
VendorNotFoundException: If vendor not found (404)
Contact fields are resolved using vendor override or company fallback.
Inheritance flags indicate if value comes from company.
"""
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
contact_info = vendor.get_contact_info_with_inheritance()
return VendorDetailResponse(
# Vendor fields
@@ -151,15 +142,39 @@ def get_vendor_details(
updated_at=vendor.updated_at,
# Company info
company_name=vendor.company.name,
company_contact_email=vendor.company.contact_email,
company_contact_phone=vendor.company.contact_phone,
company_website=vendor.company.website,
# Owner details (from company)
owner_email=vendor.company.owner.email,
owner_username=vendor.company.owner.username,
# Resolved contact info with inheritance flags
**contact_info,
# Original company values for UI reference
company_contact_email=vendor.company.contact_email,
company_contact_phone=vendor.company.contact_phone,
company_website=vendor.company.website,
)
@router.get("/{vendor_identifier}", response_model=VendorDetailResponse)
def get_vendor_details(
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get detailed vendor information including company and owner details (Admin only).
Accepts either vendor ID (integer) or vendor_code (string).
Returns vendor info with company contact details, owner info, and
resolved contact fields (vendor override or company default).
Raises:
VendorNotFoundException: If vendor not found (404)
"""
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
return _build_vendor_detail_response(vendor)
@router.put("/{vendor_identifier}", response_model=VendorDetailResponse)
def update_vendor(
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
@@ -176,40 +191,18 @@ def update_vendor(
- Basic info: name, description, subdomain
- Marketplace URLs
- Status: is_active, is_verified
- Contact info: contact_email, contact_phone, website, business_address, tax_number
(these override company defaults; set to empty to reset to inherit)
**Cannot update:**
- `vendor_code` (immutable)
- Business contact info (use company update endpoints)
Raises:
VendorNotFoundException: If vendor not found (404)
"""
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
vendor = admin_service.update_vendor(db, vendor.id, vendor_update)
return VendorDetailResponse(
id=vendor.id,
vendor_code=vendor.vendor_code,
subdomain=vendor.subdomain,
name=vendor.name,
description=vendor.description,
company_id=vendor.company_id,
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
is_active=vendor.is_active,
is_verified=vendor.is_verified,
created_at=vendor.created_at,
updated_at=vendor.updated_at,
# Company info
company_name=vendor.company.name,
company_contact_email=vendor.company.contact_email,
company_contact_phone=vendor.company.contact_phone,
company_website=vendor.company.website,
# Owner details (from company)
owner_email=vendor.company.owner.email,
owner_username=vendor.company.owner.username,
)
return _build_vendor_detail_response(vendor)
# NOTE: Ownership transfer is now at the Company level.
@@ -243,29 +236,7 @@ def toggle_vendor_verification(
db.commit() # ✅ ARCH: Commit at API level for transaction control
logger.info(f"Vendor verification updated: {message}")
return VendorDetailResponse(
id=vendor.id,
vendor_code=vendor.vendor_code,
subdomain=vendor.subdomain,
name=vendor.name,
description=vendor.description,
company_id=vendor.company_id,
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
is_active=vendor.is_active,
is_verified=vendor.is_verified,
created_at=vendor.created_at,
updated_at=vendor.updated_at,
# Company info
company_name=vendor.company.name,
company_contact_email=vendor.company.contact_email,
company_contact_phone=vendor.company.contact_phone,
company_website=vendor.company.website,
# Owner details (from company)
owner_email=vendor.company.owner.email,
owner_username=vendor.company.owner.username,
)
return _build_vendor_detail_response(vendor)
@router.put("/{vendor_identifier}/status", response_model=VendorDetailResponse)
@@ -294,29 +265,7 @@ def toggle_vendor_status(
db.commit() # ✅ ARCH: Commit at API level for transaction control
logger.info(f"Vendor status updated: {message}")
return VendorDetailResponse(
id=vendor.id,
vendor_code=vendor.vendor_code,
subdomain=vendor.subdomain,
name=vendor.name,
description=vendor.description,
company_id=vendor.company_id,
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
is_active=vendor.is_active,
is_verified=vendor.is_verified,
created_at=vendor.created_at,
updated_at=vendor.updated_at,
# Company info
company_name=vendor.company.name,
company_contact_email=vendor.company.contact_email,
company_contact_phone=vendor.company.contact_phone,
company_website=vendor.company.website,
# Owner details (from company)
owner_email=vendor.company.owner.email,
owner_username=vendor.company.owner.username,
)
return _build_vendor_detail_response(vendor)
@router.delete("/{vendor_identifier}")

View File

@@ -369,6 +369,21 @@ class AdminService:
# Get update data
update_data = vendor_update.model_dump(exclude_unset=True)
# Handle reset_contact_to_company flag
if update_data.pop("reset_contact_to_company", False):
# Reset all contact fields to None (inherit from company)
update_data["contact_email"] = None
update_data["contact_phone"] = None
update_data["website"] = None
update_data["business_address"] = None
update_data["tax_number"] = None
# Convert empty strings to None for contact fields (empty = inherit)
contact_fields = ["contact_email", "contact_phone", "website", "business_address", "tax_number"]
for field in contact_fields:
if field in update_data and update_data[field] == "":
update_data[field] = None
# Check subdomain uniqueness if changing
if (
"subdomain" in update_data

View File

@@ -0,0 +1,301 @@
# Vendor Contact Inheritance Migration
## Overview
**Feature:** Add contact information fields to Vendor model with inheritance from Company.
**Pattern:** Nullable with Fallback - Vendor fields are nullable; if null, inherit from parent company at read time.
**Benefits:**
- Vendors inherit company contact info by default
- Can override specific fields for vendor-specific branding/identity
- Can reset to "inherit from company" by setting field to null
- Company updates automatically reflect in vendors that haven't overridden
---
## Database Changes
### New Columns in `vendor` Table
| Column | Type | Nullable | Default | Description |
|--------|------|----------|---------|-------------|
| `contact_email` | VARCHAR(255) | Yes | NULL | Override company contact email |
| `contact_phone` | VARCHAR(50) | Yes | NULL | Override company contact phone |
| `website` | VARCHAR(255) | Yes | NULL | Override company website |
| `business_address` | TEXT | Yes | NULL | Override company business address |
| `tax_number` | VARCHAR(100) | Yes | NULL | Override company tax number |
### Resolution Logic
```
effective_value = vendor.field if vendor.field is not None else vendor.company.field
```
---
## Files to Modify
### 1. Database Model
- `models/database/vendor.py` - Add nullable contact fields
### 2. Alembic Migration
- `alembic/versions/xxx_add_vendor_contact_fields.py` - New migration
### 3. Pydantic Schemas
- `models/schema/vendor.py`:
- `VendorUpdate` - Add optional contact fields
- `VendorResponse` - Add resolved contact fields
- `VendorDetailResponse` - Add contact fields with inheritance indicator
### 4. Service Layer
- `app/services/vendor_service.py` - Add contact resolution helper
- `app/services/admin_service.py` - Update create/update to handle contact fields
### 5. API Endpoints
- `app/api/v1/admin/vendors.py` - Update responses to include resolved contact info
### 6. Frontend
- `app/templates/admin/vendor-edit.html` - Add contact fields with inheritance toggle
- `static/admin/js/vendor-edit.js` - Handle inheritance UI logic
---
## Implementation Steps
### Step 1: Database Model
```python
# models/database/vendor.py
class Vendor(Base):
# ... existing fields ...
# Contact fields (nullable = inherit from company)
contact_email = Column(String(255), nullable=True)
contact_phone = Column(String(50), nullable=True)
website = Column(String(255), nullable=True)
business_address = Column(Text, nullable=True)
tax_number = Column(String(100), nullable=True)
# Helper properties for resolved values
@property
def effective_contact_email(self) -> str | None:
return self.contact_email if self.contact_email is not None else (
self.company.contact_email if self.company else None
)
@property
def effective_contact_phone(self) -> str | None:
return self.contact_phone if self.contact_phone is not None else (
self.company.contact_phone if self.company else None
)
# ... similar for other fields ...
```
### Step 2: Alembic Migration
```python
def upgrade():
op.add_column('vendor', sa.Column('contact_email', sa.String(255), nullable=True))
op.add_column('vendor', sa.Column('contact_phone', sa.String(50), nullable=True))
op.add_column('vendor', sa.Column('website', sa.String(255), nullable=True))
op.add_column('vendor', sa.Column('business_address', sa.Text(), nullable=True))
op.add_column('vendor', sa.Column('tax_number', sa.String(100), nullable=True))
def downgrade():
op.drop_column('vendor', 'tax_number')
op.drop_column('vendor', 'business_address')
op.drop_column('vendor', 'website')
op.drop_column('vendor', 'contact_phone')
op.drop_column('vendor', 'contact_email')
```
### Step 3: Pydantic Schemas
```python
# models/schema/vendor.py
class VendorUpdate(BaseModel):
# ... existing fields ...
# Contact fields (None = don't update, empty string could mean "clear/inherit")
contact_email: str | None = None
contact_phone: str | None = None
website: str | None = None
business_address: str | None = None
tax_number: str | None = None
class VendorContactInfo(BaseModel):
"""Resolved contact information with inheritance indicators."""
contact_email: str | None
contact_phone: str | None
website: str | None
business_address: str | None
tax_number: str | None
# Inheritance flags
contact_email_inherited: bool = False
contact_phone_inherited: bool = False
website_inherited: bool = False
business_address_inherited: bool = False
tax_number_inherited: bool = False
class VendorDetailResponse(BaseModel):
# ... existing fields ...
# Resolved contact info
contact_email: str | None
contact_phone: str | None
website: str | None
business_address: str | None
tax_number: str | None
# Inheritance indicators (for UI)
contact_email_inherited: bool
contact_phone_inherited: bool
website_inherited: bool
business_address_inherited: bool
tax_number_inherited: bool
```
### Step 4: Service Layer Helper
```python
# app/services/vendor_service.py
def get_resolved_contact_info(self, vendor: Vendor) -> dict:
"""
Get resolved contact information with inheritance flags.
Returns dict with both values and flags indicating if inherited.
"""
company = vendor.company
return {
"contact_email": vendor.contact_email or (company.contact_email if company else None),
"contact_email_inherited": vendor.contact_email is None and company is not None,
"contact_phone": vendor.contact_phone or (company.contact_phone if company else None),
"contact_phone_inherited": vendor.contact_phone is None and company is not None,
"website": vendor.website or (company.website if company else None),
"website_inherited": vendor.website is None and company is not None,
"business_address": vendor.business_address or (company.business_address if company else None),
"business_address_inherited": vendor.business_address is None and company is not None,
"tax_number": vendor.tax_number or (company.tax_number if company else None),
"tax_number_inherited": vendor.tax_number is None and company is not None,
}
```
### Step 5: API Endpoint Updates
```python
# app/api/v1/admin/vendors.py
@router.get("/{vendor_identifier}", response_model=VendorDetailResponse)
def get_vendor_details(...):
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
contact_info = vendor_service.get_resolved_contact_info(vendor)
return VendorDetailResponse(
# ... existing fields ...
**contact_info, # Includes values and inheritance flags
)
```
### Step 6: Frontend UI
```html
<!-- Vendor edit form with inheritance toggle -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">
Contact Email
<span x-show="contactEmailInherited" class="text-xs text-purple-500">(inherited from company)</span>
</span>
<div class="flex gap-2">
<input type="email" x-model="formData.contact_email"
:placeholder="companyContactEmail"
:class="{ 'bg-gray-100': contactEmailInherited }">
<button type="button" @click="resetToCompany('contact_email')"
x-show="!contactEmailInherited"
class="text-sm text-purple-600">
Reset to Company
</button>
</div>
</label>
```
---
## API Behavior
### GET /api/v1/admin/vendors/{id}
Returns resolved contact info with inheritance flags:
```json
{
"id": 1,
"vendor_code": "VENDOR001",
"name": "My Vendor",
"contact_email": "sales@company.com",
"contact_email_inherited": true,
"contact_phone": "+352 123 456",
"contact_phone_inherited": false,
"website": "https://company.com",
"website_inherited": true
}
```
### PUT /api/v1/admin/vendors/{id}
To override a field:
```json
{
"contact_email": "vendor-specific@example.com"
}
```
To reset to inherit from company (set to null):
```json
{
"contact_email": null
}
```
---
## Testing Plan
1. **Create vendor** - Verify contact fields are null (inheriting)
2. **Read vendor** - Verify resolved values come from company
3. **Update vendor contact** - Verify override works
4. **Reset to inherit** - Verify setting null restores inheritance
5. **Update company** - Verify change reflects in inheriting vendors
6. **Update company** - Verify change does NOT affect overridden vendors
---
## Rollback Plan
If issues occur:
1. Run downgrade migration: `alembic downgrade -1`
2. Revert code changes
3. Re-deploy
---
## Progress Tracking
- [ ] Database model updated
- [ ] Alembic migration created and applied
- [ ] Pydantic schemas updated
- [ ] Service layer helper added
- [ ] API endpoints updated
- [ ] Frontend forms updated
- [ ] Tests written and passing
- [ ] Documentation updated

View File

@@ -63,6 +63,17 @@ class Vendor(Base, TimestampMixin):
Boolean, default=False
) # Boolean to indicate if the vendor brand is verified
# ========================================================================
# Contact Information (nullable = inherit from company)
# ========================================================================
# These fields allow vendor-specific branding/identity.
# If null, the value is inherited from the parent company.
contact_email = Column(String(255), nullable=True) # Override company contact email
contact_phone = Column(String(50), nullable=True) # Override company contact phone
website = Column(String(255), nullable=True) # Override company website
business_address = Column(Text, nullable=True) # Override company business address
tax_number = Column(String(100), nullable=True) # Override company tax number
# ========================================================================
# Relationships
# ========================================================================
@@ -202,6 +213,66 @@ class Vendor(Base, TimestampMixin):
domains.append(domain.domain) # Add other active custom domains
return domains
# ========================================================================
# Contact Resolution Helper Properties
# ========================================================================
# These properties return the effective value (vendor override or company fallback)
@property
def effective_contact_email(self) -> str | None:
"""Get contact email (vendor override or company fallback)."""
if self.contact_email is not None:
return self.contact_email
return self.company.contact_email if self.company else None
@property
def effective_contact_phone(self) -> str | None:
"""Get contact phone (vendor override or company fallback)."""
if self.contact_phone is not None:
return self.contact_phone
return self.company.contact_phone if self.company else None
@property
def effective_website(self) -> str | None:
"""Get website (vendor override or company fallback)."""
if self.website is not None:
return self.website
return self.company.website if self.company else None
@property
def effective_business_address(self) -> str | None:
"""Get business address (vendor override or company fallback)."""
if self.business_address is not None:
return self.business_address
return self.company.business_address if self.company else None
@property
def effective_tax_number(self) -> str | None:
"""Get tax number (vendor override or company fallback)."""
if self.tax_number is not None:
return self.tax_number
return self.company.tax_number if self.company else None
def get_contact_info_with_inheritance(self) -> dict:
"""
Get all contact info with inheritance flags.
Returns dict with resolved values and flags indicating if inherited from company.
"""
company = self.company
return {
"contact_email": self.effective_contact_email,
"contact_email_inherited": self.contact_email is None and company is not None,
"contact_phone": self.effective_contact_phone,
"contact_phone_inherited": self.contact_phone is None and company is not None,
"website": self.effective_website,
"website_inherited": self.website is None and company is not None,
"business_address": self.effective_business_address,
"business_address_inherited": self.business_address is None and company is not None,
"tax_number": self.effective_tax_number,
"tax_number_inherited": self.tax_number is None and company is not None,
}
class VendorUserType(str, enum.Enum):
"""Types of vendor users."""

View File

@@ -25,7 +25,8 @@ class VendorCreate(BaseModel):
"""
Schema for creating a new vendor (storefront/brand) under an existing company.
Business contact info is inherited from the parent company.
Contact info is inherited from the parent company by default.
Optionally, provide contact fields to override from the start.
"""
# Parent company
@@ -51,6 +52,13 @@ class VendorCreate(BaseModel):
letzshop_csv_url_en: str | None = Field(None, description="English CSV URL")
letzshop_csv_url_de: str | None = Field(None, description="German CSV URL")
# Contact Info (optional - if not provided, inherited from company)
contact_email: str | None = Field(None, description="Override company contact email")
contact_phone: str | None = Field(None, description="Override company contact phone")
website: str | None = Field(None, description="Override company website")
business_address: str | None = Field(None, description="Override company business address")
tax_number: str | None = Field(None, description="Override company tax number")
@field_validator("subdomain")
@classmethod
def validate_subdomain(cls, v):
@@ -72,8 +80,8 @@ class VendorUpdate(BaseModel):
"""
Schema for updating vendor information (Admin only).
Note: Business contact info (contact_email, etc.) is at the Company level.
Use company update endpoints to modify those fields.
Contact fields can be overridden at the vendor level.
Set to null/empty to reset to company default (inherit).
"""
# Basic Information
@@ -90,6 +98,18 @@ class VendorUpdate(BaseModel):
is_active: bool | None = None
is_verified: bool | None = None
# Contact Info (set value to override, set to empty string to reset to inherit)
contact_email: str | None = Field(None, description="Override company contact email")
contact_phone: str | None = Field(None, description="Override company contact phone")
website: str | None = Field(None, description="Override company website")
business_address: str | None = Field(None, description="Override company business address")
tax_number: str | None = Field(None, description="Override company tax number")
# Special flag to reset contact fields to inherit from company
reset_contact_to_company: bool | None = Field(
None, description="If true, reset all contact fields to inherit from company"
)
@field_validator("subdomain")
@classmethod
def subdomain_lowercase(cls, v):
@@ -135,16 +155,14 @@ class VendorResponse(BaseModel):
class VendorDetailResponse(VendorResponse):
"""
Extended vendor response including company information.
Extended vendor response including company information and resolved contact info.
Includes company details like contact info and owner information.
Contact fields show the effective value (vendor override or company default)
with flags indicating if the value is inherited from the parent company.
"""
# Company info
company_name: str = Field(..., description="Name of the parent company")
company_contact_email: str = Field(..., description="Company business contact email")
company_contact_phone: str | None = Field(None, description="Company phone number")
company_website: str | None = Field(None, description="Company website URL")
# Owner info (at company level)
owner_email: str = Field(
@@ -152,6 +170,25 @@ class VendorDetailResponse(VendorResponse):
)
owner_username: str = Field(..., description="Username of the company owner")
# Resolved contact info (vendor override or company default)
contact_email: str | None = Field(None, description="Effective contact email")
contact_phone: str | None = Field(None, description="Effective contact phone")
website: str | None = Field(None, description="Effective website")
business_address: str | None = Field(None, description="Effective business address")
tax_number: str | None = Field(None, description="Effective tax number")
# Inheritance flags (True = value is inherited from company, not overridden)
contact_email_inherited: bool = Field(False, description="True if contact_email is from company")
contact_phone_inherited: bool = Field(False, description="True if contact_phone is from company")
website_inherited: bool = Field(False, description="True if website is from company")
business_address_inherited: bool = Field(False, description="True if business_address is from company")
tax_number_inherited: bool = Field(False, description="True if tax_number is from company")
# Original company values (for reference in UI)
company_contact_email: str | None = Field(None, description="Company's contact email")
company_contact_phone: str | None = Field(None, description="Company's phone number")
company_website: str | None = Field(None, description="Company's website URL")
class VendorCreateResponse(VendorDetailResponse):
"""