diff --git a/app/api/v1/admin/vendor_products.py b/app/api/v1/admin/vendor_products.py index 2a2bb4c1..0318bcaa 100644 --- a/app/api/v1/admin/vendor_products.py +++ b/app/api/v1/admin/vendor_products.py @@ -25,10 +25,13 @@ from models.schema.vendor_product import ( CatalogVendor, CatalogVendorsResponse, RemoveProductResponse, + VendorProductCreate, + VendorProductCreateResponse, VendorProductDetail, VendorProductListItem, VendorProductListResponse, VendorProductStats, + VendorProductUpdate, ) router = APIRouter(prefix="/vendor-products") @@ -109,6 +112,37 @@ def get_vendor_product_detail( return VendorProductDetail(**product) +@router.post("", response_model=VendorProductCreateResponse) +def create_vendor_product( + data: VendorProductCreate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """Create a new vendor product.""" + product = vendor_product_service.create_product(db, data.model_dump()) + db.commit() # ✅ ARCH: Commit at API level for transaction control + return VendorProductCreateResponse( + id=product.id, message="Product created successfully" + ) + + +@router.patch("/{product_id}", response_model=VendorProductDetail) +def update_vendor_product( + product_id: int, + data: VendorProductUpdate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """Update a vendor product.""" + # Only include fields that were explicitly set + update_data = data.model_dump(exclude_unset=True) + vendor_product_service.update_product(db, product_id, update_data) + db.commit() # ✅ ARCH: Commit at API level for transaction control + # Return the updated product detail + product = vendor_product_service.get_product_detail(db, product_id) + return VendorProductDetail(**product) + + @router.delete("/{product_id}", response_model=RemoveProductResponse) def remove_vendor_product( product_id: int, diff --git a/app/routes/admin_pages.py b/app/routes/admin_pages.py index 57434514..4343a660 100644 --- a/app/routes/admin_pages.py +++ b/app/routes/admin_pages.py @@ -807,6 +807,25 @@ async def admin_vendor_products_page( ) +@router.get("/vendor-products/create", response_class=HTMLResponse, include_in_schema=False) +async def admin_vendor_product_create_page( + request: Request, + current_user: User = Depends(get_current_admin_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render vendor product create page. + Create a new vendor product entry. + """ + return templates.TemplateResponse( + "admin/vendor-product-create.html", + { + "request": request, + "user": current_user, + }, + ) + + @router.get( "/vendor-products/{product_id}", response_class=HTMLResponse, @@ -832,6 +851,31 @@ async def admin_vendor_product_detail_page( ) +@router.get( + "/vendor-products/{product_id}/edit", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_vendor_product_edit_page( + request: Request, + product_id: int = Path(..., description="Vendor Product ID"), + current_user: User = Depends(get_current_admin_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render vendor product edit page. + Edit vendor product information and overrides. + """ + return templates.TemplateResponse( + "admin/vendor-product-edit.html", + { + "request": request, + "user": current_user, + "product_id": product_id, + }, + ) + + # ============================================================================ # SETTINGS ROUTES # ============================================================================ diff --git a/app/services/vendor_product_service.py b/app/services/vendor_product_service.py index e7d10370..772db8b6 100644 --- a/app/services/vendor_product_service.py +++ b/app/services/vendor_product_service.py @@ -238,6 +238,78 @@ class VendorProductService: else None, } + def create_product(self, db: Session, data: dict) -> Product: + """Create a new vendor product. + + Args: + db: Database session + data: Product data dict + + Returns: + Created Product instance + """ + product = Product( + vendor_id=data["vendor_id"], + vendor_sku=data.get("vendor_sku"), + brand=data.get("brand"), + gtin=data.get("gtin"), + price=data.get("price"), + currency=data.get("currency", "EUR"), + availability=data.get("availability"), + is_active=data.get("is_active", True), + is_featured=data.get("is_featured", False), + is_digital=data.get("is_digital", False), + description=data.get("description"), + ) + + db.add(product) + db.flush() + + logger.info(f"Created vendor product {product.id} for vendor {data['vendor_id']}") + + return product + + def update_product(self, db: Session, product_id: int, data: dict) -> Product: + """Update a vendor product. + + Args: + db: Database session + product_id: Product ID to update + data: Fields to update + + Returns: + Updated Product instance + """ + product = db.query(Product).filter(Product.id == product_id).first() + + if not product: + raise ProductNotFoundException(product_id) + + # Update allowed fields + updatable_fields = [ + "vendor_sku", + "brand", + "gtin", + "price_override", + "currency_override", + "availability", + "is_active", + "is_featured", + "is_digital", + "description", + "title", + ] + + for field in updatable_fields: + if field in data: + setattr(product, field, data[field]) + + db.flush() + + logger.info(f"Updated vendor product {product_id}") + + return product + def remove_product(self, db: Session, product_id: int) -> dict: """Remove a product from vendor catalog.""" product = db.query(Product).filter(Product.id == product_id).first() @@ -273,6 +345,9 @@ class VendorProductService: "brand": product.brand, "price": product.price, "currency": product.currency, + # Effective price/currency for UI (same as price/currency for now) + "effective_price": product.price, + "effective_currency": product.currency, "is_active": product.is_active, "is_featured": product.is_featured, "is_digital": product.is_digital, diff --git a/app/templates/admin/vendor-product-create.html b/app/templates/admin/vendor-product-create.html new file mode 100644 index 00000000..df5a85fe --- /dev/null +++ b/app/templates/admin/vendor-product-create.html @@ -0,0 +1,242 @@ +{# app/templates/admin/vendor-product-create.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import detail_page_header %} + +{% block title %}Create Vendor Product{% endblock %} + +{% block alpine_data %}adminVendorProductCreate(){% endblock %} + +{% block extra_head %} + + + +{% endblock %} + +{% block content %} +{% call detail_page_header("'Create Vendor Product'", '/admin/vendor-products') %} + Add a new product to a vendor's catalog +{% endcall %} + + +
+ +
+

+ Vendor +

+
+ + +

The vendor whose catalog this product will be added to

+
+
+ + +
+

+ Basic Information +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

+ Pricing +

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

+ Status +

+
+ + + +
+
+ + +
+

+ Description +

+ +
+ + +
+ + Cancel + + +
+
+{% endblock %} + +{% block extra_scripts %} + + + +{% endblock %} diff --git a/app/templates/admin/vendor-product-edit.html b/app/templates/admin/vendor-product-edit.html new file mode 100644 index 00000000..213eee1a --- /dev/null +++ b/app/templates/admin/vendor-product-edit.html @@ -0,0 +1,180 @@ +{# app/templates/admin/vendor-product-edit.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/headers.html' import detail_page_header %} + +{% block title %}Edit Vendor Product{% endblock %} + +{% block alpine_data %}adminVendorProductEdit(){% endblock %} + +{% block content %} +{% call detail_page_header("'Edit: ' + (product?.title || 'Product')", '/admin/vendor-products', subtitle_show='product') %} + +{% endcall %} + +{{ loading_state('Loading product...') }} + +{{ error_state('Error loading product') }} + + +
+
+ +
+

+ Basic Information +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

+ Pricing +

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

+ Status +

+
+ + + +
+
+ + +
+

+ Description +

+ +
+ + +
+ + Cancel + + +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/templates/admin/vendor-products.html b/app/templates/admin/vendor-products.html index 1a7522cb..c867adc2 100644 --- a/app/templates/admin/vendor-products.html +++ b/app/templates/admin/vendor-products.html @@ -62,6 +62,13 @@ {{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }} + + + Create Product + {% endcall %} @@ -316,21 +323,21 @@ - + diff --git a/models/schema/vendor_product.py b/models/schema/vendor_product.py index 5d21533f..0a312a46 100644 --- a/models/schema/vendor_product.py +++ b/models/schema/vendor_product.py @@ -21,12 +21,14 @@ class VendorProductListItem(BaseModel): vendor_id: int vendor_name: str | None = None vendor_code: str | None = None - marketplace_product_id: int + marketplace_product_id: int | None = None vendor_sku: str | None = None title: str | None = None brand: str | None = None price: float | None = None currency: str | None = None + effective_price: float | None = None + effective_currency: str | None = None is_active: bool | None = None is_featured: bool | None = None is_digital: bool | None = None @@ -142,3 +144,43 @@ class RemoveProductResponse(BaseModel): """Response from product removal.""" message: str + + +class VendorProductCreate(BaseModel): + """Schema for creating a vendor product.""" + + vendor_id: int + title: str + brand: str | None = None + vendor_sku: str | None = None + gtin: str | None = None + price: float | None = None + currency: str = "EUR" + availability: str | None = None + is_active: bool = True + is_featured: bool = False + is_digital: bool = False + description: str | None = None + + +class VendorProductUpdate(BaseModel): + """Schema for updating a vendor product.""" + + title: str | None = None + brand: str | None = None + vendor_sku: str | None = None + gtin: str | None = None + price_override: float | None = None + currency_override: str | None = None + availability: str | None = None + is_active: bool | None = None + is_featured: bool | None = None + is_digital: bool | None = None + description: str | None = None + + +class VendorProductCreateResponse(BaseModel): + """Response from product creation.""" + + id: int + message: str diff --git a/static/admin/js/vendor-product-create.js b/static/admin/js/vendor-product-create.js new file mode 100644 index 00000000..2f869496 --- /dev/null +++ b/static/admin/js/vendor-product-create.js @@ -0,0 +1,164 @@ +// static/admin/js/vendor-product-create.js +/** + * Admin vendor product create page logic + * Create new vendor product entries + */ + +const adminVendorProductCreateLog = window.LogConfig.loggers.adminVendorProductCreate || + window.LogConfig.createLogger('adminVendorProductCreate', false); + +adminVendorProductCreateLog.info('Loading...'); + +function adminVendorProductCreate() { + adminVendorProductCreateLog.info('adminVendorProductCreate() called'); + + return { + // Inherit base layout state + ...data(), + + // Set page identifier + currentPage: 'vendor-products', + + // Loading states + saving: false, + + // Tom Select instance + vendorSelectInstance: null, + + // Form data + form: { + vendor_id: null, + title: '', + brand: '', + vendor_sku: '', + gtin: '', + price: null, + currency: 'EUR', + availability: '', + is_active: true, + is_featured: false, + is_digital: false, + description: '' + }, + + async init() { + adminVendorProductCreateLog.info('Vendor Product Create init() called'); + + // Guard against multiple initialization + if (window._adminVendorProductCreateInitialized) { + adminVendorProductCreateLog.warn('Already initialized, skipping'); + return; + } + window._adminVendorProductCreateInitialized = true; + + // Initialize Tom Select + this.initVendorSelect(); + + adminVendorProductCreateLog.info('Vendor Product Create initialization complete'); + }, + + /** + * Initialize Tom Select for vendor autocomplete + */ + initVendorSelect() { + const selectEl = this.$refs.vendorSelect; + if (!selectEl) { + adminVendorProductCreateLog.warn('Vendor select element not found'); + return; + } + + // Wait for Tom Select to be available + if (typeof TomSelect === 'undefined') { + adminVendorProductCreateLog.warn('TomSelect not loaded, retrying in 100ms'); + setTimeout(() => this.initVendorSelect(), 100); + return; + } + + this.vendorSelectInstance = new TomSelect(selectEl, { + valueField: 'id', + labelField: 'name', + searchField: ['name', 'vendor_code'], + placeholder: 'Search vendor...', + load: async (query, callback) => { + try { + const response = await apiClient.get('/admin/vendors', { + search: query, + limit: 50 + }); + callback(response.vendors || []); + } catch (error) { + adminVendorProductCreateLog.error('Failed to search vendors:', error); + callback([]); + } + }, + render: { + option: (data, escape) => { + return `
+ ${escape(data.name)} + ${escape(data.vendor_code || '')} +
`; + }, + item: (data, escape) => { + return `
${escape(data.name)}
`; + } + }, + onChange: (value) => { + this.form.vendor_id = value ? parseInt(value) : null; + } + }); + + adminVendorProductCreateLog.info('Vendor select initialized'); + }, + + /** + * Create the product + */ + async createProduct() { + if (!this.form.vendor_id) { + Utils.showToast('Please select a vendor', 'error'); + return; + } + + if (!this.form.title) { + Utils.showToast('Please enter a product title', 'error'); + return; + } + + this.saving = true; + + try { + // Build create payload + const payload = { + vendor_id: this.form.vendor_id, + title: this.form.title, + brand: this.form.brand || null, + vendor_sku: this.form.vendor_sku || null, + gtin: this.form.gtin || null, + price: this.form.price ? parseFloat(this.form.price) : null, + currency: this.form.currency || 'EUR', + availability: this.form.availability || null, + is_active: this.form.is_active, + is_featured: this.form.is_featured, + is_digital: this.form.is_digital, + description: this.form.description || null + }; + + const response = await apiClient.post('/admin/vendor-products', payload); + + adminVendorProductCreateLog.info('Product created:', response.id); + + Utils.showToast('Product created successfully', 'success'); + + // Redirect to the new product's detail page + setTimeout(() => { + window.location.href = `/admin/vendor-products/${response.id}`; + }, 1000); + } catch (error) { + adminVendorProductCreateLog.error('Failed to create product:', error); + Utils.showToast(error.message || 'Failed to create product', 'error'); + } finally { + this.saving = false; + } + } + }; +} diff --git a/static/admin/js/vendor-product-edit.js b/static/admin/js/vendor-product-edit.js new file mode 100644 index 00000000..65a2e703 --- /dev/null +++ b/static/admin/js/vendor-product-edit.js @@ -0,0 +1,143 @@ +// static/admin/js/vendor-product-edit.js +/** + * Admin vendor product edit page logic + * Edit vendor product information and overrides + */ + +const adminVendorProductEditLog = window.LogConfig.loggers.adminVendorProductEdit || + window.LogConfig.createLogger('adminVendorProductEdit', false); + +adminVendorProductEditLog.info('Loading...'); + +function adminVendorProductEdit() { + adminVendorProductEditLog.info('adminVendorProductEdit() called'); + + // Extract product ID from URL + const pathParts = window.location.pathname.split('/'); + const productId = parseInt(pathParts[pathParts.length - 2]); // /vendor-products/{id}/edit + + return { + // Inherit base layout state + ...data(), + + // Set page identifier + currentPage: 'vendor-products', + + // Product ID from URL + productId: productId, + + // Loading states + loading: true, + saving: false, + error: '', + + // Product data + product: null, + + // Form data + form: { + title: '', + brand: '', + vendor_sku: '', + gtin: '', + price_override: null, + currency_override: '', + availability: '', + is_active: true, + is_featured: false, + is_digital: false, + description: '' + }, + + async init() { + adminVendorProductEditLog.info('Vendor Product Edit init() called, ID:', this.productId); + + // Guard against multiple initialization + if (window._adminVendorProductEditInitialized) { + adminVendorProductEditLog.warn('Already initialized, skipping'); + return; + } + window._adminVendorProductEditInitialized = true; + + // Load product data + await this.loadProduct(); + + adminVendorProductEditLog.info('Vendor Product Edit initialization complete'); + }, + + /** + * Load product details and populate form + */ + async loadProduct() { + this.loading = true; + this.error = ''; + + try { + const response = await apiClient.get(`/admin/vendor-products/${this.productId}`); + this.product = response; + + // Populate form with current values + this.form = { + title: response.title || '', + brand: response.brand || '', + vendor_sku: response.vendor_sku || '', + gtin: response.gtin || '', + price_override: response.price_override || null, + currency_override: response.currency_override || '', + availability: response.availability || '', + is_active: response.is_active ?? true, + is_featured: response.is_featured ?? false, + is_digital: response.is_digital ?? false, + description: response.description || '' + }; + + adminVendorProductEditLog.info('Loaded product:', this.product.id); + } catch (error) { + adminVendorProductEditLog.error('Failed to load product:', error); + this.error = error.message || 'Failed to load product details'; + } finally { + this.loading = false; + } + }, + + /** + * Save product changes + */ + async saveProduct() { + this.saving = true; + + try { + // Build update payload + const payload = { + title: this.form.title || null, + brand: this.form.brand || null, + vendor_sku: this.form.vendor_sku || null, + gtin: this.form.gtin || null, + price_override: this.form.price_override ? parseFloat(this.form.price_override) : null, + currency_override: this.form.currency_override || null, + availability: this.form.availability || null, + is_active: this.form.is_active, + is_featured: this.form.is_featured, + is_digital: this.form.is_digital, + description: this.form.description || null + }; + + await apiClient.patch(`/admin/vendor-products/${this.productId}`, payload); + + adminVendorProductEditLog.info('Product saved:', this.productId); + + Utils.showToast('Product updated successfully', 'success'); + + // Redirect to detail page + setTimeout(() => { + window.location.href = `/admin/vendor-products/${this.productId}`; + }, 1000); + } catch (error) { + adminVendorProductEditLog.error('Failed to save product:', error); + Utils.showToast(error.message || 'Failed to save product', 'error'); + } finally { + this.saving = false; + } + } + }; +}