feat: add logging, marketplace, and admin enhancements
Database & Migrations: - Add application_logs table migration for hybrid cloud logging - Add companies table migration and restructure vendor relationships Logging System: - Implement hybrid logging system (database + file) - Add log_service for centralized log management - Create admin logs page with filtering and viewing capabilities - Add init_log_settings.py script for log configuration - Enhance core logging with database integration Marketplace Integration: - Add marketplace admin page with product management - Create marketplace vendor page with product listings - Implement marketplace.js for both admin and vendor interfaces - Add marketplace integration documentation Admin Enhancements: - Add imports management page and functionality - Create settings page for admin configuration - Add vendor themes management page - Enhance vendor detail and edit pages - Improve code quality dashboard and violation details - Add logs viewing and management - Update icons guide and shared icon system Architecture & Documentation: - Document frontend structure and component architecture - Document models structure and relationships - Add vendor-in-token architecture documentation - Add vendor RBAC (role-based access control) documentation - Document marketplace integration patterns - Update architecture patterns documentation Infrastructure: - Add platform static files structure (css, img, js) - Move architecture_scan.py to proper models location - Update model imports and registrations - Enhance exception handling - Update dependency injection patterns UI/UX: - Improve vendor edit interface - Update admin user interface - Enhance page templates documentation - Add vendor marketplace interface
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -163,3 +163,4 @@ credentials/
|
||||
# Alembic
|
||||
# Note: Keep alembic/versions/ tracked for migrations
|
||||
# alembic/versions/*.pyc is already covered by __pycache__
|
||||
.aider*
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""add application_logs table for hybrid logging
|
||||
|
||||
Revision ID: 0bd9ffaaced1
|
||||
Revises: 7a7ce92593d5
|
||||
Create Date: 2025-11-29 12:44:55.427245
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0bd9ffaaced1'
|
||||
down_revision: Union[str, None] = '7a7ce92593d5'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create application_logs table
|
||||
op.create_table(
|
||||
'application_logs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('timestamp', sa.DateTime(), nullable=False),
|
||||
sa.Column('level', sa.String(length=20), nullable=False),
|
||||
sa.Column('logger_name', sa.String(length=200), nullable=False),
|
||||
sa.Column('module', sa.String(length=200), nullable=True),
|
||||
sa.Column('function_name', sa.String(length=100), nullable=True),
|
||||
sa.Column('line_number', sa.Integer(), nullable=True),
|
||||
sa.Column('message', sa.Text(), nullable=False),
|
||||
sa.Column('exception_type', sa.String(length=200), nullable=True),
|
||||
sa.Column('exception_message', sa.Text(), nullable=True),
|
||||
sa.Column('stack_trace', sa.Text(), nullable=True),
|
||||
sa.Column('request_id', sa.String(length=100), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('vendor_id', sa.Integer(), nullable=True),
|
||||
sa.Column('context', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create indexes for better query performance
|
||||
op.create_index(op.f('ix_application_logs_id'), 'application_logs', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_application_logs_timestamp'), 'application_logs', ['timestamp'], unique=False)
|
||||
op.create_index(op.f('ix_application_logs_level'), 'application_logs', ['level'], unique=False)
|
||||
op.create_index(op.f('ix_application_logs_logger_name'), 'application_logs', ['logger_name'], unique=False)
|
||||
op.create_index(op.f('ix_application_logs_request_id'), 'application_logs', ['request_id'], unique=False)
|
||||
op.create_index(op.f('ix_application_logs_user_id'), 'application_logs', ['user_id'], unique=False)
|
||||
op.create_index(op.f('ix_application_logs_vendor_id'), 'application_logs', ['vendor_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop indexes
|
||||
op.drop_index(op.f('ix_application_logs_vendor_id'), table_name='application_logs')
|
||||
op.drop_index(op.f('ix_application_logs_user_id'), table_name='application_logs')
|
||||
op.drop_index(op.f('ix_application_logs_request_id'), table_name='application_logs')
|
||||
op.drop_index(op.f('ix_application_logs_logger_name'), table_name='application_logs')
|
||||
op.drop_index(op.f('ix_application_logs_level'), table_name='application_logs')
|
||||
op.drop_index(op.f('ix_application_logs_timestamp'), table_name='application_logs')
|
||||
op.drop_index(op.f('ix_application_logs_id'), table_name='application_logs')
|
||||
|
||||
# Drop table
|
||||
op.drop_table('application_logs')
|
||||
@@ -0,0 +1,77 @@
|
||||
"""add_companies_table_and_restructure_vendors
|
||||
|
||||
Revision ID: d0325d7c0f25
|
||||
Revises: 0bd9ffaaced1
|
||||
Create Date: 2025-11-30 14:58:17.165142
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd0325d7c0f25'
|
||||
down_revision: Union[str, None] = '0bd9ffaaced1'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create companies table
|
||||
op.create_table(
|
||||
'companies',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('owner_user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('contact_email', sa.String(), nullable=False),
|
||||
sa.Column('contact_phone', sa.String(), nullable=True),
|
||||
sa.Column('website', sa.String(), nullable=True),
|
||||
sa.Column('business_address', sa.Text(), nullable=True),
|
||||
sa.Column('tax_number', sa.String(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('is_verified', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(['owner_user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_companies_id'), 'companies', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_companies_name'), 'companies', ['name'], unique=False)
|
||||
|
||||
# Use batch mode for SQLite to modify vendors table
|
||||
with op.batch_alter_table('vendors', schema=None) as batch_op:
|
||||
# Add company_id column
|
||||
batch_op.add_column(sa.Column('company_id', sa.Integer(), nullable=True))
|
||||
batch_op.create_index(batch_op.f('ix_vendors_company_id'), ['company_id'], unique=False)
|
||||
batch_op.create_foreign_key('fk_vendors_company_id', 'companies', ['company_id'], ['id'])
|
||||
|
||||
# Remove old contact fields
|
||||
batch_op.drop_column('contact_email')
|
||||
batch_op.drop_column('contact_phone')
|
||||
batch_op.drop_column('website')
|
||||
batch_op.drop_column('business_address')
|
||||
batch_op.drop_column('tax_number')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Use batch mode for SQLite to modify vendors table
|
||||
with op.batch_alter_table('vendors', schema=None) as batch_op:
|
||||
# Re-add contact fields to vendors
|
||||
batch_op.add_column(sa.Column('tax_number', sa.String(), nullable=True))
|
||||
batch_op.add_column(sa.Column('business_address', sa.Text(), nullable=True))
|
||||
batch_op.add_column(sa.Column('website', sa.String(), nullable=True))
|
||||
batch_op.add_column(sa.Column('contact_phone', sa.String(), nullable=True))
|
||||
batch_op.add_column(sa.Column('contact_email', sa.String(), nullable=True))
|
||||
|
||||
# Remove company_id from vendors
|
||||
batch_op.drop_constraint('fk_vendors_company_id', type_='foreignkey')
|
||||
batch_op.drop_index(batch_op.f('ix_vendors_company_id'))
|
||||
batch_op.drop_column('company_id')
|
||||
|
||||
# Drop companies table
|
||||
op.drop_index(op.f('ix_companies_name'), table_name='companies')
|
||||
op.drop_index(op.f('ix_companies_id'), table_name='companies')
|
||||
op.drop_table('companies')
|
||||
@@ -271,17 +271,18 @@ def get_current_vendor_api(
|
||||
Get current vendor user from Authorization header ONLY.
|
||||
|
||||
Used for vendor API endpoints that should not accept cookies.
|
||||
Validates that user still has access to the vendor specified in the token.
|
||||
|
||||
Args:
|
||||
credentials: Bearer token from Authorization header
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
User: Authenticated vendor user
|
||||
User: Authenticated vendor user (with token_vendor_id, token_vendor_code, token_vendor_role)
|
||||
|
||||
Raises:
|
||||
InvalidTokenException: If no token or invalid token
|
||||
InsufficientPermissionsException: If user is not vendor or is admin
|
||||
InsufficientPermissionsException: If user is not vendor or lost access to vendor
|
||||
"""
|
||||
if not credentials:
|
||||
raise InvalidTokenException("Authorization header required for API calls")
|
||||
@@ -297,6 +298,24 @@ def get_current_vendor_api(
|
||||
logger.warning(f"Non-vendor user {user.username} attempted vendor API")
|
||||
raise InsufficientPermissionsException("Vendor privileges required")
|
||||
|
||||
# Validate vendor access if token is vendor-scoped
|
||||
if hasattr(user, "token_vendor_id"):
|
||||
vendor_id = user.token_vendor_id
|
||||
|
||||
# Verify user still has access to this vendor
|
||||
if not user.is_member_of(vendor_id):
|
||||
logger.warning(
|
||||
f"User {user.username} lost access to vendor_id={vendor_id}"
|
||||
)
|
||||
raise InsufficientPermissionsException(
|
||||
"Access to vendor has been revoked. Please login again."
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Vendor API access: user={user.username}, vendor_id={vendor_id}, "
|
||||
f"vendor_code={getattr(user, 'token_vendor_code', 'N/A')}"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
|
||||
342
app/api/v1/admin/logs.py
Normal file
342
app/api/v1/admin/logs.py
Normal file
@@ -0,0 +1,342 @@
|
||||
# app/api/v1/admin/logs.py
|
||||
"""
|
||||
Log management endpoints for admin.
|
||||
|
||||
Provides endpoints for:
|
||||
- Viewing database logs with filters
|
||||
- Reading file logs
|
||||
- Log statistics
|
||||
- Log settings management
|
||||
- Log cleanup operations
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.core.logging import reload_log_level
|
||||
from app.services.admin_audit_service import admin_audit_service
|
||||
from app.services.admin_settings_service import admin_settings_service
|
||||
from app.services.log_service import log_service
|
||||
from models.database.user import User
|
||||
from models.schema.admin import (
|
||||
ApplicationLogFilters,
|
||||
ApplicationLogListResponse,
|
||||
FileLogResponse,
|
||||
LogSettingsResponse,
|
||||
LogSettingsUpdate,
|
||||
LogStatistics,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/logs")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DATABASE LOGS ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/database", response_model=ApplicationLogListResponse)
|
||||
def get_database_logs(
|
||||
level: str | None = Query(None, description="Filter by log level"),
|
||||
logger_name: str | None = Query(None, description="Filter by logger name"),
|
||||
module: str | None = Query(None, description="Filter by module"),
|
||||
user_id: int | None = Query(None, description="Filter by user ID"),
|
||||
vendor_id: int | None = Query(None, description="Filter by vendor ID"),
|
||||
search: str | None = Query(None, description="Search in message"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get logs from database with filtering.
|
||||
|
||||
Supports filtering by level, logger, module, user, vendor, and date range.
|
||||
Returns paginated results.
|
||||
"""
|
||||
filters = ApplicationLogFilters(
|
||||
level=level,
|
||||
logger_name=logger_name,
|
||||
module=module,
|
||||
user_id=user_id,
|
||||
vendor_id=vendor_id,
|
||||
search=search,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return log_service.get_database_logs(db, filters)
|
||||
|
||||
|
||||
@router.get("/statistics", response_model=LogStatistics)
|
||||
def get_log_statistics(
|
||||
days: int = Query(7, ge=1, le=90, description="Number of days to analyze"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get log statistics for the last N days.
|
||||
|
||||
Returns counts by level, module, and recent critical errors.
|
||||
"""
|
||||
return log_service.get_log_statistics(db, days)
|
||||
|
||||
|
||||
@router.delete("/database/cleanup")
|
||||
def cleanup_old_logs(
|
||||
retention_days: int = Query(30, ge=1, le=365),
|
||||
confirm: bool = Query(False, description="Must be true to confirm cleanup"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Delete logs older than retention period.
|
||||
|
||||
Requires confirmation parameter.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
if not confirm:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cleanup requires confirmation parameter: confirm=true",
|
||||
)
|
||||
|
||||
deleted_count = log_service.cleanup_old_logs(db, retention_days)
|
||||
|
||||
# Log action
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="cleanup_logs",
|
||||
target_type="application_logs",
|
||||
target_id="bulk",
|
||||
details={"retention_days": retention_days, "deleted_count": deleted_count},
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"Deleted {deleted_count} log entries older than {retention_days} days",
|
||||
"deleted_count": deleted_count,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/database/{log_id}")
|
||||
def delete_log(
|
||||
log_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Delete a specific log entry."""
|
||||
message = log_service.delete_log(db, log_id)
|
||||
|
||||
# Log action
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="delete_log",
|
||||
target_type="application_log",
|
||||
target_id=str(log_id),
|
||||
details={},
|
||||
)
|
||||
|
||||
return {"message": message}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FILE LOGS ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/files")
|
||||
def list_log_files(
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
List all available log files.
|
||||
|
||||
Returns list of log files with size and modification date.
|
||||
"""
|
||||
return {"files": log_service.list_log_files()}
|
||||
|
||||
|
||||
@router.get("/files/{filename}", response_model=FileLogResponse)
|
||||
def get_file_log(
|
||||
filename: str,
|
||||
lines: int = Query(500, ge=1, le=10000, description="Number of lines to read"),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Read log file content.
|
||||
|
||||
Returns the last N lines from the specified log file.
|
||||
"""
|
||||
return log_service.get_file_logs(filename, lines)
|
||||
|
||||
|
||||
@router.get("/files/{filename}/download")
|
||||
def download_log_file(
|
||||
filename: str,
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Download log file.
|
||||
|
||||
Returns the entire log file for download.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
# Determine log file path
|
||||
log_file_path = settings.log_file
|
||||
if log_file_path:
|
||||
log_file = Path(log_file_path).parent / filename
|
||||
else:
|
||||
log_file = Path("logs") / filename
|
||||
|
||||
if not log_file.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Log file '{filename}' not found")
|
||||
|
||||
# Log action
|
||||
from app.core.database import get_db
|
||||
|
||||
db_gen = get_db()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="download_log_file",
|
||||
target_type="log_file",
|
||||
target_id=filename,
|
||||
details={"size_bytes": log_file.stat().st_size},
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return FileResponse(
|
||||
log_file,
|
||||
media_type="text/plain",
|
||||
filename=filename,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LOG SETTINGS ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/settings", response_model=LogSettingsResponse)
|
||||
def get_log_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get current log configuration settings."""
|
||||
log_level = admin_settings_service.get_setting_value(db, "log_level", "INFO")
|
||||
max_size_mb = admin_settings_service.get_setting_value(
|
||||
db, "log_file_max_size_mb", 10
|
||||
)
|
||||
backup_count = admin_settings_service.get_setting_value(
|
||||
db, "log_file_backup_count", 5
|
||||
)
|
||||
retention_days = admin_settings_service.get_setting_value(
|
||||
db, "db_log_retention_days", 30
|
||||
)
|
||||
file_enabled = admin_settings_service.get_setting_value(
|
||||
db, "file_logging_enabled", "true"
|
||||
)
|
||||
db_enabled = admin_settings_service.get_setting_value(
|
||||
db, "db_logging_enabled", "true"
|
||||
)
|
||||
|
||||
return LogSettingsResponse(
|
||||
log_level=str(log_level),
|
||||
log_file_max_size_mb=int(max_size_mb),
|
||||
log_file_backup_count=int(backup_count),
|
||||
db_log_retention_days=int(retention_days),
|
||||
file_logging_enabled=str(file_enabled).lower() == "true",
|
||||
db_logging_enabled=str(db_enabled).lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
def update_log_settings(
|
||||
settings_update: LogSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Update log configuration settings.
|
||||
|
||||
Changes are applied immediately without restart (for log level).
|
||||
File rotation settings require restart.
|
||||
"""
|
||||
from models.schema.admin import AdminSettingUpdate
|
||||
|
||||
updated = []
|
||||
|
||||
# Update log level
|
||||
if settings_update.log_level:
|
||||
admin_settings_service.update_setting(
|
||||
db,
|
||||
"log_level",
|
||||
AdminSettingUpdate(value=settings_update.log_level),
|
||||
current_admin.id,
|
||||
)
|
||||
updated.append("log_level")
|
||||
|
||||
# Reload log level immediately
|
||||
reload_log_level()
|
||||
|
||||
# Update file rotation settings
|
||||
if settings_update.log_file_max_size_mb:
|
||||
admin_settings_service.update_setting(
|
||||
db,
|
||||
"log_file_max_size_mb",
|
||||
AdminSettingUpdate(value=str(settings_update.log_file_max_size_mb)),
|
||||
current_admin.id,
|
||||
)
|
||||
updated.append("log_file_max_size_mb")
|
||||
|
||||
if settings_update.log_file_backup_count is not None:
|
||||
admin_settings_service.update_setting(
|
||||
db,
|
||||
"log_file_backup_count",
|
||||
AdminSettingUpdate(value=str(settings_update.log_file_backup_count)),
|
||||
current_admin.id,
|
||||
)
|
||||
updated.append("log_file_backup_count")
|
||||
|
||||
# Update retention
|
||||
if settings_update.db_log_retention_days:
|
||||
admin_settings_service.update_setting(
|
||||
db,
|
||||
"db_log_retention_days",
|
||||
AdminSettingUpdate(value=str(settings_update.db_log_retention_days)),
|
||||
current_admin.id,
|
||||
)
|
||||
updated.append("db_log_retention_days")
|
||||
|
||||
# Log action
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="update_log_settings",
|
||||
target_type="settings",
|
||||
target_id="logging",
|
||||
details={"updated_fields": updated},
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Log settings updated successfully",
|
||||
"updated_fields": updated,
|
||||
"note": "Log level changes are applied immediately. File rotation settings require restart.",
|
||||
}
|
||||
22
app/api/v1/vendor/auth.py
vendored
22
app/api/v1/vendor/auth.py
vendored
@@ -142,28 +142,36 @@ def vendor_login(
|
||||
f"for vendor {vendor.vendor_code} as {vendor_role}"
|
||||
)
|
||||
|
||||
# Create vendor-scoped access token with vendor information
|
||||
token_data = auth_service.auth_manager.create_access_token(
|
||||
user=user,
|
||||
vendor_id=vendor.id,
|
||||
vendor_code=vendor.vendor_code,
|
||||
vendor_role=vendor_role,
|
||||
)
|
||||
|
||||
# Set HTTP-only cookie for browser navigation
|
||||
# CRITICAL: path=/vendor restricts cookie to vendor routes only
|
||||
response.set_cookie(
|
||||
key="vendor_token",
|
||||
value=login_result["token_data"]["access_token"],
|
||||
value=token_data["access_token"],
|
||||
httponly=True, # JavaScript cannot access (XSS protection)
|
||||
secure=should_use_secure_cookies(), # HTTPS only in production/staging
|
||||
samesite="lax", # CSRF protection
|
||||
max_age=login_result["token_data"]["expires_in"], # Match JWT expiry
|
||||
max_age=token_data["expires_in"], # Match JWT expiry
|
||||
path="/vendor", # RESTRICTED TO VENDOR ROUTES ONLY
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Set vendor_token cookie with {login_result['token_data']['expires_in']}s expiry "
|
||||
f"Set vendor_token cookie with {token_data['expires_in']}s expiry "
|
||||
f"(path=/vendor, httponly=True, secure={should_use_secure_cookies()})"
|
||||
)
|
||||
|
||||
# Return full login response
|
||||
# Return full login response with vendor-scoped token
|
||||
return VendorLoginResponse(
|
||||
access_token=login_result["token_data"]["access_token"],
|
||||
token_type=login_result["token_data"]["token_type"],
|
||||
expires_in=login_result["token_data"]["expires_in"],
|
||||
access_token=token_data["access_token"],
|
||||
token_type=token_data["token_type"],
|
||||
expires_in=token_data["expires_in"],
|
||||
user={
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
|
||||
28
app/api/v1/vendor/dashboard.py
vendored
28
app/api/v1/vendor/dashboard.py
vendored
@@ -32,31 +32,29 @@ def get_vendor_dashboard_stats(
|
||||
- Total customers
|
||||
- Revenue metrics
|
||||
|
||||
Vendor is determined from the authenticated user's vendor_user association.
|
||||
Vendor is determined from the JWT token (vendor_id claim).
|
||||
Requires Authorization header (API endpoint).
|
||||
"""
|
||||
# Get vendor from authenticated user's vendor_user record
|
||||
from models.database.vendor import VendorUser
|
||||
|
||||
vendor_user = (
|
||||
db.query(VendorUser).filter(VendorUser.user_id == current_user.id).first()
|
||||
)
|
||||
|
||||
if not vendor_user:
|
||||
from fastapi import HTTPException
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token (set by get_current_vendor_api)
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=403, detail="User is not associated with any vendor"
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor = vendor_user.vendor
|
||||
if not vendor or not vendor.is_active:
|
||||
from fastapi import HTTPException
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Get vendor object to include in response
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if not vendor or not vendor.is_active:
|
||||
raise HTTPException(status_code=404, detail="Vendor not found or inactive")
|
||||
|
||||
# Get vendor-scoped statistics
|
||||
stats_data = stats_service.get_vendor_stats(db=db, vendor_id=vendor.id)
|
||||
stats_data = stats_service.get_vendor_stats(db=db, vendor_id=vendor_id)
|
||||
|
||||
return {
|
||||
"vendor": {
|
||||
|
||||
45
app/api/v1/vendor/orders.py
vendored
45
app/api/v1/vendor/orders.py
vendored
@@ -11,9 +11,7 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.services.order_service import order_service
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor
|
||||
from models.schema.order import (
|
||||
OrderDetailResponse,
|
||||
OrderListResponse,
|
||||
@@ -31,7 +29,6 @@ def get_vendor_orders(
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
status: str | None = Query(None, description="Filter by order status"),
|
||||
customer_id: int | None = Query(None, description="Filter by customer"),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -42,11 +39,23 @@ def get_vendor_orders(
|
||||
- status: Order status (pending, processing, shipped, delivered, cancelled)
|
||||
- customer_id: Filter orders from specific customer
|
||||
|
||||
Vendor is determined from JWT token (vendor_id claim).
|
||||
Requires Authorization header (API endpoint).
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
orders, total = order_service.get_vendor_orders(
|
||||
db=db,
|
||||
vendor_id=vendor.id,
|
||||
vendor_id=vendor_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
status=status,
|
||||
@@ -64,7 +73,6 @@ def get_vendor_orders(
|
||||
@router.get("/{order_id}", response_model=OrderDetailResponse)
|
||||
def get_order_details(
|
||||
order_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -73,7 +81,18 @@ def get_order_details(
|
||||
|
||||
Requires Authorization header (API endpoint).
|
||||
"""
|
||||
order = order_service.get_order(db=db, vendor_id=vendor.id, order_id=order_id)
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
order = order_service.get_order(db=db, vendor_id=vendor_id, order_id=order_id)
|
||||
|
||||
return OrderDetailResponse.model_validate(order)
|
||||
|
||||
@@ -82,7 +101,6 @@ def get_order_details(
|
||||
def update_order_status(
|
||||
order_id: int,
|
||||
order_update: OrderUpdate,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -99,8 +117,19 @@ def update_order_status(
|
||||
|
||||
Requires Authorization header (API endpoint).
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
order = order_service.update_order_status(
|
||||
db=db, vendor_id=vendor.id, order_id=order_id, order_update=order_update
|
||||
db=db, vendor_id=vendor_id, order_id=order_id, order_update=order_update
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
128
app/api/v1/vendor/products.py
vendored
128
app/api/v1/vendor/products.py
vendored
@@ -11,9 +11,7 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.services.product_service import product_service
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor
|
||||
from models.schema.product import (
|
||||
ProductCreate,
|
||||
ProductDetailResponse,
|
||||
@@ -32,7 +30,6 @@ def get_vendor_products(
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
is_active: bool | None = Query(None),
|
||||
is_featured: bool | None = Query(None),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -42,10 +39,23 @@ def get_vendor_products(
|
||||
Supports filtering by:
|
||||
- is_active: Filter active/inactive products
|
||||
- is_featured: Filter featured products
|
||||
|
||||
Vendor is determined from JWT token (vendor_id claim).
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
products, total = product_service.get_vendor_products(
|
||||
db=db,
|
||||
vendor_id=vendor.id,
|
||||
vendor_id=vendor_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
is_active=is_active,
|
||||
@@ -63,13 +73,23 @@ def get_vendor_products(
|
||||
@router.get("/{product_id}", response_model=ProductDetailResponse)
|
||||
def get_product_details(
|
||||
product_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get detailed product information including inventory."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product = product_service.get_product(
|
||||
db=db, vendor_id=vendor.id, product_id=product_id
|
||||
db=db, vendor_id=vendor_id, product_id=product_id
|
||||
)
|
||||
|
||||
return ProductDetailResponse.model_validate(product)
|
||||
@@ -78,7 +98,6 @@ def get_product_details(
|
||||
@router.post("", response_model=ProductResponse)
|
||||
def add_product_to_catalog(
|
||||
product_data: ProductCreate,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -87,13 +106,24 @@ def add_product_to_catalog(
|
||||
|
||||
This publishes a MarketplaceProduct to the vendor's public catalog.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product = product_service.create_product(
|
||||
db=db, vendor_id=vendor.id, product_data=product_data
|
||||
db=db, vendor_id=vendor_id, product_data=product_data
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Product {product.id} added to catalog by user {current_user.username} "
|
||||
f"for vendor {vendor.vendor_code}"
|
||||
f"for vendor {current_user.token_vendor_code}"
|
||||
)
|
||||
|
||||
return ProductResponse.model_validate(product)
|
||||
@@ -103,18 +133,28 @@ def add_product_to_catalog(
|
||||
def update_product(
|
||||
product_id: int,
|
||||
product_data: ProductUpdate,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update product in vendor catalog."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product = product_service.update_product(
|
||||
db=db, vendor_id=vendor.id, product_id=product_id, product_update=product_data
|
||||
db=db, vendor_id=vendor_id, product_id=product_id, product_update=product_data
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Product {product_id} updated by user {current_user.username} "
|
||||
f"for vendor {vendor.vendor_code}"
|
||||
f"for vendor {current_user.token_vendor_code}"
|
||||
)
|
||||
|
||||
return ProductResponse.model_validate(product)
|
||||
@@ -123,16 +163,26 @@ def update_product(
|
||||
@router.delete("/{product_id}")
|
||||
def remove_product_from_catalog(
|
||||
product_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Remove product from vendor catalog."""
|
||||
product_service.delete_product(db=db, vendor_id=vendor.id, product_id=product_id)
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product_service.delete_product(db=db, vendor_id=vendor_id, product_id=product_id)
|
||||
|
||||
logger.info(
|
||||
f"Product {product_id} removed from catalog by user {current_user.username} "
|
||||
f"for vendor {vendor.vendor_code}"
|
||||
f"for vendor {current_user.token_vendor_code}"
|
||||
)
|
||||
|
||||
return {"message": f"Product {product_id} removed from catalog"}
|
||||
@@ -141,7 +191,6 @@ def remove_product_from_catalog(
|
||||
@router.post("/from-import/{marketplace_product_id}", response_model=ProductResponse)
|
||||
def publish_from_marketplace(
|
||||
marketplace_product_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -150,17 +199,28 @@ def publish_from_marketplace(
|
||||
|
||||
Shortcut endpoint for publishing directly from marketplace import.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product_data = ProductCreate(
|
||||
marketplace_product_id=marketplace_product_id, is_active=True
|
||||
)
|
||||
|
||||
product = product_service.create_product(
|
||||
db=db, vendor_id=vendor.id, product_data=product_data
|
||||
db=db, vendor_id=vendor_id, product_data=product_data
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Marketplace product {marketplace_product_id} published to catalog "
|
||||
f"by user {current_user.username} for vendor {vendor.vendor_code}"
|
||||
f"by user {current_user.username} for vendor {current_user.token_vendor_code}"
|
||||
)
|
||||
|
||||
return ProductResponse.model_validate(product)
|
||||
@@ -169,19 +229,29 @@ def publish_from_marketplace(
|
||||
@router.put("/{product_id}/toggle-active")
|
||||
def toggle_product_active(
|
||||
product_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Toggle product active status."""
|
||||
product = product_service.get_product(db, vendor.id, product_id)
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product = product_service.get_product(db, vendor_id, product_id)
|
||||
|
||||
product.is_active = not product.is_active
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
|
||||
status = "activated" if product.is_active else "deactivated"
|
||||
logger.info(f"Product {product_id} {status} for vendor {vendor.vendor_code}")
|
||||
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
|
||||
|
||||
return {"message": f"Product {status}", "is_active": product.is_active}
|
||||
|
||||
@@ -189,18 +259,28 @@ def toggle_product_active(
|
||||
@router.put("/{product_id}/toggle-featured")
|
||||
def toggle_product_featured(
|
||||
product_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Toggle product featured status."""
|
||||
product = product_service.get_product(db, vendor.id, product_id)
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product = product_service.get_product(db, vendor_id, product_id)
|
||||
|
||||
product.is_featured = not product.is_featured
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
|
||||
status = "featured" if product.is_featured else "unfeatured"
|
||||
logger.info(f"Product {product_id} {status} for vendor {vendor.vendor_code}")
|
||||
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
|
||||
|
||||
return {"message": f"Product {status}", "is_featured": product.is_featured}
|
||||
|
||||
@@ -1,50 +1,235 @@
|
||||
# app/core/logging.py
|
||||
"""Summary description ....
|
||||
"""Hybrid logging system with file rotation and database storage.
|
||||
|
||||
This module provides classes and functions for:
|
||||
- ....
|
||||
- ....
|
||||
- ....
|
||||
- File-based logging with automatic rotation
|
||||
- Database logging for critical events (WARNING, ERROR, CRITICAL)
|
||||
- Dynamic log level configuration from database settings
|
||||
- Log retention and cleanup policies
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import traceback
|
||||
from datetime import UTC, datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class DatabaseLogHandler(logging.Handler):
|
||||
"""
|
||||
Custom logging handler that stores WARNING, ERROR, and CRITICAL logs in database.
|
||||
|
||||
Runs asynchronously to avoid blocking application performance.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setLevel(logging.WARNING) # Only log WARNING and above to database
|
||||
|
||||
def emit(self, record):
|
||||
"""Emit a log record to the database."""
|
||||
try:
|
||||
from app.core.database import SessionLocal
|
||||
from models.database.admin import ApplicationLog
|
||||
|
||||
# Skip if no database session available
|
||||
db = SessionLocal()
|
||||
if not db:
|
||||
return
|
||||
|
||||
try:
|
||||
# Extract exception information if present
|
||||
exception_type = None
|
||||
exception_message = None
|
||||
stack_trace = None
|
||||
|
||||
if record.exc_info:
|
||||
exception_type = record.exc_info[0].__name__ if record.exc_info[0] else None
|
||||
exception_message = str(record.exc_info[1]) if record.exc_info[1] else None
|
||||
stack_trace = "".join(traceback.format_exception(*record.exc_info))
|
||||
|
||||
# Extract context from record (if middleware added it)
|
||||
user_id = getattr(record, "user_id", None)
|
||||
vendor_id = getattr(record, "vendor_id", None)
|
||||
request_id = getattr(record, "request_id", None)
|
||||
context = getattr(record, "context", None)
|
||||
|
||||
# Create log entry
|
||||
log_entry = ApplicationLog(
|
||||
timestamp=datetime.fromtimestamp(record.created, tz=UTC),
|
||||
level=record.levelname,
|
||||
logger_name=record.name,
|
||||
module=record.module,
|
||||
function_name=record.funcName,
|
||||
line_number=record.lineno,
|
||||
message=record.getMessage(),
|
||||
exception_type=exception_type,
|
||||
exception_message=exception_message,
|
||||
stack_trace=stack_trace,
|
||||
request_id=request_id,
|
||||
user_id=user_id,
|
||||
vendor_id=vendor_id,
|
||||
context=context,
|
||||
)
|
||||
|
||||
db.add(log_entry)
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
# If database logging fails, don't crash the app
|
||||
# Just print to stderr
|
||||
print(f"Failed to write log to database: {e}", file=sys.stderr)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
except Exception:
|
||||
# Silently fail - logging should never crash the app
|
||||
pass
|
||||
|
||||
|
||||
def get_log_level_from_db():
|
||||
"""
|
||||
Get log level from database settings.
|
||||
Falls back to environment variable if not found.
|
||||
"""
|
||||
try:
|
||||
from app.core.database import SessionLocal
|
||||
from app.services.admin_settings_service import admin_settings_service
|
||||
|
||||
db = SessionLocal()
|
||||
if not db:
|
||||
return settings.log_level
|
||||
|
||||
try:
|
||||
log_level = admin_settings_service.get_setting_value(
|
||||
db, "log_level", default=settings.log_level
|
||||
)
|
||||
return log_level.upper() if log_level else settings.log_level.upper()
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
# If database not ready or error, fall back to settings
|
||||
return settings.log_level.upper()
|
||||
|
||||
|
||||
def get_rotation_settings_from_db():
|
||||
"""
|
||||
Get log rotation settings from database.
|
||||
Returns tuple: (max_bytes, backup_count)
|
||||
"""
|
||||
try:
|
||||
from app.core.database import SessionLocal
|
||||
from app.services.admin_settings_service import admin_settings_service
|
||||
|
||||
db = SessionLocal()
|
||||
if not db:
|
||||
return (10 * 1024 * 1024, 5) # 10MB, 5 backups
|
||||
|
||||
try:
|
||||
max_mb = admin_settings_service.get_setting_value(
|
||||
db, "log_file_max_size_mb", default=10
|
||||
)
|
||||
backup_count = admin_settings_service.get_setting_value(
|
||||
db, "log_file_backup_count", default=5
|
||||
)
|
||||
return (int(max_mb) * 1024 * 1024, int(backup_count))
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
# Fall back to defaults
|
||||
return (10 * 1024 * 1024, 5)
|
||||
|
||||
|
||||
def reload_log_level():
|
||||
"""
|
||||
Reload log level from database without restarting application.
|
||||
Useful when log level is changed via admin panel.
|
||||
"""
|
||||
try:
|
||||
new_level = get_log_level_from_db()
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(getattr(logging, new_level))
|
||||
logging.info(f"Log level changed to: {new_level}")
|
||||
return new_level
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to reload log level: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def setup_logging():
|
||||
"""Configure application logging with file and console handlers."""
|
||||
"""Configure application logging with file rotation and database handlers."""
|
||||
# Determine log file path
|
||||
log_file_path = settings.log_file
|
||||
if log_file_path:
|
||||
log_file = Path(log_file_path)
|
||||
else:
|
||||
# Default to logs/app.log
|
||||
log_file = Path("logs") / "app.log"
|
||||
|
||||
# Create logs directory if it doesn't exist
|
||||
log_file = Path(settings.log_file)
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Get log level from database (or fall back to env)
|
||||
log_level = get_log_level_from_db()
|
||||
|
||||
# Get rotation settings from database (or fall back to defaults)
|
||||
max_bytes, backup_count = get_rotation_settings_from_db()
|
||||
|
||||
# Configure root logger
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(getattr(logging, settings.log_level.upper()))
|
||||
logger.setLevel(getattr(logging, log_level))
|
||||
|
||||
# Remove existing handlers
|
||||
for handler in logger.handlers[:]:
|
||||
logger.removeHandler(handler)
|
||||
|
||||
# Create formatters
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
detailed_formatter = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - [%(module)s:%(funcName)s:%(lineno)d] - %(message)s"
|
||||
)
|
||||
simple_formatter = logging.Formatter(
|
||||
"%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
# Console handler
|
||||
# Console handler (simple format)
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.setFormatter(simple_formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# File handler
|
||||
file_handler = logging.FileHandler(log_file)
|
||||
file_handler.setFormatter(formatter)
|
||||
# Rotating file handler (detailed format)
|
||||
file_handler = RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=max_bytes,
|
||||
backupCount=backup_count,
|
||||
encoding="utf-8"
|
||||
)
|
||||
file_handler.setFormatter(detailed_formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# Configure specific loggers
|
||||
# Database handler for critical events (WARNING and above)
|
||||
try:
|
||||
db_handler = DatabaseLogHandler()
|
||||
db_handler.setFormatter(detailed_formatter)
|
||||
logger.addHandler(db_handler)
|
||||
except Exception as e:
|
||||
# If database handler fails, just use file logging
|
||||
print(f"Warning: Database logging handler could not be initialized: {e}", file=sys.stderr)
|
||||
|
||||
# Configure specific loggers to reduce noise
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||
|
||||
# Log startup info
|
||||
logger.info("=" * 80)
|
||||
logger.info("LOGGING SYSTEM INITIALIZED")
|
||||
logger.info(f"Log Level: {log_level}")
|
||||
logger.info(f"Log File: {log_file}")
|
||||
logger.info(f"Max File Size: {max_bytes / (1024 * 1024):.1f} MB")
|
||||
logger.info(f"Backup Count: {backup_count}")
|
||||
logger.info(f"Database Logging: Enabled (WARNING and above)")
|
||||
logger.info("=" * 80)
|
||||
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
@@ -54,6 +54,18 @@ from .cart import (
|
||||
ProductNotAvailableForCartException,
|
||||
)
|
||||
|
||||
# Company exceptions
|
||||
from .company import (
|
||||
CompanyAlreadyExistsException,
|
||||
CompanyHasVendorsException,
|
||||
CompanyNotActiveException,
|
||||
CompanyNotFoundException,
|
||||
CompanyNotVerifiedException,
|
||||
CompanyValidationException,
|
||||
InvalidCompanyDataException,
|
||||
UnauthorizedCompanyAccessException,
|
||||
)
|
||||
|
||||
# Customer exceptions
|
||||
from .customer import (
|
||||
CustomerAlreadyExistsException,
|
||||
@@ -284,6 +296,15 @@ __all__ = [
|
||||
"InsufficientInventoryForCartException",
|
||||
"InvalidCartQuantityException",
|
||||
"ProductNotAvailableForCartException",
|
||||
# Company exceptions
|
||||
"CompanyNotFoundException",
|
||||
"CompanyAlreadyExistsException",
|
||||
"CompanyNotActiveException",
|
||||
"CompanyNotVerifiedException",
|
||||
"UnauthorizedCompanyAccessException",
|
||||
"InvalidCompanyDataException",
|
||||
"CompanyValidationException",
|
||||
"CompanyHasVendorsException",
|
||||
# MarketplaceProduct exceptions
|
||||
"MarketplaceProductNotFoundException",
|
||||
"MarketplaceProductAlreadyExistsException",
|
||||
|
||||
@@ -364,8 +364,30 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
|
||||
logger.debug("Redirecting to /admin/login")
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
if context_type == RequestContext.VENDOR_DASHBOARD:
|
||||
logger.debug("Redirecting to /vendor/login")
|
||||
return RedirectResponse(url="/vendor/login", status_code=302)
|
||||
# Extract vendor code from the request path
|
||||
# Path format: /vendor/{vendor_code}/...
|
||||
path_parts = request.url.path.split('/')
|
||||
vendor_code = None
|
||||
|
||||
# Find vendor code in path
|
||||
if len(path_parts) >= 3 and path_parts[1] == 'vendor':
|
||||
vendor_code = path_parts[2]
|
||||
|
||||
# Fallback: try to get from request state
|
||||
if not vendor_code:
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
if vendor:
|
||||
vendor_code = vendor.subdomain
|
||||
|
||||
# Construct proper login URL with vendor code
|
||||
if vendor_code:
|
||||
login_url = f"/vendor/{vendor_code}/login"
|
||||
else:
|
||||
# Fallback if we can't determine vendor code
|
||||
login_url = "/vendor/login"
|
||||
|
||||
logger.debug(f"Redirecting to {login_url}")
|
||||
return RedirectResponse(url=login_url, status_code=302)
|
||||
if context_type == RequestContext.SHOP:
|
||||
# For shop context, redirect to shop login (customer login)
|
||||
# Calculate base_url for proper routing (supports domain, subdomain, and path-based access)
|
||||
|
||||
@@ -11,7 +11,7 @@ from datetime import datetime
|
||||
from sqlalchemy import desc, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.architecture_scan import (
|
||||
from models.database.architecture_scan import (
|
||||
ArchitectureScan,
|
||||
ArchitectureViolation,
|
||||
ViolationAssignment,
|
||||
|
||||
379
app/services/log_service.py
Normal file
379
app/services/log_service.py
Normal file
@@ -0,0 +1,379 @@
|
||||
# app/services/log_service.py
|
||||
"""
|
||||
Log management service for viewing and managing application logs.
|
||||
|
||||
This module provides functions for:
|
||||
- Querying database logs with filters
|
||||
- Reading file logs
|
||||
- Log statistics and analytics
|
||||
- Log retention and cleanup
|
||||
- Downloading log files
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.exceptions import AdminOperationException, ResourceNotFoundException
|
||||
from models.database.admin import ApplicationLog
|
||||
from models.schema.admin import (
|
||||
ApplicationLogFilters,
|
||||
ApplicationLogListResponse,
|
||||
ApplicationLogResponse,
|
||||
FileLogResponse,
|
||||
LogStatistics,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LogService:
|
||||
"""Service for managing application logs."""
|
||||
|
||||
def get_database_logs(
|
||||
self, db: Session, filters: ApplicationLogFilters
|
||||
) -> ApplicationLogListResponse:
|
||||
"""
|
||||
Get logs from database with filtering and pagination.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
filters: Filter criteria
|
||||
|
||||
Returns:
|
||||
Paginated list of logs
|
||||
"""
|
||||
try:
|
||||
query = db.query(ApplicationLog)
|
||||
|
||||
# Apply filters
|
||||
conditions = []
|
||||
|
||||
if filters.level:
|
||||
conditions.append(ApplicationLog.level == filters.level.upper())
|
||||
|
||||
if filters.logger_name:
|
||||
conditions.append(ApplicationLog.logger_name.like(f"%{filters.logger_name}%"))
|
||||
|
||||
if filters.module:
|
||||
conditions.append(ApplicationLog.module.like(f"%{filters.module}%"))
|
||||
|
||||
if filters.user_id:
|
||||
conditions.append(ApplicationLog.user_id == filters.user_id)
|
||||
|
||||
if filters.vendor_id:
|
||||
conditions.append(ApplicationLog.vendor_id == filters.vendor_id)
|
||||
|
||||
if filters.date_from:
|
||||
conditions.append(ApplicationLog.timestamp >= filters.date_from)
|
||||
|
||||
if filters.date_to:
|
||||
conditions.append(ApplicationLog.timestamp <= filters.date_to)
|
||||
|
||||
if filters.search:
|
||||
search_pattern = f"%{filters.search}%"
|
||||
conditions.append(
|
||||
or_(
|
||||
ApplicationLog.message.like(search_pattern),
|
||||
ApplicationLog.exception_message.like(search_pattern),
|
||||
)
|
||||
)
|
||||
|
||||
if conditions:
|
||||
query = query.filter(and_(*conditions))
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Apply pagination and sorting
|
||||
logs = (
|
||||
query.order_by(ApplicationLog.timestamp.desc())
|
||||
.offset(filters.skip)
|
||||
.limit(filters.limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return ApplicationLogListResponse(
|
||||
logs=[ApplicationLogResponse.model_validate(log) for log in logs],
|
||||
total=total,
|
||||
skip=filters.skip,
|
||||
limit=filters.limit,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get database logs: {e}")
|
||||
raise AdminOperationException(
|
||||
operation="get_database_logs", reason=f"Database query failed: {str(e)}"
|
||||
)
|
||||
|
||||
def get_log_statistics(self, db: Session, days: int = 7) -> LogStatistics:
|
||||
"""
|
||||
Get statistics about logs from the last N days.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
days: Number of days to analyze
|
||||
|
||||
Returns:
|
||||
Log statistics
|
||||
"""
|
||||
try:
|
||||
cutoff_date = datetime.now(UTC) - timedelta(days=days)
|
||||
|
||||
# Total counts
|
||||
total_count = (
|
||||
db.query(func.count(ApplicationLog.id))
|
||||
.filter(ApplicationLog.timestamp >= cutoff_date)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
warning_count = (
|
||||
db.query(func.count(ApplicationLog.id))
|
||||
.filter(
|
||||
and_(
|
||||
ApplicationLog.timestamp >= cutoff_date,
|
||||
ApplicationLog.level == "WARNING",
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
error_count = (
|
||||
db.query(func.count(ApplicationLog.id))
|
||||
.filter(
|
||||
and_(
|
||||
ApplicationLog.timestamp >= cutoff_date,
|
||||
ApplicationLog.level == "ERROR",
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
critical_count = (
|
||||
db.query(func.count(ApplicationLog.id))
|
||||
.filter(
|
||||
and_(
|
||||
ApplicationLog.timestamp >= cutoff_date,
|
||||
ApplicationLog.level == "CRITICAL",
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
# Count by level
|
||||
by_level_raw = (
|
||||
db.query(ApplicationLog.level, func.count(ApplicationLog.id))
|
||||
.filter(ApplicationLog.timestamp >= cutoff_date)
|
||||
.group_by(ApplicationLog.level)
|
||||
.all()
|
||||
)
|
||||
by_level = {level: count for level, count in by_level_raw}
|
||||
|
||||
# Count by module (top 10)
|
||||
by_module_raw = (
|
||||
db.query(ApplicationLog.module, func.count(ApplicationLog.id))
|
||||
.filter(ApplicationLog.timestamp >= cutoff_date)
|
||||
.filter(ApplicationLog.module.isnot(None))
|
||||
.group_by(ApplicationLog.module)
|
||||
.order_by(func.count(ApplicationLog.id).desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
by_module = {module: count for module, count in by_module_raw}
|
||||
|
||||
# Recent errors (last 5)
|
||||
recent_errors = (
|
||||
db.query(ApplicationLog)
|
||||
.filter(
|
||||
and_(
|
||||
ApplicationLog.timestamp >= cutoff_date,
|
||||
ApplicationLog.level.in_(["ERROR", "CRITICAL"]),
|
||||
)
|
||||
)
|
||||
.order_by(ApplicationLog.timestamp.desc())
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
|
||||
return LogStatistics(
|
||||
total_count=total_count or 0,
|
||||
warning_count=warning_count or 0,
|
||||
error_count=error_count or 0,
|
||||
critical_count=critical_count or 0,
|
||||
by_level=by_level,
|
||||
by_module=by_module,
|
||||
recent_errors=[
|
||||
ApplicationLogResponse.model_validate(log) for log in recent_errors
|
||||
],
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get log statistics: {e}")
|
||||
raise AdminOperationException(
|
||||
operation="get_log_statistics", reason=f"Database query failed: {str(e)}"
|
||||
)
|
||||
|
||||
def get_file_logs(
|
||||
self, filename: str = "app.log", lines: int = 500
|
||||
) -> FileLogResponse:
|
||||
"""
|
||||
Read logs from file.
|
||||
|
||||
Args:
|
||||
filename: Log filename (default: app.log)
|
||||
lines: Number of lines to return from end of file
|
||||
|
||||
Returns:
|
||||
File log content
|
||||
"""
|
||||
try:
|
||||
# Determine log file path
|
||||
log_file_path = settings.log_file
|
||||
if log_file_path:
|
||||
log_file = Path(log_file_path)
|
||||
else:
|
||||
log_file = Path("logs") / "app.log"
|
||||
|
||||
# Allow reading backup files
|
||||
if filename != "app.log":
|
||||
log_file = log_file.parent / filename
|
||||
|
||||
if not log_file.exists():
|
||||
raise ResourceNotFoundException(
|
||||
resource_type="log_file", identifier=str(log_file)
|
||||
)
|
||||
|
||||
# Get file stats
|
||||
stat = log_file.stat()
|
||||
|
||||
# Read last N lines efficiently
|
||||
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
|
||||
# For large files, seek to end and read backwards
|
||||
all_lines = f.readlines()
|
||||
log_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
|
||||
|
||||
return FileLogResponse(
|
||||
filename=log_file.name,
|
||||
size_bytes=stat.st_size,
|
||||
last_modified=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
|
||||
lines=[line.rstrip("\n") for line in log_lines],
|
||||
total_lines=len(all_lines),
|
||||
)
|
||||
|
||||
except ResourceNotFoundException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read log file: {e}")
|
||||
raise AdminOperationException(
|
||||
operation="get_file_logs", reason=f"File read failed: {str(e)}"
|
||||
)
|
||||
|
||||
def list_log_files(self) -> list[dict]:
|
||||
"""
|
||||
List all available log files.
|
||||
|
||||
Returns:
|
||||
List of log file info (name, size, modified date)
|
||||
"""
|
||||
try:
|
||||
# Determine log directory
|
||||
log_file_path = settings.log_file
|
||||
if log_file_path:
|
||||
log_dir = Path(log_file_path).parent
|
||||
else:
|
||||
log_dir = Path("logs")
|
||||
|
||||
if not log_dir.exists():
|
||||
return []
|
||||
|
||||
files = []
|
||||
for log_file in log_dir.glob("*.log*"):
|
||||
if log_file.is_file():
|
||||
stat = log_file.stat()
|
||||
files.append(
|
||||
{
|
||||
"filename": log_file.name,
|
||||
"size_bytes": stat.st_size,
|
||||
"size_mb": round(stat.st_size / (1024 * 1024), 2),
|
||||
"last_modified": datetime.fromtimestamp(
|
||||
stat.st_mtime, tz=UTC
|
||||
).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by modified date (newest first)
|
||||
files.sort(key=lambda x: x["last_modified"], reverse=True)
|
||||
|
||||
return files
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list log files: {e}")
|
||||
raise AdminOperationException(
|
||||
operation="list_log_files", reason=f"Directory read failed: {str(e)}"
|
||||
)
|
||||
|
||||
def cleanup_old_logs(self, db: Session, retention_days: int) -> int:
|
||||
"""
|
||||
Delete logs older than retention period from database.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
retention_days: Days to retain logs
|
||||
|
||||
Returns:
|
||||
Number of logs deleted
|
||||
"""
|
||||
try:
|
||||
cutoff_date = datetime.now(UTC) - timedelta(days=retention_days)
|
||||
|
||||
deleted_count = (
|
||||
db.query(ApplicationLog)
|
||||
.filter(ApplicationLog.timestamp < cutoff_date)
|
||||
.delete()
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Cleaned up {deleted_count} logs older than {retention_days} days"
|
||||
)
|
||||
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to cleanup old logs: {e}")
|
||||
raise AdminOperationException(
|
||||
operation="cleanup_old_logs", reason=f"Delete operation failed: {str(e)}"
|
||||
)
|
||||
|
||||
def delete_log(self, db: Session, log_id: int) -> str:
|
||||
"""Delete a specific log entry."""
|
||||
try:
|
||||
log_entry = db.query(ApplicationLog).filter(ApplicationLog.id == log_id).first()
|
||||
|
||||
if not log_entry:
|
||||
raise ResourceNotFoundException(resource_type="log", identifier=str(log_id))
|
||||
|
||||
db.delete(log_entry)
|
||||
db.commit()
|
||||
|
||||
return f"Log entry {log_id} deleted successfully"
|
||||
|
||||
except ResourceNotFoundException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to delete log {log_id}: {e}")
|
||||
raise AdminOperationException(
|
||||
operation="delete_log", reason=f"Delete operation failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# Create service instance
|
||||
log_service = LogService()
|
||||
@@ -71,7 +71,7 @@
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Card: Total Violations -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
|
||||
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-white dark:bg-red-600">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -87,7 +87,7 @@
|
||||
<!-- Card: Errors -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||
<span x-html="$icon('alert', 'w-5 h-5')"></span>
|
||||
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
@@ -102,7 +102,7 @@
|
||||
<!-- Card: Warnings -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
|
||||
<span x-html="$icon('info', 'w-5 h-5')"></span>
|
||||
<span x-html="$icon('information-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
@@ -122,7 +122,7 @@
|
||||
'text-yellow-500 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-500': stats.technical_debt_score >= 50 && stats.technical_debt_score < 80,
|
||||
'text-red-500 bg-red-100 dark:text-red-100 dark:bg-red-500': stats.technical_debt_score < 50
|
||||
}">
|
||||
<span x-html="$icon('chart', 'w-5 h-5')"></span>
|
||||
<span x-html="$icon('chart-bar', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
@@ -275,7 +275,7 @@
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a href="/admin/code-quality/violations"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
|
||||
<span x-html="$icon('list', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-html="$icon('clipboard-list', 'w-4 h-4 mr-2')"></span>
|
||||
View All Violations
|
||||
</a>
|
||||
<a href="/admin/code-quality/violations?status=open"
|
||||
@@ -285,7 +285,7 @@
|
||||
</a>
|
||||
<a href="/admin/code-quality/violations?severity=error"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:shadow-outline-gray">
|
||||
<span x-html="$icon('alert', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-html="$icon('exclamation', 'w-4 h-4 mr-2')"></span>
|
||||
Errors Only
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -18,8 +18,9 @@ function codeQualityViolationDetail(violationId) {
|
||||
updating: false,
|
||||
commenting: false,
|
||||
newComment: '',
|
||||
newStatus: '',
|
||||
assignedTo: '',
|
||||
assignUserId: '',
|
||||
resolutionNote: '',
|
||||
ignoreReason: '',
|
||||
|
||||
async init() {
|
||||
await this.loadViolation();
|
||||
@@ -30,10 +31,8 @@ function codeQualityViolationDetail(violationId) {
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/api/v1/admin/code-quality/violations/${this.violationId}`);
|
||||
this.violation = response.data;
|
||||
this.newStatus = this.violation.status;
|
||||
this.assignedTo = this.violation.assigned_to || '';
|
||||
const response = await apiClient.get(`/admin/code-quality/violations/${this.violationId}`);
|
||||
this.violation = response;
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Load Violation');
|
||||
this.error = error.response?.data?.message || 'Failed to load violation details';
|
||||
@@ -42,41 +41,75 @@ function codeQualityViolationDetail(violationId) {
|
||||
}
|
||||
},
|
||||
|
||||
async updateStatus() {
|
||||
if (!this.newStatus) return;
|
||||
async assignViolation() {
|
||||
const userId = parseInt(this.assignUserId);
|
||||
if (!userId || isNaN(userId)) {
|
||||
Utils.showToast('Please enter a valid user ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.updating = true;
|
||||
|
||||
try {
|
||||
await apiClient.patch(`/api/v1/admin/code-quality/violations/${this.violationId}/status`, {
|
||||
status: this.newStatus
|
||||
await apiClient.post(`/admin/code-quality/violations/${this.violationId}/assign`, {
|
||||
user_id: userId,
|
||||
priority: 'medium'
|
||||
});
|
||||
|
||||
Utils.showToast('Status updated successfully', 'success');
|
||||
this.assignUserId = '';
|
||||
Utils.showToast('Violation assigned successfully', 'success');
|
||||
await this.loadViolation();
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Update Status');
|
||||
Utils.showToast(error.response?.data?.message || 'Failed to update status', 'error');
|
||||
window.LogConfig.logError(error, 'Assign Violation');
|
||||
Utils.showToast(error.message || 'Failed to assign violation', 'error');
|
||||
} finally {
|
||||
this.updating = false;
|
||||
}
|
||||
},
|
||||
|
||||
async assignViolation() {
|
||||
if (!this.assignedTo) return;
|
||||
async resolveViolation() {
|
||||
if (!this.resolutionNote.trim()) {
|
||||
Utils.showToast('Please enter a resolution note', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.updating = true;
|
||||
|
||||
try {
|
||||
await apiClient.patch(`/api/v1/admin/code-quality/violations/${this.violationId}/assign`, {
|
||||
assigned_to: this.assignedTo
|
||||
await apiClient.post(`/admin/code-quality/violations/${this.violationId}/resolve`, {
|
||||
resolution_note: this.resolutionNote
|
||||
});
|
||||
|
||||
Utils.showToast('Violation assigned successfully', 'success');
|
||||
this.resolutionNote = '';
|
||||
Utils.showToast('Violation resolved successfully', 'success');
|
||||
await this.loadViolation();
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Assign Violation');
|
||||
Utils.showToast(error.response?.data?.message || 'Failed to assign violation', 'error');
|
||||
window.LogConfig.logError(error, 'Resolve Violation');
|
||||
Utils.showToast(error.message || 'Failed to resolve violation', 'error');
|
||||
} finally {
|
||||
this.updating = false;
|
||||
}
|
||||
},
|
||||
|
||||
async ignoreViolation() {
|
||||
if (!this.ignoreReason.trim()) {
|
||||
Utils.showToast('Please enter a reason for ignoring', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.updating = true;
|
||||
|
||||
try {
|
||||
await apiClient.post(`/admin/code-quality/violations/${this.violationId}/ignore`, {
|
||||
reason: this.ignoreReason
|
||||
});
|
||||
|
||||
this.ignoreReason = '';
|
||||
Utils.showToast('Violation ignored successfully', 'success');
|
||||
await this.loadViolation();
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Ignore Violation');
|
||||
Utils.showToast(error.message || 'Failed to ignore violation', 'error');
|
||||
} finally {
|
||||
this.updating = false;
|
||||
}
|
||||
@@ -88,7 +121,7 @@ function codeQualityViolationDetail(violationId) {
|
||||
this.commenting = true;
|
||||
|
||||
try {
|
||||
await apiClient.post(`/api/v1/admin/code-quality/violations/${this.violationId}/comments`, {
|
||||
await apiClient.post(`/admin/code-quality/violations/${this.violationId}/comments`, {
|
||||
comment: this.newComment
|
||||
});
|
||||
|
||||
@@ -97,7 +130,7 @@ function codeQualityViolationDetail(violationId) {
|
||||
await this.loadViolation();
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Add Comment');
|
||||
Utils.showToast(error.response?.data?.message || 'Failed to add comment', 'error');
|
||||
Utils.showToast(error.message || 'Failed to add comment', 'error');
|
||||
} finally {
|
||||
this.commenting = false;
|
||||
}
|
||||
@@ -222,48 +255,72 @@ function codeQualityViolationDetail(violationId) {
|
||||
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<h4 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">Manage Violation</h4>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 mb-6">
|
||||
<!-- Update Status -->
|
||||
<!-- Assign Section -->
|
||||
<div class="mb-6" x-show="violation.status === 'open' || violation.status === 'assigned'">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Assign to User</label>
|
||||
<div class="flex gap-2">
|
||||
<input x-model="assignUserId"
|
||||
type="number"
|
||||
placeholder="User ID"
|
||||
class="flex-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input rounded-md">
|
||||
<button @click="assignViolation()"
|
||||
:disabled="updating || !assignUserId"
|
||||
class="px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!updating">Assign</span>
|
||||
<span x-show="updating">Assigning...</span>
|
||||
</button>
|
||||
</div>
|
||||
<p x-show="violation.assigned_to" class="mt-1 text-xs text-gray-500">
|
||||
Currently assigned to user ID: <span x-text="violation.assigned_to"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="grid gap-4 md:grid-cols-2" x-show="violation.status === 'open' || violation.status === 'assigned'">
|
||||
<!-- Resolve -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Status</label>
|
||||
<div class="flex gap-2">
|
||||
<select x-model="newStatus"
|
||||
class="flex-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-select rounded-md">
|
||||
<option value="open">Open</option>
|
||||
<option value="assigned">Assigned</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="ignored">Ignored</option>
|
||||
</select>
|
||||
<button @click="updateStatus()"
|
||||
:disabled="updating || newStatus === violation.status"
|
||||
class="px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!updating">Update</span>
|
||||
<span x-show="updating">Updating...</span>
|
||||
</button>
|
||||
</div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Resolve Violation</label>
|
||||
<textarea x-model="resolutionNote"
|
||||
rows="2"
|
||||
placeholder="Resolution note..."
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-textarea rounded-md"></textarea>
|
||||
<button @click="resolveViolation()"
|
||||
:disabled="updating || !resolutionNote.trim()"
|
||||
class="mt-2 w-full px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-green-600 border border-transparent rounded-lg active:bg-green-600 hover:bg-green-700 focus:outline-none focus:shadow-outline-green disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!updating">Mark as Resolved</span>
|
||||
<span x-show="updating">Resolving...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Assign -->
|
||||
<!-- Ignore -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Assign To</label>
|
||||
<div class="flex gap-2">
|
||||
<input x-model="assignedTo"
|
||||
type="text"
|
||||
placeholder="Username or email"
|
||||
class="flex-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input rounded-md">
|
||||
<button @click="assignViolation()"
|
||||
:disabled="updating || !assignedTo"
|
||||
class="px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!updating">Assign</span>
|
||||
<span x-show="updating">Assigning...</span>
|
||||
</button>
|
||||
</div>
|
||||
<p x-show="violation.assigned_to" class="mt-1 text-xs text-gray-500">
|
||||
Currently assigned to: <span x-text="violation.assigned_to"></span>
|
||||
</p>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Ignore Violation</label>
|
||||
<textarea x-model="ignoreReason"
|
||||
rows="2"
|
||||
placeholder="Reason for ignoring..."
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-textarea rounded-md"></textarea>
|
||||
<button @click="ignoreViolation()"
|
||||
:disabled="updating || !ignoreReason.trim()"
|
||||
class="mt-2 w-full px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-gray-600 border border-transparent rounded-lg active:bg-gray-600 hover:bg-gray-700 focus:outline-none focus:shadow-outline-gray disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!updating">Ignore Violation</span>
|
||||
<span x-show="updating">Ignoring...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolution Info (for resolved/ignored) -->
|
||||
<div x-show="violation.status === 'resolved' || violation.status === 'ignored'" class="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
This violation has been <span x-text="violation.status"></span>
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400" x-show="violation.resolution_note">
|
||||
Note: <span x-text="violation.resolution_note"></span>
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mt-1" x-show="violation.resolved_at">
|
||||
<span x-text="formatDate(violation.resolved_at)"></span> by user ID <span x-text="violation.resolved_by"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Comments Section -->
|
||||
<div>
|
||||
<h5 class="text-md font-semibold text-gray-700 dark:text-gray-200 mb-3">Comments</h5>
|
||||
@@ -293,7 +350,9 @@ function codeQualityViolationDetail(violationId) {
|
||||
<template x-for="comment in (violation.comments || [])" :key="comment.id">
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="comment.user"></p>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
User ID: <span x-text="comment.user_id"></span>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500" x-text="formatDate(comment.created_at)"></p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-900 dark:text-white" x-text="comment.comment"></p>
|
||||
|
||||
@@ -304,7 +304,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Usage Guide -->
|
||||
<div class="mt-6 bg-blue-50 dark:bg-gray-800 border border-blue-200 dark:border-gray-700 rounded-lg p-6">
|
||||
<div class="mb-8 mt-6 bg-blue-50 dark:bg-gray-800 border border-blue-200 dark:border-gray-700 rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">
|
||||
<span x-html="$icon('book-open', 'w-5 h-5 mr-2 text-blue-600')"></span>
|
||||
How to Use Icons
|
||||
|
||||
432
app/templates/admin/imports.html
Normal file
432
app/templates/admin/imports.html
Normal file
@@ -0,0 +1,432 @@
|
||||
{# app/templates/admin/imports.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Import Jobs - Platform Monitoring{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminImports(){% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="/static/admin/js/imports.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Platform Import Jobs
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
System-wide monitoring of all marketplace import jobs
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="refreshJobs()"
|
||||
:disabled="loading"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Total Jobs -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('cube', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Total Jobs
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Jobs -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
|
||||
<span x-html="$icon('clock', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Active Jobs
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Completed
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.completed">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Failed -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-white dark:bg-red-600">
|
||||
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Failed
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.failed">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="error" x-transition class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Error</p>
|
||||
<p class="text-sm" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 p-4">
|
||||
<div class="grid gap-4 md:grid-cols-5">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Filter by Vendor
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.vendor_id"
|
||||
@change="applyFilters()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Vendors</option>
|
||||
<template x-for="vendor in vendors" :key="vendor.id">
|
||||
<option :value="vendor.id" x-text="`${vendor.name} (${vendor.vendor_code})`"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Filter by Status
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.status"
|
||||
@change="applyFilters()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="completed_with_errors">Completed with Errors</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Filter by Marketplace
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.marketplace"
|
||||
@change="applyFilters()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Marketplaces</option>
|
||||
<option value="Letzshop">Letzshop</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Created By
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.created_by"
|
||||
@change="applyFilters()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Users</option>
|
||||
<option value="me">My Jobs Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
@click="clearFilters()"
|
||||
class="px-3 py-1 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Jobs List -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Import Jobs
|
||||
</h3>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading import jobs...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && jobs.length === 0" class="text-center py-12">
|
||||
<span x-html="$icon('inbox', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
|
||||
<p class="text-gray-600 dark:text-gray-400">No import jobs found</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500">Try adjusting your filters or wait for new imports</p>
|
||||
</div>
|
||||
|
||||
<!-- Jobs Table -->
|
||||
<div x-show="!loading && jobs.length > 0" class="w-full overflow-hidden rounded-lg shadow-xs">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full whitespace-no-wrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Job ID</th>
|
||||
<th class="px-4 py-3">Vendor</th>
|
||||
<th class="px-4 py-3">Marketplace</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Progress</th>
|
||||
<th class="px-4 py-3">Started</th>
|
||||
<th class="px-4 py-3">Duration</th>
|
||||
<th class="px-4 py-3">Created By</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="job in jobs" :key="job.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<td class="px-4 py-3 text-sm">
|
||||
#<span x-text="job.id"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="getVendorName(job.vendor_id)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.marketplace"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="{
|
||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': job.status === 'completed',
|
||||
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': job.status === 'processing',
|
||||
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': job.status === 'pending',
|
||||
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': job.status === 'failed',
|
||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': job.status === 'completed_with_errors'
|
||||
}"
|
||||
x-text="job.status.replace('_', ' ').toUpperCase()">
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<span class="text-green-600 dark:text-green-400" x-text="job.imported_count"></span> imported,
|
||||
<span class="text-blue-600 dark:text-blue-400" x-text="job.updated_count"></span> updated
|
||||
</div>
|
||||
<div x-show="job.error_count > 0" class="text-xs text-red-600 dark:text-red-400">
|
||||
<span x-text="job.error_count"></span> errors
|
||||
</div>
|
||||
<div x-show="job.total_processed > 0" class="text-xs text-gray-500 dark:text-gray-500">
|
||||
Total: <span x-text="job.total_processed"></span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.started_at ? formatDate(job.started_at) : 'Not started'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="calculateDuration(job)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.created_by_name || 'System'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="viewJobDetails(job.id)"
|
||||
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="View Details"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
x-show="job.status === 'processing' || job.status === 'pending'"
|
||||
@click="refreshJobStatus(job.id)"
|
||||
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-blue-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Refresh Status"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div x-show="!loading && totalJobs > limit" class="px-4 py-3 border-t dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-400">
|
||||
Showing <span x-text="((page - 1) * limit) + 1"></span> to
|
||||
<span x-text="Math.min(page * limit, totalJobs)"></span> of
|
||||
<span x-text="totalJobs"></span> jobs
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="previousPage()"
|
||||
:disabled="page === 1"
|
||||
class="px-3 py-1 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-md hover:bg-purple-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
@click="nextPage()"
|
||||
:disabled="page * limit >= totalJobs"
|
||||
class="px-3 py-1 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-md hover:bg-purple-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job Details Modal (same as marketplace version) -->
|
||||
<div x-show="showJobModal"
|
||||
x-cloak
|
||||
@click.away="closeJobModal()"
|
||||
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0">
|
||||
<div @click.away="closeJobModal()"
|
||||
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-2xl"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 transform translate-y-1/2"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0 transform translate-y-1/2">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Import Job Details
|
||||
</h3>
|
||||
<button @click="closeJobModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-html="$icon('close', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div x-show="selectedJob" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Job ID</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.id"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Vendor</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="getVendorName(selectedJob?.vendor_id)"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Marketplace</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.marketplace"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Status</p>
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="{
|
||||
'text-green-700 bg-green-100': selectedJob?.status === 'completed',
|
||||
'text-blue-700 bg-blue-100': selectedJob?.status === 'processing',
|
||||
'text-yellow-700 bg-yellow-100': selectedJob?.status === 'pending',
|
||||
'text-red-700 bg-red-100': selectedJob?.status === 'failed',
|
||||
'text-orange-700 bg-orange-100': selectedJob?.status === 'completed_with_errors'
|
||||
}"
|
||||
x-text="selectedJob?.status.replace('_', ' ').toUpperCase()">
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Source URL</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100 break-all" x-text="selectedJob?.source_url"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Imported</p>
|
||||
<p class="text-sm text-green-600 dark:text-green-400" x-text="selectedJob?.imported_count"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Updated</p>
|
||||
<p class="text-sm text-blue-600 dark:text-blue-400" x-text="selectedJob?.updated_count"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Errors</p>
|
||||
<p class="text-sm text-red-600 dark:text-red-400" x-text="selectedJob?.error_count"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Total Processed</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.total_processed"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Started At</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.started_at ? formatDate(selectedJob.started_at) : 'Not started'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Completed At</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.completed_at ? formatDate(selectedJob.completed_at) : 'Not completed'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Created By</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.created_by_name || 'System'"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Details -->
|
||||
<div x-show="selectedJob?.error_details && selectedJob.error_details.length > 0" class="mt-4">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Error Details</p>
|
||||
<div class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg max-h-48 overflow-y-auto">
|
||||
<pre class="text-xs text-red-700 dark:text-red-300 whitespace-pre-wrap" x-text="JSON.stringify(selectedJob.error_details, null, 2)"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="flex justify-end mt-6">
|
||||
<button
|
||||
@click="closeJobModal()"
|
||||
class="px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-700 hover:border-gray-500 focus:outline-none"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
378
app/templates/admin/logs.html
Normal file
378
app/templates/admin/logs.html
Normal file
@@ -0,0 +1,378 @@
|
||||
{# app/templates/admin/logs.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Application Logs{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminLogs(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Application Logs
|
||||
</h2>
|
||||
<div class="flex items-center space-x-3">
|
||||
<button
|
||||
@click="refresh()"
|
||||
:disabled="loading"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Success</p>
|
||||
<p class="text-sm" x-text="successMessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="error" x-transition class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Error</p>
|
||||
<p class="text-sm" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div x-show="stats" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Total Logs -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('document-text', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total Logs (7d)</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_count">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Warnings</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.warning_count">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Errors -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-white dark:bg-red-600">
|
||||
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Errors</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.error_count">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Critical -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('lightning-bolt', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Critical</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.critical_count">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Source Tabs -->
|
||||
<div class="mb-6">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<button
|
||||
@click="logSource = 'database'; loadLogs()"
|
||||
:class="logSource === 'database' ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'"
|
||||
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
>
|
||||
<span x-html="$icon('database', 'inline w-5 h-5 mr-2')"></span>
|
||||
Database Logs
|
||||
</button>
|
||||
<button
|
||||
@click="logSource = 'file'; loadFileLogs()"
|
||||
:class="logSource === 'file' ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'"
|
||||
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
>
|
||||
<span x-html="$icon('document', 'inline w-5 h-5 mr-2')"></span>
|
||||
File Logs
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Logs Section -->
|
||||
<div x-show="logSource === 'database'" x-transition>
|
||||
<!-- Filters -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Filters</h3>
|
||||
<button
|
||||
@click="resetFilters()"
|
||||
class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400"
|
||||
>
|
||||
Reset Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Log Level Filter -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Log Level</label>
|
||||
<select
|
||||
x-model="filters.level"
|
||||
@change="loadLogs()"
|
||||
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
>
|
||||
<option value="">All Levels</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Module Filter -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Module</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="filters.module"
|
||||
@keyup.enter="loadLogs()"
|
||||
placeholder="Filter by module..."
|
||||
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="filters.search"
|
||||
@keyup.enter="loadLogs()"
|
||||
placeholder="Search in messages..."
|
||||
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Logs Table -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full whitespace-no-wrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Timestamp</th>
|
||||
<th class="px-4 py-3">Level</th>
|
||||
<th class="px-4 py-3">Module</th>
|
||||
<th class="px-4 py-3">Message</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-if="loading">
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-8 text-center">
|
||||
<span x-html="$icon('spinner', 'inline w-6 h-6 text-purple-600')"></span>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Loading logs...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && logs.length === 0">
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
No logs found
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<template x-for="log in logs" :key="log.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="formatTimestamp(log.timestamp)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span
|
||||
:class="{
|
||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100': log.level === 'WARNING',
|
||||
'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100': log.level === 'ERROR',
|
||||
'bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100': log.level === 'CRITICAL'
|
||||
}"
|
||||
class="px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
x-text="log.level"
|
||||
></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="log.module || '-'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="max-w-2xl truncate" x-text="log.message"></div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<button
|
||||
@click="showLogDetail(log)"
|
||||
class="text-purple-600 hover:text-purple-700 dark:text-purple-400"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div x-show="!loading && logs.length > 0" class="px-4 py-3 border-t dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-400">
|
||||
Showing <span x-text="filters.skip + 1"></span> to <span x-text="Math.min(filters.skip + filters.limit, totalLogs)"></span> of <span x-text="totalLogs"></span>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="previousPage()"
|
||||
:disabled="filters.skip === 0"
|
||||
class="px-3 py-1 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
@click="nextPage()"
|
||||
:disabled="filters.skip + filters.limit >= totalLogs"
|
||||
class="px-3 py-1 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Logs Section -->
|
||||
<div x-show="logSource === 'file'" x-transition>
|
||||
<!-- File Selection -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Log Files</h3>
|
||||
<button
|
||||
@click="loadFileLogs()"
|
||||
class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400"
|
||||
>
|
||||
Refresh List
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Select Log File</label>
|
||||
<select
|
||||
x-model="selectedFile"
|
||||
@change="loadFileContent()"
|
||||
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
>
|
||||
<option value="">Select a file...</option>
|
||||
<template x-for="file in logFiles" :key="file.filename">
|
||||
<option :value="file.filename" x-text="`${file.filename} (${file.size_mb} MB)`"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
@click="downloadLogFile()"
|
||||
:disabled="!selectedFile"
|
||||
class="px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-green-600 border border-transparent rounded-lg hover:bg-green-700 focus:outline-none focus:shadow-outline-green disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-html="$icon('download', 'inline w-4 h-4 mr-2')"></span>
|
||||
Download File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Content -->
|
||||
<div x-show="fileContent" class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
<div class="p-4 border-b dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="selectedFile"></h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing last <span x-text="fileContent?.lines?.length || 0"></span> lines of <span x-text="fileContent?.total_lines || 0"></span> total
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-900 overflow-x-auto">
|
||||
<pre class="text-xs text-green-400 font-mono"><code x-text="fileContent?.lines?.join('\n')"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Detail Modal -->
|
||||
<div x-show="selectedLog" x-transition class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50" @click.self="selectedLog = null">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden">
|
||||
<div class="p-6 border-b dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-200">Log Details</h3>
|
||||
<button @click="selectedLog = null" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||
<span x-html="$icon('close', 'w-6 h-6')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
|
||||
<template x-if="selectedLog">
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Timestamp</p>
|
||||
<p class="text-sm text-gray-800 dark:text-gray-200" x-text="formatTimestamp(selectedLog.timestamp)"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Level</p>
|
||||
<p class="text-sm text-gray-800 dark:text-gray-200" x-text="selectedLog.level"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Logger</p>
|
||||
<p class="text-sm text-gray-800 dark:text-gray-200" x-text="selectedLog.logger_name"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Module</p>
|
||||
<p class="text-sm text-gray-800 dark:text-gray-200" x-text="selectedLog.module || '-'"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Message</p>
|
||||
<p class="text-sm text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 p-3 rounded" x-text="selectedLog.message"></p>
|
||||
</div>
|
||||
<div x-show="selectedLog.exception_message">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Exception</p>
|
||||
<p class="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900 p-3 rounded" x-text="selectedLog.exception_type + ': ' + selectedLog.exception_message"></p>
|
||||
</div>
|
||||
<div x-show="selectedLog.stack_trace">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Stack Trace</p>
|
||||
<pre class="text-xs text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 p-3 rounded overflow-x-auto"><code x-text="selectedLog.stack_trace"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('static', path='admin/js/logs.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -1,11 +1,513 @@
|
||||
<DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>System-wide marketplace monitoring</title>
|
||||
</head>
|
||||
<body>
|
||||
<-- System-wide marketplace monitoring -->
|
||||
</body>
|
||||
</html>
|
||||
{# app/templates/admin/marketplace.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Marketplace Import{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminMarketplace(){% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="/static/admin/js/marketplace.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Marketplace Import
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Import products from Letzshop marketplace for any vendor (self-service)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="refreshJobs()"
|
||||
:disabled="loading"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold" x-text="successMessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="error" x-transition class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Error</p>
|
||||
<p class="text-sm" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Form Card -->
|
||||
<div class="mb-8 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Start New Import
|
||||
</h3>
|
||||
|
||||
<form @submit.prevent="startImport()">
|
||||
<div class="grid gap-6 mb-4 md:grid-cols-2">
|
||||
<!-- Vendor Selection -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Vendor <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
x-model="importForm.vendor_id"
|
||||
@change="onVendorChange()"
|
||||
required
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
>
|
||||
<option value="">Select a vendor...</option>
|
||||
<template x-for="vendor in vendors" :key="vendor.id">
|
||||
<option :value="vendor.id" x-text="`${vendor.name} (${vendor.vendor_code})`"></option>
|
||||
</template>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Select the vendor to import products for
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- CSV URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
CSV URL <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
x-model="importForm.csv_url"
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://example.com/products.csv"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Enter the URL of the Letzshop CSV feed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Language Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Language
|
||||
</label>
|
||||
<select
|
||||
x-model="importForm.language"
|
||||
@change="onLanguageChange()"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
>
|
||||
<option value="fr">French (FR)</option>
|
||||
<option value="en">English (EN)</option>
|
||||
<option value="de">German (DE)</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Select the language of the CSV feed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Marketplace -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Marketplace
|
||||
</label>
|
||||
<input
|
||||
x-model="importForm.marketplace"
|
||||
type="text"
|
||||
readonly
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-600 border border-gray-300 dark:border-gray-600 rounded-md cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Batch Size -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Batch Size
|
||||
</label>
|
||||
<input
|
||||
x-model.number="importForm.batch_size"
|
||||
type="number"
|
||||
min="100"
|
||||
max="5000"
|
||||
step="100"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Number of products to process per batch (100-5000)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Fill Buttons (when vendor is selected) -->
|
||||
<div class="mb-4" x-show="importForm.vendor_id && selectedVendor">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Quick Fill (from vendor settings)
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="quickFill('fr')"
|
||||
x-show="selectedVendor?.letzshop_csv_url_fr"
|
||||
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
|
||||
>
|
||||
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
|
||||
French CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="quickFill('en')"
|
||||
x-show="selectedVendor?.letzshop_csv_url_en"
|
||||
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
|
||||
>
|
||||
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
|
||||
English CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="quickFill('de')"
|
||||
x-show="selectedVendor?.letzshop_csv_url_de"
|
||||
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
|
||||
>
|
||||
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
|
||||
German CSV
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400" x-show="!selectedVendor?.letzshop_csv_url_fr && !selectedVendor?.letzshop_csv_url_en && !selectedVendor?.letzshop_csv_url_de">
|
||||
This vendor has no Letzshop CSV URLs configured
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex items-center justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="importing || !importForm.csv_url || !importForm.vendor_id"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!importing" x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="importing ? 'Starting Import...' : 'Start Import'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 p-4">
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Filter by Vendor
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.vendor_id"
|
||||
@change="loadJobs()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Vendors</option>
|
||||
<template x-for="vendor in vendors" :key="vendor.id">
|
||||
<option :value="vendor.id" x-text="`${vendor.name} (${vendor.vendor_code})`"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Filter by Status
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.status"
|
||||
@change="loadJobs()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="completed_with_errors">Completed with Errors</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Filter by Marketplace
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.marketplace"
|
||||
@change="loadJobs()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Marketplaces</option>
|
||||
<option value="Letzshop">Letzshop</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
@click="clearFilters()"
|
||||
class="px-3 py-1 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Jobs List -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
My Import Jobs
|
||||
</h3>
|
||||
<a href="/admin/imports" class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300">
|
||||
View all system imports →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading import jobs...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && jobs.length === 0" class="text-center py-12">
|
||||
<span x-html="$icon('inbox', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
|
||||
<p class="text-gray-600 dark:text-gray-400">You haven't triggered any imports yet</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500">Start a new import using the form above</p>
|
||||
</div>
|
||||
|
||||
<!-- Jobs Table -->
|
||||
<div x-show="!loading && jobs.length > 0" class="w-full overflow-hidden rounded-lg shadow-xs">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full whitespace-no-wrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Job ID</th>
|
||||
<th class="px-4 py-3">Vendor</th>
|
||||
<th class="px-4 py-3">Marketplace</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Progress</th>
|
||||
<th class="px-4 py-3">Started</th>
|
||||
<th class="px-4 py-3">Duration</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="job in jobs" :key="job.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<td class="px-4 py-3 text-sm">
|
||||
#<span x-text="job.id"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="getVendorName(job.vendor_id)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.marketplace"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="{
|
||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': job.status === 'completed',
|
||||
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': job.status === 'processing',
|
||||
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': job.status === 'pending',
|
||||
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': job.status === 'failed',
|
||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': job.status === 'completed_with_errors'
|
||||
}"
|
||||
x-text="job.status.replace('_', ' ').toUpperCase()">
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<span class="text-green-600 dark:text-green-400" x-text="job.imported_count"></span> imported,
|
||||
<span class="text-blue-600 dark:text-blue-400" x-text="job.updated_count"></span> updated
|
||||
</div>
|
||||
<div x-show="job.error_count > 0" class="text-xs text-red-600 dark:text-red-400">
|
||||
<span x-text="job.error_count"></span> errors
|
||||
</div>
|
||||
<div x-show="job.total_processed > 0" class="text-xs text-gray-500 dark:text-gray-500">
|
||||
Total: <span x-text="job.total_processed"></span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.started_at ? formatDate(job.started_at) : 'Not started'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="calculateDuration(job)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="viewJobDetails(job.id)"
|
||||
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="View Details"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
x-show="job.status === 'processing' || job.status === 'pending'"
|
||||
@click="refreshJobStatus(job.id)"
|
||||
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-blue-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Refresh Status"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div x-show="!loading && totalJobs > limit" class="px-4 py-3 border-t dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-400">
|
||||
Showing <span x-text="((page - 1) * limit) + 1"></span> to
|
||||
<span x-text="Math.min(page * limit, totalJobs)"></span> of
|
||||
<span x-text="totalJobs"></span> jobs
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="previousPage()"
|
||||
:disabled="page === 1"
|
||||
class="px-3 py-1 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-md hover:bg-purple-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
@click="nextPage()"
|
||||
:disabled="page * limit >= totalJobs"
|
||||
class="px-3 py-1 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-md hover:bg-purple-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job Details Modal (same as vendor version) -->
|
||||
<div x-show="showJobModal"
|
||||
x-cloak
|
||||
@click.away="closeJobModal()"
|
||||
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0">
|
||||
<div @click.away="closeJobModal()"
|
||||
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-2xl"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 transform translate-y-1/2"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0 transform translate-y-1/2">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Import Job Details
|
||||
</h3>
|
||||
<button @click="closeJobModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-html="$icon('close', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div x-show="selectedJob" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Job ID</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.id"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Vendor</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="getVendorName(selectedJob?.vendor_id)"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Marketplace</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.marketplace"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Status</p>
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="{
|
||||
'text-green-700 bg-green-100': selectedJob?.status === 'completed',
|
||||
'text-blue-700 bg-blue-100': selectedJob?.status === 'processing',
|
||||
'text-yellow-700 bg-yellow-100': selectedJob?.status === 'pending',
|
||||
'text-red-700 bg-red-100': selectedJob?.status === 'failed',
|
||||
'text-orange-700 bg-orange-100': selectedJob?.status === 'completed_with_errors'
|
||||
}"
|
||||
x-text="selectedJob?.status.replace('_', ' ').toUpperCase()">
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Source URL</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100 truncate" x-text="selectedJob?.source_url" :title="selectedJob?.source_url"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Imported</p>
|
||||
<p class="text-sm text-green-600 dark:text-green-400" x-text="selectedJob?.imported_count"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Updated</p>
|
||||
<p class="text-sm text-blue-600 dark:text-blue-400" x-text="selectedJob?.updated_count"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Errors</p>
|
||||
<p class="text-sm text-red-600 dark:text-red-400" x-text="selectedJob?.error_count"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Total Processed</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.total_processed"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Started At</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.started_at ? formatDate(selectedJob.started_at) : 'Not started'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Completed At</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.completed_at ? formatDate(selectedJob.completed_at) : 'Not completed'"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Details -->
|
||||
<div x-show="selectedJob?.error_details && selectedJob.error_details.length > 0" class="mt-4">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Error Details</p>
|
||||
<div class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg max-h-48 overflow-y-auto">
|
||||
<pre class="text-xs text-red-700 dark:text-red-300 whitespace-pre-wrap" x-text="JSON.stringify(selectedJob.error_details, null, 2)"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="flex justify-end mt-6">
|
||||
<button
|
||||
@click="closeJobModal()"
|
||||
class="px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-700 hover:border-gray-500 focus:outline-none"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
261
app/templates/admin/settings.html
Normal file
261
app/templates/admin/settings.html
Normal file
@@ -0,0 +1,261 @@
|
||||
{# app/templates/admin/settings.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Platform Settings{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminSettings(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Platform Settings
|
||||
</h2>
|
||||
<button
|
||||
@click="refresh()"
|
||||
:disabled="loading"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Success</p>
|
||||
<p class="text-sm" x-text="successMessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="error" x-transition class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Error</p>
|
||||
<p class="text-sm" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Categories Tabs -->
|
||||
<div class="mb-6">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<button
|
||||
@click="activeTab = 'logging'"
|
||||
:class="activeTab === 'logging' ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'"
|
||||
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
>
|
||||
<span x-html="$icon('document-text', 'inline w-5 h-5 mr-2')"></span>
|
||||
Logging
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'system'"
|
||||
:class="activeTab === 'system' ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'"
|
||||
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
>
|
||||
<span x-html="$icon('cog', 'inline w-5 h-5 mr-2')"></span>
|
||||
System
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'security'"
|
||||
:class="activeTab === 'security' ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'"
|
||||
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
>
|
||||
<span x-html="$icon('shield-check', 'inline w-5 h-5 mr-2')"></span>
|
||||
Security
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logging Settings Tab -->
|
||||
<div x-show="activeTab === 'logging'" x-transition>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">
|
||||
Logging Configuration
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure application logging behavior, file rotation, and retention policies.
|
||||
</p>
|
||||
|
||||
<!-- Log Level -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Log Level
|
||||
</label>
|
||||
<select
|
||||
x-model="logSettings.log_level"
|
||||
class="block w-full md:w-1/2 px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
>
|
||||
<option value="DEBUG">DEBUG - Detailed information for diagnosing problems</option>
|
||||
<option value="INFO">INFO - General informational messages</option>
|
||||
<option value="WARNING">WARNING - Warning messages</option>
|
||||
<option value="ERROR">ERROR - Error messages</option>
|
||||
<option value="CRITICAL">CRITICAL - Critical errors only</option>
|
||||
</select>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Changes take effect immediately without restart.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- File Rotation Settings -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Max File Size (MB)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="logSettings.log_file_max_size_mb"
|
||||
min="1"
|
||||
max="1000"
|
||||
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Log file will rotate when it reaches this size.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Backup File Count
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="logSettings.log_file_backup_count"
|
||||
min="0"
|
||||
max="50"
|
||||
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Number of rotated backup files to keep.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Retention -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Database Log Retention (Days)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="logSettings.db_log_retention_days"
|
||||
min="1"
|
||||
max="365"
|
||||
class="block w-full md:w-1/2 px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Logs older than this will be automatically deleted from database.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Logging Toggles -->
|
||||
<div class="space-y-4 mb-6">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium text-gray-700 dark:text-gray-200">File Logging</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Write logs to rotating files on disk</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" x-model="logSettings.file_logging_enabled" class="sr-only peer">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-purple-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium text-gray-700 dark:text-gray-200">Database Logging</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Store WARNING/ERROR/CRITICAL logs in database for searching</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" x-model="logSettings.db_logging_enabled" class="sr-only peer">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-purple-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span x-html="$icon('information-circle', 'inline w-4 h-4 mr-1')"></span>
|
||||
File rotation settings require application restart to take effect.
|
||||
</p>
|
||||
<button
|
||||
@click="saveLogSettings()"
|
||||
:disabled="saving"
|
||||
class="px-6 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!saving">Save Logging Settings</span>
|
||||
<span x-show="saving">Saving...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mt-8">
|
||||
<div class="mb-8 p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Quick Actions
|
||||
</h4>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a
|
||||
href="/admin/logs"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
|
||||
>
|
||||
<span x-html="$icon('document-text', 'w-4 h-4 mr-2')"></span>
|
||||
View Logs
|
||||
</a>
|
||||
<button
|
||||
@click="cleanupOldLogs()"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:shadow-outline-gray"
|
||||
>
|
||||
<span x-html="$icon('delete', 'w-4 h-4 mr-2')"></span>
|
||||
Cleanup Old Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Settings Tab -->
|
||||
<div x-show="activeTab === 'system'" x-transition>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">
|
||||
System Configuration
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
General system settings and configuration options.
|
||||
</p>
|
||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('cog', 'inline w-12 h-12 mb-4')"></span>
|
||||
<p>System settings coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Settings Tab -->
|
||||
<div x-show="activeTab === 'security'" x-transition>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">
|
||||
Security Configuration
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Security and authentication settings.
|
||||
</p>
|
||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('shield-check', 'inline w-12 h-12 mb-4')"></span>
|
||||
<p>Security settings coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('static', path='admin/js/settings.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -223,7 +223,7 @@
|
||||
@click="deleteUser(user)"
|
||||
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
title="Delete"
|
||||
x-html="$icon('trash', 'w-5 h-5')"
|
||||
x-html="$icon('delete', 'w-5 h-5')"
|
||||
></button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -18,23 +18,11 @@
|
||||
<span x-text="vendor?.subdomain"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
:href="`/admin/vendors/${vendorCode}/edit`"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
|
||||
<span x-html="$icon('edit', 'w-4 h-4 mr-2')"></span>
|
||||
Edit Vendor
|
||||
</a>
|
||||
<a :href="`/admin/vendors/${vendorCode}/theme`"
|
||||
class="px-4 py-2 text-sm font-medium text-purple-700 bg-white border border-purple-600 rounded-lg hover:bg-purple-50">
|
||||
Customize Theme
|
||||
</a>
|
||||
<a href="/admin/vendors"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800 hover:border-gray-400 focus:outline-none">
|
||||
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
|
||||
Back
|
||||
</a>
|
||||
</div>
|
||||
<a href="/admin/vendors"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800 hover:border-gray-400 focus:outline-none">
|
||||
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
|
||||
Back
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
@@ -54,6 +42,27 @@
|
||||
|
||||
<!-- Vendor Details -->
|
||||
<div x-show="!loading && vendor">
|
||||
<!-- Quick Actions Card -->
|
||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Quick Actions
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
:href="`/admin/vendors/${vendorCode}/edit`"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
|
||||
<span x-html="$icon('edit', 'w-4 h-4 mr-2')"></span>
|
||||
Edit Vendor
|
||||
</a>
|
||||
<button
|
||||
@click="deleteVendor()"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-red-600 border border-transparent rounded-lg hover:bg-red-700 focus:outline-none focus:shadow-outline-red">
|
||||
<span x-html="$icon('delete', 'w-4 h-4 mr-2')"></span>
|
||||
Delete Vendor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Cards -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-4">
|
||||
<!-- Verification Status -->
|
||||
@@ -255,14 +264,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="deleteVendor()"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-red-600 border border-transparent rounded-lg hover:bg-red-700 focus:outline-none focus:shadow-outline-red">
|
||||
<span x-html="$icon('delete', 'w-4 h-4 mr-2')"></span>
|
||||
Delete Vendor
|
||||
</button>
|
||||
<!-- More Actions -->
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
More Actions
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a
|
||||
:href="`/admin/vendors/${vendorCode}/theme`"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
|
||||
>
|
||||
<span x-html="$icon('color-swatch', 'w-4 h-4 mr-2')"></span>
|
||||
Customize Theme
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -267,6 +267,70 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Marketplace Integration -->
|
||||
<div class="mb-8">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span x-html="$icon('shopping-bag', 'inline w-5 h-5 mr-2')"></span>
|
||||
Letzshop Marketplace URLs
|
||||
</h3>
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Configure CSV feed URLs for automatic product imports from Letzshop marketplace
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- French CSV URL -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
French CSV URL
|
||||
</span>
|
||||
<input
|
||||
type="url"
|
||||
x-model="formData.letzshop_csv_url_fr"
|
||||
:disabled="saving"
|
||||
placeholder="https://letzshop.lu/feed/fr/products.csv"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
|
||||
>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
URL for French language product feed
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- English CSV URL -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
English CSV URL
|
||||
</span>
|
||||
<input
|
||||
type="url"
|
||||
x-model="formData.letzshop_csv_url_en"
|
||||
:disabled="saving"
|
||||
placeholder="https://letzshop.lu/feed/en/products.csv"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
|
||||
>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
URL for English language product feed
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- German CSV URL -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
German CSV URL
|
||||
</span>
|
||||
<input
|
||||
type="url"
|
||||
x-model="formData.letzshop_csv_url_de"
|
||||
:disabled="saving"
|
||||
placeholder="https://letzshop.lu/feed/de/products.csv"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
|
||||
>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
URL for German language product feed
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex items-center justify-end gap-3 pt-6 border-t dark:border-gray-700">
|
||||
<a
|
||||
|
||||
99
app/templates/admin/vendor-themes.html
Normal file
99
app/templates/admin/vendor-themes.html
Normal file
@@ -0,0 +1,99 @@
|
||||
{# app/templates/admin/vendor-themes.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Vendor Themes{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminVendorThemes(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Vendor Themes
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Customize vendor theme colors and branding
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vendor Selection -->
|
||||
<div class="px-4 py-6 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Select Vendor
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Choose a vendor to customize their theme
|
||||
</p>
|
||||
|
||||
<div class="max-w-md">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Vendor
|
||||
</label>
|
||||
<select
|
||||
x-model="selectedVendorCode"
|
||||
@change="navigateToTheme()"
|
||||
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
>
|
||||
<option value="">Select a vendor...</option>
|
||||
<template x-for="vendor in vendors" :key="vendor.vendor_code">
|
||||
<option :value="vendor.vendor_code" x-text="`${vendor.name} (${vendor.vendor_code})`"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading vendors...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div x-show="error && !loading" class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Error loading vendors</p>
|
||||
<p class="text-sm" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vendors List -->
|
||||
<div x-show="!loading && vendors.length > 0">
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
All Vendors
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<template x-for="vendor in vendors" :key="vendor.vendor_code">
|
||||
<a
|
||||
:href="`/admin/vendors/${vendor.vendor_code}/theme`"
|
||||
class="block p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-purple-500 dark:hover:border-purple-500 hover:shadow-md transition-all"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="font-semibold text-gray-700 dark:text-gray-200" x-text="vendor.name"></h4>
|
||||
<span x-html="$icon('color-swatch', 'w-5 h-5 text-purple-600')"></span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400" x-text="vendor.vendor_code"></p>
|
||||
<div class="mt-3 flex items-center text-xs text-purple-600 dark:text-purple-400">
|
||||
<span>Customize theme</span>
|
||||
<span x-html="$icon('chevron-right', 'w-4 h-4 ml-1')"></span>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && vendors.length === 0" class="text-center py-12">
|
||||
<span x-html="$icon('shopping-bag', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
|
||||
<p class="text-gray-600 dark:text-gray-400">No vendors found</p>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('static', path='admin/js/vendor-themes.js') }}"></script>
|
||||
{% endblock %}
|
||||
421
app/templates/vendor/marketplace.html
vendored
421
app/templates/vendor/marketplace.html
vendored
@@ -1,31 +1,416 @@
|
||||
{# app/templates/vendor/marketplace.html #}
|
||||
{% extends "vendor/base.html" %}
|
||||
|
||||
{% block title %}Marketplace{% endblock %}
|
||||
{% block title %}Marketplace Import{% endblock %}
|
||||
|
||||
{% block alpine_data %}data(){% endblock %}
|
||||
{% block alpine_data %}vendorMarketplace(){% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="/static/vendor/js/marketplace.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Marketplace Import
|
||||
</h2>
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Marketplace Import
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Import products from Letzshop marketplace CSV feeds
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="refreshJobs()"
|
||||
:disabled="loading"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Coming Soon Notice -->
|
||||
<div class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
|
||||
<div class="w-full p-12 bg-white dark:bg-gray-800 text-center">
|
||||
<div class="text-6xl mb-4">🌐</div>
|
||||
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
|
||||
Marketplace Import Coming Soon
|
||||
<!-- Success Message -->
|
||||
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold" x-text="successMessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="error" x-transition class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Error</p>
|
||||
<p class="text-sm" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Form Card -->
|
||||
<div class="mb-8 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Start New Import
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
This page is under development. You'll be able to import products from marketplace here.
|
||||
</p>
|
||||
<a href="/vendor/{{ vendor_code }}/dashboard"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
|
||||
Back to Dashboard
|
||||
</a>
|
||||
|
||||
<form @submit.prevent="startImport()">
|
||||
<div class="grid gap-6 mb-4 md:grid-cols-2">
|
||||
<!-- CSV URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
CSV URL <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
x-model="importForm.csv_url"
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://example.com/products.csv"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Enter the URL of the Letzshop CSV feed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Language Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Language
|
||||
</label>
|
||||
<select
|
||||
x-model="importForm.language"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
>
|
||||
<option value="fr">French (FR)</option>
|
||||
<option value="en">English (EN)</option>
|
||||
<option value="de">German (DE)</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Select the language of the CSV feed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Marketplace -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Marketplace
|
||||
</label>
|
||||
<input
|
||||
x-model="importForm.marketplace"
|
||||
type="text"
|
||||
readonly
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-600 border border-gray-300 dark:border-gray-600 rounded-md cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Batch Size -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Batch Size
|
||||
</label>
|
||||
<input
|
||||
x-model.number="importForm.batch_size"
|
||||
type="number"
|
||||
min="100"
|
||||
max="5000"
|
||||
step="100"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Number of products to process per batch (100-5000)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Fill Buttons -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Quick Fill
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="quickFill('fr')"
|
||||
x-show="vendorSettings.letzshop_csv_url_fr"
|
||||
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
|
||||
>
|
||||
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
|
||||
French CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="quickFill('en')"
|
||||
x-show="vendorSettings.letzshop_csv_url_en"
|
||||
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
|
||||
>
|
||||
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
|
||||
English CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="quickFill('de')"
|
||||
x-show="vendorSettings.letzshop_csv_url_de"
|
||||
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
|
||||
>
|
||||
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
|
||||
German CSV
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400" x-show="!vendorSettings.letzshop_csv_url_fr && !vendorSettings.letzshop_csv_url_en && !vendorSettings.letzshop_csv_url_de">
|
||||
Configure Letzshop CSV URLs in settings to use quick fill
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex items-center justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="importing || !importForm.csv_url"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!importing" x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="importing ? 'Starting Import...' : 'Start Import'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Jobs List -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Import History
|
||||
</h3>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading import jobs...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && jobs.length === 0" class="text-center py-12">
|
||||
<span x-html="$icon('inbox', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
|
||||
<p class="text-gray-600 dark:text-gray-400">No import jobs yet</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500">Start your first import using the form above</p>
|
||||
</div>
|
||||
|
||||
<!-- Jobs Table -->
|
||||
<div x-show="!loading && jobs.length > 0" class="w-full overflow-hidden rounded-lg shadow-xs">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full whitespace-no-wrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Job ID</th>
|
||||
<th class="px-4 py-3">Marketplace</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Progress</th>
|
||||
<th class="px-4 py-3">Started</th>
|
||||
<th class="px-4 py-3">Duration</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="job in jobs" :key="job.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<td class="px-4 py-3 text-sm">
|
||||
#<span x-text="job.id"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.marketplace"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="{
|
||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': job.status === 'completed',
|
||||
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': job.status === 'processing',
|
||||
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': job.status === 'pending',
|
||||
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': job.status === 'failed',
|
||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': job.status === 'completed_with_errors'
|
||||
}"
|
||||
x-text="job.status.replace('_', ' ').toUpperCase()">
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<span class="text-green-600 dark:text-green-400" x-text="job.imported_count"></span> imported,
|
||||
<span class="text-blue-600 dark:text-blue-400" x-text="job.updated_count"></span> updated
|
||||
</div>
|
||||
<div x-show="job.error_count > 0" class="text-xs text-red-600 dark:text-red-400">
|
||||
<span x-text="job.error_count"></span> errors
|
||||
</div>
|
||||
<div x-show="job.total_processed > 0" class="text-xs text-gray-500 dark:text-gray-500">
|
||||
Total: <span x-text="job.total_processed"></span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.started_at ? formatDate(job.started_at) : 'Not started'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="calculateDuration(job)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="viewJobDetails(job.id)"
|
||||
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="View Details"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
x-show="job.status === 'processing' || job.status === 'pending'"
|
||||
@click="refreshJobStatus(job.id)"
|
||||
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-blue-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Refresh Status"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div x-show="!loading && totalJobs > limit" class="px-4 py-3 border-t dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-400">
|
||||
Showing <span x-text="((page - 1) * limit) + 1"></span> to
|
||||
<span x-text="Math.min(page * limit, totalJobs)"></span> of
|
||||
<span x-text="totalJobs"></span> jobs
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="previousPage()"
|
||||
:disabled="page === 1"
|
||||
class="px-3 py-1 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-md hover:bg-purple-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
@click="nextPage()"
|
||||
:disabled="page * limit >= totalJobs"
|
||||
class="px-3 py-1 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-md hover:bg-purple-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job Details Modal -->
|
||||
<div x-show="showJobModal"
|
||||
x-cloak
|
||||
@click.away="closeJobModal()"
|
||||
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0">
|
||||
<div @click.away="closeJobModal()"
|
||||
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-2xl"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 transform translate-y-1/2"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0 transform translate-y-1/2">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Import Job Details
|
||||
</h3>
|
||||
<button @click="closeJobModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-html="$icon('close', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div x-show="selectedJob" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Job ID</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.id"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Marketplace</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.marketplace"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Status</p>
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="{
|
||||
'text-green-700 bg-green-100': selectedJob?.status === 'completed',
|
||||
'text-blue-700 bg-blue-100': selectedJob?.status === 'processing',
|
||||
'text-yellow-700 bg-yellow-100': selectedJob?.status === 'pending',
|
||||
'text-red-700 bg-red-100': selectedJob?.status === 'failed',
|
||||
'text-orange-700 bg-orange-100': selectedJob?.status === 'completed_with_errors'
|
||||
}"
|
||||
x-text="selectedJob?.status.replace('_', ' ').toUpperCase()">
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Source URL</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100 truncate" x-text="selectedJob?.source_url" :title="selectedJob?.source_url"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Imported</p>
|
||||
<p class="text-sm text-green-600 dark:text-green-400" x-text="selectedJob?.imported_count"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Updated</p>
|
||||
<p class="text-sm text-blue-600 dark:text-blue-400" x-text="selectedJob?.updated_count"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Errors</p>
|
||||
<p class="text-sm text-red-600 dark:text-red-400" x-text="selectedJob?.error_count"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Total Processed</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.total_processed"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Started At</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.started_at ? formatDate(selectedJob.started_at) : 'Not started'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Completed At</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.completed_at ? formatDate(selectedJob.completed_at) : 'Not completed'"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Details -->
|
||||
<div x-show="selectedJob?.error_details && selectedJob.error_details.length > 0" class="mt-4">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Error Details</p>
|
||||
<div class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg max-h-48 overflow-y-auto">
|
||||
<pre class="text-xs text-red-700 dark:text-red-300 whitespace-pre-wrap" x-text="JSON.stringify(selectedJob.error_details, null, 2)"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="flex justify-end mt-6">
|
||||
<button
|
||||
@click="closeJobModal()"
|
||||
class="px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-700 hover:border-gray-500 focus:outline-none"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -221,6 +221,101 @@ async def create_vendor(
|
||||
return result
|
||||
```
|
||||
|
||||
### Rule API-005: Vendor Context from Token (Not URL)
|
||||
|
||||
**Vendor API endpoints MUST extract vendor context from JWT token, NOT from URL.**
|
||||
|
||||
> **Rationale:** Embedding vendor context in JWT tokens enables clean RESTful API endpoints, eliminates URL-based vendor detection issues, and improves security by cryptographically signing vendor access.
|
||||
|
||||
**❌ BAD: URL-based vendor detection**
|
||||
|
||||
```python
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
|
||||
@router.get("/products")
|
||||
def get_products(
|
||||
vendor: Vendor = Depends(require_vendor_context()), # ❌ Requires vendor in URL
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
# This fails on /api/v1/vendor/products (no vendor in URL)
|
||||
products = product_service.get_vendor_products(db, vendor.id)
|
||||
return products
|
||||
```
|
||||
|
||||
**Issues with URL-based approach:**
|
||||
- ❌ Only works with routes like `/vendor/{vendor_code}/dashboard`
|
||||
- ❌ Fails on API routes like `/api/v1/vendor/products` (no vendor in URL)
|
||||
- ❌ Inconsistent between page routes and API routes
|
||||
- ❌ Violates RESTful API design
|
||||
- ❌ Requires database lookup on every request
|
||||
|
||||
**✅ GOOD: Token-based vendor context**
|
||||
|
||||
```python
|
||||
@router.get("/products")
|
||||
def get_products(
|
||||
current_user: User = Depends(get_current_vendor_api), # ✅ Vendor in token
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
# Extract vendor from JWT token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Use vendor_id from token
|
||||
products = product_service.get_vendor_products(db, vendor_id)
|
||||
return products
|
||||
```
|
||||
|
||||
**Benefits of token-based approach:**
|
||||
- ✅ Works on all routes (page and API)
|
||||
- ✅ Clean RESTful API endpoints
|
||||
- ✅ Vendor context cryptographically signed in JWT
|
||||
- ✅ No database lookup needed for vendor detection
|
||||
- ✅ Consistent authentication mechanism
|
||||
- ✅ Security: Cannot be tampered with by client
|
||||
|
||||
**Token structure:**
|
||||
```json
|
||||
{
|
||||
"sub": "user_id",
|
||||
"username": "john.doe",
|
||||
"vendor_id": 123, ← Vendor context
|
||||
"vendor_code": "WIZAMART", ← Vendor code
|
||||
"vendor_role": "Owner" ← Vendor role
|
||||
}
|
||||
```
|
||||
|
||||
**Available token attributes:**
|
||||
- `current_user.token_vendor_id` - Vendor ID (use for database queries)
|
||||
- `current_user.token_vendor_code` - Vendor code (use for logging)
|
||||
- `current_user.token_vendor_role` - Vendor role (Owner, Manager, etc.)
|
||||
|
||||
**Migration checklist:**
|
||||
1. Remove `vendor: Vendor = Depends(require_vendor_context())`
|
||||
2. Remove unused imports: `from middleware.vendor_context import require_vendor_context`
|
||||
3. Extract vendor from token: `vendor_id = current_user.token_vendor_id`
|
||||
4. Add token validation check (see example above)
|
||||
5. Update logging to use `current_user.token_vendor_code`
|
||||
|
||||
**See also:** `docs/backend/vendor-in-token-architecture.md` for complete migration guide
|
||||
|
||||
**Files requiring migration:**
|
||||
- `app/api/v1/vendor/customers.py`
|
||||
- `app/api/v1/vendor/notifications.py`
|
||||
- `app/api/v1/vendor/media.py`
|
||||
- `app/api/v1/vendor/marketplace.py`
|
||||
- `app/api/v1/vendor/inventory.py`
|
||||
- `app/api/v1/vendor/settings.py`
|
||||
- `app/api/v1/vendor/analytics.py`
|
||||
- `app/api/v1/vendor/payments.py`
|
||||
- `app/api/v1/vendor/profile.py`
|
||||
|
||||
---
|
||||
|
||||
## Service Layer Patterns
|
||||
|
||||
545
docs/architecture/frontend-structure.md
Normal file
545
docs/architecture/frontend-structure.md
Normal file
@@ -0,0 +1,545 @@
|
||||
# Frontend Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
This application has **4 distinct frontends**, each with its own templates and static assets:
|
||||
|
||||
1. **Platform** - Public platform pages (homepage, about, contact)
|
||||
2. **Admin** - Administrative control panel
|
||||
3. **Vendor** - Vendor management portal
|
||||
4. **Shop** - Customer-facing e-commerce store
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── templates/
|
||||
│ ├── platform/ # Platform public pages
|
||||
│ ├── admin/ # Admin portal pages
|
||||
│ ├── vendor/ # Vendor portal pages
|
||||
│ ├── shop/ # Shop customer pages
|
||||
│ └── shared/ # Shared components (emails, errors)
|
||||
│
|
||||
└── static/
|
||||
├── platform/ # Platform static assets
|
||||
│ ├── js/
|
||||
│ ├── css/
|
||||
│ └── img/
|
||||
├── admin/ # Admin static assets
|
||||
│ ├── js/
|
||||
│ ├── css/
|
||||
│ └── img/
|
||||
├── vendor/ # Vendor static assets
|
||||
│ ├── js/
|
||||
│ ├── css/
|
||||
│ └── img/
|
||||
├── shop/ # Shop static assets
|
||||
│ ├── js/
|
||||
│ ├── css/
|
||||
│ └── img/
|
||||
└── shared/ # Shared assets (icons, utilities)
|
||||
├── js/
|
||||
├── css/
|
||||
└── img/
|
||||
```
|
||||
|
||||
## Frontend Details
|
||||
|
||||
### 1. Platform Frontend
|
||||
|
||||
**Purpose:** Public-facing platform pages (marketing, info pages)
|
||||
|
||||
**Location:**
|
||||
- Templates: `app/templates/platform/`
|
||||
- Static: `static/platform/`
|
||||
|
||||
**Pages:**
|
||||
- Homepage (multiple layouts: default, minimal, modern)
|
||||
- Content pages (about, privacy, terms)
|
||||
- Landing pages
|
||||
|
||||
**Features:**
|
||||
- SEO-optimized
|
||||
- Multi-layout homepage support
|
||||
- Content management system integration
|
||||
- Responsive design
|
||||
|
||||
**Routes:** `/`, `/about`, `/contact`, etc.
|
||||
|
||||
**Authentication:** Not required (public access)
|
||||
|
||||
---
|
||||
|
||||
### 2. Admin Frontend
|
||||
|
||||
**Purpose:** Platform administration and management
|
||||
|
||||
**Location:**
|
||||
- Templates: `app/templates/admin/`
|
||||
- Static: `static/admin/`
|
||||
|
||||
**Pages:**
|
||||
- Dashboard
|
||||
- Vendor management
|
||||
- User management
|
||||
- Content management
|
||||
- Theme customization
|
||||
- System settings
|
||||
- Logs and monitoring
|
||||
- Code quality dashboard
|
||||
|
||||
**Technology Stack:**
|
||||
- Alpine.js for reactive components
|
||||
- Tailwind CSS for styling
|
||||
- Heroicons for icons
|
||||
- Centralized logging system
|
||||
- API-driven architecture
|
||||
|
||||
**Routes:** `/admin/*`
|
||||
|
||||
**Authentication:** Admin role required
|
||||
|
||||
---
|
||||
|
||||
### 3. Vendor Frontend
|
||||
|
||||
**Purpose:** Vendor portal for product and order management
|
||||
|
||||
**Location:**
|
||||
- Templates: `app/templates/vendor/`
|
||||
- Static: `static/vendor/`
|
||||
|
||||
**Pages:**
|
||||
- Vendor dashboard
|
||||
- Product management
|
||||
- Inventory management
|
||||
- Order management
|
||||
- Analytics
|
||||
- Profile settings
|
||||
|
||||
**Technology Stack:**
|
||||
- Alpine.js for reactive components
|
||||
- Tailwind CSS for styling
|
||||
- Heroicons for icons
|
||||
- API-driven architecture
|
||||
- Vendor context middleware
|
||||
|
||||
**Routes:** `/vendor/{vendor_code}/*`
|
||||
|
||||
**Authentication:** Vendor role required
|
||||
|
||||
---
|
||||
|
||||
### 4. Shop Frontend
|
||||
|
||||
**Purpose:** Customer-facing e-commerce store
|
||||
|
||||
**Location:**
|
||||
- Templates: `app/templates/shop/`
|
||||
- Static: `static/shop/`
|
||||
|
||||
**Pages:**
|
||||
- Product catalog
|
||||
- Product details
|
||||
- Shopping cart
|
||||
- Checkout
|
||||
- Order tracking
|
||||
- Customer account
|
||||
|
||||
**Technology Stack:**
|
||||
- Alpine.js for interactive features
|
||||
- Tailwind CSS for styling
|
||||
- E-commerce specific components
|
||||
- Payment integration
|
||||
- Shopping cart management
|
||||
|
||||
**Routes:** `/shop/*`
|
||||
|
||||
**Authentication:** Optional (required for checkout)
|
||||
|
||||
---
|
||||
|
||||
## Using Static Assets
|
||||
|
||||
Each frontend has its own static directory for frontend-specific assets. Use the appropriate directory based on which frontend the asset belongs to.
|
||||
|
||||
### Platform Static Assets (`static/platform/`)
|
||||
|
||||
**JavaScript Files:**
|
||||
```html
|
||||
<!-- In platform templates -->
|
||||
<script src="{{ url_for('static', path='platform/js/homepage.js') }}"></script>
|
||||
<script src="{{ url_for('static', path='platform/js/animations.js') }}"></script>
|
||||
```
|
||||
|
||||
**CSS Files:**
|
||||
```html
|
||||
<link href="{{ url_for('static', path='platform/css/styles.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', path='platform/css/landing.css') }}" rel="stylesheet">
|
||||
```
|
||||
|
||||
**Images:**
|
||||
```html
|
||||
<img src="{{ url_for('static', path='platform/img/hero-banner.jpg') }}" alt="Hero">
|
||||
<img src="{{ url_for('static', path='platform/img/features/feature-1.svg') }}" alt="Feature">
|
||||
```
|
||||
|
||||
**Current Usage:** Platform currently uses only shared assets (fonts, Tailwind CSS). Platform-specific directories are ready for future platform-specific assets.
|
||||
|
||||
---
|
||||
|
||||
### Admin Static Assets (`static/admin/`)
|
||||
|
||||
**JavaScript Files:**
|
||||
```html
|
||||
<!-- In admin templates -->
|
||||
<script src="{{ url_for('static', path='admin/js/dashboard.js') }}"></script>
|
||||
<script src="{{ url_for('static', path='admin/js/vendors.js') }}"></script>
|
||||
```
|
||||
|
||||
**CSS Files:**
|
||||
```html
|
||||
<link href="{{ url_for('static', path='admin/css/custom.css') }}" rel="stylesheet">
|
||||
```
|
||||
|
||||
**Images:**
|
||||
```html
|
||||
<img src="{{ url_for('static', path='admin/img/placeholder.png') }}" alt="Placeholder">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Vendor Static Assets (`static/vendor/`)
|
||||
|
||||
**JavaScript Files:**
|
||||
```html
|
||||
<!-- In vendor templates -->
|
||||
<script src="{{ url_for('static', path='vendor/js/dashboard.js') }}"></script>
|
||||
<script src="{{ url_for('static', path='vendor/js/products.js') }}"></script>
|
||||
```
|
||||
|
||||
**CSS Files:**
|
||||
```html
|
||||
<link href="{{ url_for('static', path='vendor/css/custom.css') }}" rel="stylesheet">
|
||||
```
|
||||
|
||||
**Images:**
|
||||
```html
|
||||
<img src="{{ url_for('static', path='vendor/img/no-products.svg') }}" alt="No Products">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Shop Static Assets (`static/shop/`)
|
||||
|
||||
**JavaScript Files:**
|
||||
```html
|
||||
<!-- In shop templates -->
|
||||
<script src="{{ url_for('static', path='shop/js/cart.js') }}"></script>
|
||||
<script src="{{ url_for('static', path='shop/js/checkout.js') }}"></script>
|
||||
```
|
||||
|
||||
**CSS Files:**
|
||||
```html
|
||||
<link href="{{ url_for('static', path='shop/css/product-gallery.css') }}" rel="stylesheet">
|
||||
```
|
||||
|
||||
**Images:**
|
||||
```html
|
||||
<img src="{{ url_for('static', path='shop/img/placeholder-product.jpg') }}" alt="Product">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### When to Use Shared vs. Frontend-Specific
|
||||
|
||||
**Use `static/shared/` when:**
|
||||
- Asset is used by 2 or more frontends
|
||||
- Common utilities (icons, API client, utilities)
|
||||
- Brand assets (logos, favicons)
|
||||
- Core libraries (Alpine.js, Tailwind CSS fallbacks)
|
||||
|
||||
**Use `static/{frontend}/` when:**
|
||||
- Asset is only used by one specific frontend
|
||||
- Frontend-specific styling
|
||||
- Frontend-specific JavaScript components
|
||||
- Frontend-specific images/graphics
|
||||
|
||||
**Example Decision Tree:**
|
||||
```
|
||||
Icon system (used by all 4 frontends) → static/shared/js/icons.js
|
||||
Admin dashboard chart → static/admin/js/charts.js
|
||||
Vendor product form → static/vendor/js/product-form.js
|
||||
Platform hero image → static/platform/img/hero.jpg
|
||||
Shop product carousel → static/shop/js/carousel.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shared Resources
|
||||
|
||||
### Templates (`app/templates/shared/`)
|
||||
|
||||
**Shared components used across multiple frontends:**
|
||||
- Email templates
|
||||
- Error pages (404, 500)
|
||||
- Common partials
|
||||
|
||||
### Static Assets (`static/shared/`)
|
||||
|
||||
**Shared JavaScript:**
|
||||
- `js/icons.js` - Heroicons system (used by all frontends)
|
||||
- `js/utils.js` - Common utilities
|
||||
- `js/api-client.js` - API communication
|
||||
- `js/log-config.js` - Centralized logging
|
||||
|
||||
**Shared CSS:**
|
||||
- Common utility classes
|
||||
- Shared theme variables
|
||||
|
||||
**Shared Images:**
|
||||
- Logos
|
||||
- Brand assets
|
||||
- Icons
|
||||
|
||||
---
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
### 1. Separation of Concerns
|
||||
|
||||
Each frontend is completely isolated:
|
||||
- Own templates directory
|
||||
- Own static assets directory
|
||||
- Own JavaScript components
|
||||
- Own CSS styles
|
||||
|
||||
**Benefits:**
|
||||
- Clear boundaries
|
||||
- Independent development
|
||||
- No cross-contamination
|
||||
- Easy to maintain
|
||||
|
||||
### 2. Shared Core
|
||||
|
||||
Common functionality is shared via `static/shared/`:
|
||||
- Icon system
|
||||
- API client
|
||||
- Utilities
|
||||
- Logging
|
||||
|
||||
**Benefits:**
|
||||
- DRY principle
|
||||
- Consistent behavior
|
||||
- Single source of truth
|
||||
- Easy updates
|
||||
|
||||
### 3. Template Inheritance
|
||||
|
||||
Each frontend has a base template:
|
||||
- `platform/base.html`
|
||||
- `admin/base.html`
|
||||
- `vendor/base.html`
|
||||
- `shop/base.html`
|
||||
|
||||
**Benefits:**
|
||||
- Consistent layout within frontend
|
||||
- Easy to customize per frontend
|
||||
- Different design systems possible
|
||||
|
||||
### 4. API-Driven
|
||||
|
||||
All frontends communicate with backend via APIs:
|
||||
- `/api/v1/admin/*` - Admin APIs
|
||||
- `/api/v1/vendor/*` - Vendor APIs
|
||||
- `/api/v1/shop/*` - Shop APIs
|
||||
- `/api/v1/platform/*` - Platform APIs
|
||||
|
||||
**Benefits:**
|
||||
- Clear backend contracts
|
||||
- Testable independently
|
||||
- Can be replaced with SPA if needed
|
||||
- Mobile app ready
|
||||
|
||||
---
|
||||
|
||||
## Frontend Technology Matrix
|
||||
|
||||
| Frontend | Framework | CSS | Icons | Auth Required | Base URL |
|
||||
|----------|-----------|-----------|------------|---------------|-------------------|
|
||||
| Platform | Alpine.js | Tailwind | Heroicons | No | `/` |
|
||||
| Admin | Alpine.js | Tailwind | Heroicons | Yes (Admin) | `/admin` |
|
||||
| Vendor | Alpine.js | Tailwind | Heroicons | Yes (Vendor) | `/vendor/{code}` |
|
||||
| Shop | Alpine.js | Tailwind | Heroicons | Optional | `/shop` |
|
||||
|
||||
---
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Adding a New Page
|
||||
|
||||
1. **Determine which frontend** the page belongs to
|
||||
2. **Create template** in appropriate `app/templates/{frontend}/` directory
|
||||
3. **Create JavaScript** (if needed) in `static/{frontend}/js/`
|
||||
4. **Create CSS** (if needed) in `static/{frontend}/css/`
|
||||
5. **Add route** in appropriate route handler
|
||||
6. **Update navigation** in frontend's base template
|
||||
|
||||
### Using Shared Resources
|
||||
|
||||
**Icons:**
|
||||
```html
|
||||
<span x-html="$icon('icon-name', 'w-5 h-5')"></span>
|
||||
```
|
||||
|
||||
**API Client:**
|
||||
```javascript
|
||||
const data = await apiClient.get('/api/v1/admin/users');
|
||||
```
|
||||
|
||||
**Utilities:**
|
||||
```javascript
|
||||
Utils.showToast('Success!', 'success');
|
||||
Utils.formatDate(dateString);
|
||||
```
|
||||
|
||||
**Logging:**
|
||||
```javascript
|
||||
const log = window.LogConfig.loggers.myPage;
|
||||
log.info('Page loaded');
|
||||
```
|
||||
|
||||
### Frontend-Specific Resources
|
||||
|
||||
**Platform-specific JavaScript:**
|
||||
```html
|
||||
<script src="{{ url_for('static', path='platform/js/homepage.js') }}"></script>
|
||||
```
|
||||
|
||||
**Admin-specific CSS:**
|
||||
```html
|
||||
<link href="{{ url_for('static', path='admin/css/dashboard.css') }}" rel="stylesheet">
|
||||
```
|
||||
|
||||
**Vendor-specific images:**
|
||||
```html
|
||||
<img src="{{ url_for('static', path='vendor/img/logo.png') }}">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes
|
||||
|
||||
### Moving Assets Between Frontends
|
||||
|
||||
If an asset is used by multiple frontends:
|
||||
1. **Move to `static/shared/`**
|
||||
2. **Update all references**
|
||||
3. **Test all affected frontends**
|
||||
|
||||
If an asset is only used by one frontend:
|
||||
1. **Move to `static/{frontend}/`**
|
||||
2. **Update references in that frontend only**
|
||||
|
||||
### Deprecation Path
|
||||
|
||||
When removing a frontend:
|
||||
1. Remove `app/templates/{frontend}/`
|
||||
2. Remove `static/{frontend}/`
|
||||
3. Remove routes
|
||||
4. Update documentation
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Potential Additional Frontends
|
||||
|
||||
- **Partner Portal** - For business partners/affiliates
|
||||
- **API Documentation** - Interactive API docs (Swagger UI)
|
||||
- **Mobile App** - Native mobile using existing APIs
|
||||
|
||||
### Frontend Modernization
|
||||
|
||||
Each frontend can be independently modernized:
|
||||
- Replace Alpine.js with React/Vue/Svelte
|
||||
- Add TypeScript
|
||||
- Implement SSR/SSG
|
||||
- Convert to PWA
|
||||
|
||||
The API-driven architecture allows this flexibility.
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Per-Frontend Testing
|
||||
|
||||
Each frontend should have:
|
||||
- **Unit tests** for JavaScript components
|
||||
- **Integration tests** for API interactions
|
||||
- **E2E tests** for critical user flows
|
||||
- **Accessibility tests**
|
||||
- **Responsive design tests**
|
||||
|
||||
### Shared Resource Testing
|
||||
|
||||
Shared resources need:
|
||||
- **Unit tests** for utilities
|
||||
- **Integration tests** with all frontends
|
||||
- **Visual regression tests** for icons
|
||||
|
||||
---
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Per-Frontend Optimization
|
||||
|
||||
Each frontend can optimize independently:
|
||||
- Code splitting
|
||||
- Lazy loading
|
||||
- Asset minification
|
||||
- CDN deployment
|
||||
- Browser caching
|
||||
|
||||
### Shared Resource Optimization
|
||||
|
||||
Shared resources are cached globally:
|
||||
- Long cache headers
|
||||
- Versioning via query params
|
||||
- CDN distribution
|
||||
- Compression
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Frontend-Specific Security
|
||||
|
||||
Each frontend has different security needs:
|
||||
- **Platform:** XSS protection, CSP
|
||||
- **Admin:** CSRF tokens, admin-only routes
|
||||
- **Vendor:** Vendor isolation, rate limiting
|
||||
- **Shop:** PCI compliance, secure checkout
|
||||
|
||||
### Shared Security
|
||||
|
||||
All frontends use:
|
||||
- JWT authentication
|
||||
- HTTPS only
|
||||
- Secure headers
|
||||
- Input sanitization
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The 4-frontend architecture provides:
|
||||
- ✅ Clear separation of concerns
|
||||
- ✅ Independent development and deployment
|
||||
- ✅ Shared core functionality
|
||||
- ✅ Flexibility for future changes
|
||||
- ✅ Optimized for each user type
|
||||
- ✅ Maintainable and scalable
|
||||
|
||||
Each frontend serves a specific purpose and audience, with shared infrastructure for common needs.
|
||||
469
docs/architecture/models-structure.md
Normal file
469
docs/architecture/models-structure.md
Normal file
@@ -0,0 +1,469 @@
|
||||
# Models Structure
|
||||
|
||||
## Overview
|
||||
|
||||
This project follows a **standardized models structure** at the root level, separating database models from Pydantic schemas.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
models/
|
||||
├── database/ # SQLAlchemy database models (ORM)
|
||||
│ ├── __init__.py
|
||||
│ ├── user.py
|
||||
│ ├── vendor.py
|
||||
│ ├── product.py
|
||||
│ ├── order.py
|
||||
│ ├── admin.py
|
||||
│ ├── architecture_scan.py
|
||||
│ └── ...
|
||||
│
|
||||
└── schema/ # Pydantic schemas (API validation)
|
||||
├── __init__.py
|
||||
├── auth.py
|
||||
├── admin.py
|
||||
├── product.py
|
||||
├── order.py
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Important Rules
|
||||
|
||||
### ✅ DO: Use Root-Level Models
|
||||
|
||||
**ALL models must be in the root `models/` directory:**
|
||||
- Database models → `models/database/`
|
||||
- Pydantic schemas → `models/schema/`
|
||||
|
||||
### ❌ DON'T: Create `app/models/`
|
||||
|
||||
**NEVER create or use `app/models/` directory.**
|
||||
|
||||
The application structure is:
|
||||
```
|
||||
app/ # Application code (routes, services, core)
|
||||
models/ # Models (database & schemas)
|
||||
```
|
||||
|
||||
NOT:
|
||||
```
|
||||
app/
|
||||
models/ # ❌ WRONG - Don't create this!
|
||||
models/ # ✓ Correct location
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Models (`models/database/`)
|
||||
|
||||
### Purpose
|
||||
SQLAlchemy ORM models that represent database tables.
|
||||
|
||||
### Naming Convention
|
||||
- Singular class names: `User`, `Product`, `Order`
|
||||
- File names match class: `user.py`, `product.py`, `order.py`
|
||||
|
||||
### Example Structure
|
||||
|
||||
**File:** `models/database/product.py`
|
||||
```python
|
||||
"""Product database model"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Float, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class Product(Base):
|
||||
"""Product database model"""
|
||||
|
||||
__tablename__ = "products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
price = Column(Float, nullable=False)
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"))
|
||||
|
||||
# Relationships
|
||||
vendor = relationship("Vendor", back_populates="products")
|
||||
```
|
||||
|
||||
### Exporting Models
|
||||
|
||||
All database models must be exported in `models/database/__init__.py`:
|
||||
|
||||
```python
|
||||
# models/database/__init__.py
|
||||
from .user import User
|
||||
from .vendor import Vendor
|
||||
from .product import Product
|
||||
from .order import Order, OrderItem
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Vendor",
|
||||
"Product",
|
||||
"Order",
|
||||
"OrderItem",
|
||||
]
|
||||
```
|
||||
|
||||
### Importing Database Models
|
||||
|
||||
```python
|
||||
# ✅ CORRECT - Import from models.database
|
||||
from models.database import User, Product
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
# ❌ WRONG - Don't import from app.models
|
||||
from app.models.user import User # This path doesn't exist!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pydantic Schemas (`models/schema/`)
|
||||
|
||||
### Purpose
|
||||
Pydantic models for API request/response validation and serialization.
|
||||
|
||||
### Naming Convention
|
||||
- Use descriptive suffixes: `Create`, `Update`, `Response`, `InDB`
|
||||
- Group related schemas in same file
|
||||
- File names match domain: `auth.py`, `product.py`, `order.py`
|
||||
|
||||
### Example Structure
|
||||
|
||||
**File:** `models/schema/product.py`
|
||||
```python
|
||||
"""Product Pydantic schemas"""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ProductBase(BaseModel):
|
||||
"""Base product schema"""
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
price: float = Field(..., gt=0)
|
||||
|
||||
|
||||
class ProductCreate(ProductBase):
|
||||
"""Schema for creating a product"""
|
||||
vendor_id: int
|
||||
|
||||
|
||||
class ProductUpdate(BaseModel):
|
||||
"""Schema for updating a product"""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
price: Optional[float] = Field(None, gt=0)
|
||||
|
||||
|
||||
class ProductResponse(ProductBase):
|
||||
"""Schema for product API response"""
|
||||
id: int
|
||||
vendor_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True # Pydantic v2
|
||||
# orm_mode = True # Pydantic v1
|
||||
```
|
||||
|
||||
### Exporting Schemas
|
||||
|
||||
Export schemas in `models/schema/__init__.py`:
|
||||
|
||||
```python
|
||||
# models/schema/__init__.py
|
||||
from .auth import LoginRequest, TokenResponse
|
||||
from .product import ProductCreate, ProductUpdate, ProductResponse
|
||||
|
||||
__all__ = [
|
||||
"LoginRequest",
|
||||
"TokenResponse",
|
||||
"ProductCreate",
|
||||
"ProductUpdate",
|
||||
"ProductResponse",
|
||||
]
|
||||
```
|
||||
|
||||
### Importing Schemas
|
||||
|
||||
```python
|
||||
# ✅ CORRECT
|
||||
from models.schema import ProductCreate, ProductResponse
|
||||
from models.schema.auth import LoginRequest
|
||||
|
||||
# ❌ WRONG
|
||||
from app.models.schema.product import ProductCreate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Database Model with Schema
|
||||
|
||||
**Database Model:** `models/database/vendor.py`
|
||||
```python
|
||||
from sqlalchemy import Column, Integer, String, Boolean
|
||||
from .base import Base
|
||||
|
||||
class Vendor(Base):
|
||||
__tablename__ = "vendors"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
code = Column(String(50), unique=True, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
```
|
||||
|
||||
**Pydantic Schema:** `models/schema/vendor.py`
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
|
||||
class VendorBase(BaseModel):
|
||||
name: str
|
||||
code: str
|
||||
|
||||
class VendorCreate(VendorBase):
|
||||
pass
|
||||
|
||||
class VendorResponse(VendorBase):
|
||||
id: int
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
```
|
||||
|
||||
**Usage in API:**
|
||||
```python
|
||||
from fastapi import APIRouter
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.database import Vendor
|
||||
from models.schema import VendorCreate, VendorResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/vendors", response_model=VendorResponse)
|
||||
def create_vendor(vendor_data: VendorCreate, db: Session):
|
||||
# VendorCreate validates input
|
||||
db_vendor = Vendor(**vendor_data.dict())
|
||||
db.add(db_vendor)
|
||||
db.commit()
|
||||
db.refresh(db_vendor)
|
||||
# VendorResponse serializes output
|
||||
return db_vendor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pattern 2: Complex Schemas
|
||||
|
||||
For complex domains, organize schemas by purpose:
|
||||
|
||||
```python
|
||||
# models/schema/order.py
|
||||
class OrderBase(BaseModel):
|
||||
"""Base order fields"""
|
||||
pass
|
||||
|
||||
class OrderCreate(OrderBase):
|
||||
"""Create order from customer"""
|
||||
items: List[OrderItemCreate]
|
||||
|
||||
class OrderUpdate(BaseModel):
|
||||
"""Admin order update"""
|
||||
status: Optional[OrderStatus]
|
||||
|
||||
class OrderResponse(OrderBase):
|
||||
"""Order API response"""
|
||||
id: int
|
||||
items: List[OrderItemResponse]
|
||||
|
||||
class OrderAdminResponse(OrderResponse):
|
||||
"""Extended response for admin"""
|
||||
internal_notes: Optional[str]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
If you accidentally created models in the wrong location:
|
||||
|
||||
### Moving Database Models
|
||||
|
||||
```bash
|
||||
# If you created app/models/my_model.py (WRONG)
|
||||
# Move to correct location:
|
||||
mv app/models/my_model.py models/database/my_model.py
|
||||
|
||||
# Update imports in all files
|
||||
# FROM: from app.models.my_model import MyModel
|
||||
# TO: from models.database.my_model import MyModel
|
||||
|
||||
# Add to models/database/__init__.py
|
||||
# Remove app/models/ directory
|
||||
rm -rf app/models/
|
||||
```
|
||||
|
||||
### Moving Pydantic Schemas
|
||||
|
||||
```bash
|
||||
# If you created app/schemas/my_schema.py (WRONG)
|
||||
# Move to correct location:
|
||||
mv app/schemas/my_schema.py models/schema/my_schema.py
|
||||
|
||||
# Update imports
|
||||
# FROM: from app.schemas.my_schema import MySchema
|
||||
# TO: from models.schema.my_schema import MySchema
|
||||
|
||||
# Add to models/schema/__init__.py
|
||||
# Remove app/schemas/ directory
|
||||
rm -rf app/schemas/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Why This Structure?
|
||||
|
||||
### ✅ Benefits
|
||||
|
||||
1. **Clear Separation**
|
||||
- Database layer separate from application layer
|
||||
- Easy to understand where models live
|
||||
|
||||
2. **Import Consistency**
|
||||
- `from models.database import ...`
|
||||
- `from models.schema import ...`
|
||||
- No confusion about import paths
|
||||
|
||||
3. **Testing**
|
||||
- Easy to mock database models
|
||||
- Easy to test schema validation
|
||||
|
||||
4. **Scalability**
|
||||
- Models can be used by multiple apps
|
||||
- Clean separation of concerns
|
||||
|
||||
5. **Tool Compatibility**
|
||||
- Alembic migrations find models easily
|
||||
- IDE autocomplete works better
|
||||
- Linters understand structure
|
||||
|
||||
### ❌ Problems with `app/models/`
|
||||
|
||||
1. **Confusion**: Is it database or schema?
|
||||
2. **Import Issues**: Circular dependencies
|
||||
3. **Migration Problems**: Alembic can't find models
|
||||
4. **Inconsistency**: Different parts of codebase use different paths
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Use this checklist when adding new models:
|
||||
|
||||
### Database Model Checklist
|
||||
- [ ] File in `models/database/{name}.py`
|
||||
- [ ] Inherits from `Base`
|
||||
- [ ] Has `__tablename__` defined
|
||||
- [ ] Exported in `models/database/__init__.py`
|
||||
- [ ] Imported using `from models.database import ...`
|
||||
- [ ] NO file in `app/models/`
|
||||
|
||||
### Pydantic Schema Checklist
|
||||
- [ ] File in `models/schema/{name}.py`
|
||||
- [ ] Inherits from `BaseModel`
|
||||
- [ ] Has descriptive suffix (`Create`, `Update`, `Response`)
|
||||
- [ ] Exported in `models/schema/__init__.py`
|
||||
- [ ] Imported using `from models.schema import ...`
|
||||
- [ ] NO file in `app/schemas/`
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
project/
|
||||
├── app/
|
||||
│ ├── api/ # API routes
|
||||
│ ├── core/ # Core functionality (config, database, auth)
|
||||
│ ├── services/ # Business logic
|
||||
│ ├── templates/ # Jinja2 templates
|
||||
│ └── routes/ # Page routes
|
||||
│
|
||||
├── models/ # ✓ Models live here!
|
||||
│ ├── database/ # ✓ SQLAlchemy models
|
||||
│ └── schema/ # ✓ Pydantic schemas
|
||||
│
|
||||
├── static/ # Frontend assets
|
||||
├── docs/ # Documentation
|
||||
├── tests/ # Tests
|
||||
└── scripts/ # Utility scripts
|
||||
```
|
||||
|
||||
**NOT:**
|
||||
```
|
||||
app/
|
||||
models/ # ❌ Don't create this
|
||||
schemas/ # ❌ Don't create this
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Examples from the Codebase
|
||||
|
||||
### ✅ Correct Examples
|
||||
|
||||
**Database Model:**
|
||||
```python
|
||||
# models/database/architecture_scan.py
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from .base import Base
|
||||
|
||||
class ArchitectureScan(Base):
|
||||
__tablename__ = "architecture_scans"
|
||||
id = Column(Integer, primary_key=True)
|
||||
```
|
||||
|
||||
**Import in Service:**
|
||||
```python
|
||||
# app/services/code_quality_service.py
|
||||
from models.database.architecture_scan import ArchitectureScan
|
||||
```
|
||||
|
||||
**Pydantic Schema:**
|
||||
```python
|
||||
# models/schema/admin.py
|
||||
from pydantic import BaseModel
|
||||
|
||||
class AdminDashboardStats(BaseModel):
|
||||
total_vendors: int
|
||||
total_users: int
|
||||
```
|
||||
|
||||
**Import in API:**
|
||||
```python
|
||||
# app/api/v1/admin/dashboard.py
|
||||
from models.schema.admin import AdminDashboardStats
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Golden Rule:** All models in `models/`, never in `app/models/` or `app/schemas/`.
|
||||
|
||||
**Quick Reference:**
|
||||
- Database models → `models/database/`
|
||||
- Pydantic schemas → `models/schema/`
|
||||
- Import pattern → `from models.{type} import ...`
|
||||
- No models in `app/` directory
|
||||
|
||||
This standard ensures consistency, clarity, and maintainability across the entire project.
|
||||
500
docs/backend/vendor-in-token-architecture.md
Normal file
500
docs/backend/vendor-in-token-architecture.md
Normal file
@@ -0,0 +1,500 @@
|
||||
# Vendor-in-Token Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the vendor-in-token authentication architecture used for vendor API endpoints. This architecture embeds vendor context directly into JWT tokens, eliminating the need for URL-based vendor detection and enabling clean, RESTful API endpoints.
|
||||
|
||||
## The Problem: URL-Based Vendor Detection
|
||||
|
||||
### Old Pattern (Deprecated)
|
||||
```python
|
||||
# ❌ DEPRECATED: URL-based vendor detection
|
||||
@router.get("/{product_id}")
|
||||
def get_product(
|
||||
product_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()), # ❌ Don't use
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
product = product_service.get_product(db, vendor.id, product_id)
|
||||
return product
|
||||
```
|
||||
|
||||
### Issues with URL-Based Detection
|
||||
|
||||
1. **Inconsistent API Routes**
|
||||
- Page routes: `/vendor/{vendor_code}/dashboard` (has vendor in URL)
|
||||
- API routes: `/api/v1/vendor/products` (no vendor in URL)
|
||||
- `require_vendor_context()` only works when vendor is in the URL path
|
||||
|
||||
2. **404 Errors on API Endpoints**
|
||||
- API calls to `/api/v1/vendor/products` would return 404
|
||||
- The dependency expected vendor code in URL but API routes don't have it
|
||||
- Breaking RESTful API design principles
|
||||
|
||||
3. **Architecture Violation**
|
||||
- Mixed concerns: URL structure determining business logic
|
||||
- Tight coupling between routing and vendor context
|
||||
- Harder to test and maintain
|
||||
|
||||
## The Solution: Vendor-in-Token
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Vendor Login Flow │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 1. Authenticate user credentials │
|
||||
│ 2. Validate vendor membership │
|
||||
│ 3. Create JWT with vendor context: │
|
||||
│ { │
|
||||
│ "sub": "user_id", │
|
||||
│ "username": "john.doe", │
|
||||
│ "vendor_id": 123, ← Vendor context in token │
|
||||
│ "vendor_code": "WIZAMART", ← Vendor code in token │
|
||||
│ "vendor_role": "Owner" ← Vendor role in token │
|
||||
│ } │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 4. Set dual token storage: │
|
||||
│ - HTTP-only cookie (path=/vendor) for page navigation │
|
||||
│ - Response body for localStorage (API calls) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 5. Subsequent API requests include vendor context │
|
||||
│ Authorization: Bearer <token-with-vendor-context> │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 6. get_current_vendor_api() extracts vendor from token: │
|
||||
│ - current_user.token_vendor_id │
|
||||
│ - current_user.token_vendor_code │
|
||||
│ - current_user.token_vendor_role │
|
||||
│ 7. Validates user still has access to vendor │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Implementation Components
|
||||
|
||||
#### 1. Token Creation (middleware/auth.py)
|
||||
```python
|
||||
def create_access_token(
|
||||
self,
|
||||
user: User,
|
||||
vendor_id: int | None = None,
|
||||
vendor_code: str | None = None,
|
||||
vendor_role: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create JWT with optional vendor context."""
|
||||
payload = {
|
||||
"sub": str(user.id),
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"exp": expire,
|
||||
"iat": datetime.now(UTC),
|
||||
}
|
||||
|
||||
# Include vendor information in token if provided
|
||||
if vendor_id is not None:
|
||||
payload["vendor_id"] = vendor_id
|
||||
if vendor_code is not None:
|
||||
payload["vendor_code"] = vendor_code
|
||||
if vendor_role is not None:
|
||||
payload["vendor_role"] = vendor_role
|
||||
|
||||
return {
|
||||
"access_token": jwt.encode(payload, self.secret_key, algorithm=self.algorithm),
|
||||
"token_type": "bearer",
|
||||
"expires_in": self.access_token_expire_minutes * 60,
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Vendor Login (app/api/v1/vendor/auth.py)
|
||||
```python
|
||||
@router.post("/login", response_model=VendorLoginResponse)
|
||||
def vendor_login(
|
||||
user_credentials: UserLogin,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Vendor team member login.
|
||||
|
||||
Creates vendor-scoped JWT token with vendor context embedded.
|
||||
"""
|
||||
# Authenticate user and determine vendor
|
||||
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
|
||||
user = login_result["user"]
|
||||
|
||||
# Determine vendor and role
|
||||
vendor = determine_vendor(db, user) # Your vendor detection logic
|
||||
vendor_role = determine_role(db, user, vendor) # Your role detection logic
|
||||
|
||||
# Create vendor-scoped access token
|
||||
token_data = auth_service.auth_manager.create_access_token(
|
||||
user=user,
|
||||
vendor_id=vendor.id,
|
||||
vendor_code=vendor.vendor_code,
|
||||
vendor_role=vendor_role,
|
||||
)
|
||||
|
||||
# Set cookie and return token
|
||||
response.set_cookie(
|
||||
key="vendor_token",
|
||||
value=token_data["access_token"],
|
||||
httponly=True,
|
||||
path="/vendor", # Restricted to vendor routes
|
||||
)
|
||||
|
||||
return VendorLoginResponse(**token_data, user=user, vendor=vendor)
|
||||
```
|
||||
|
||||
#### 3. Token Verification (app/api/deps.py)
|
||||
```python
|
||||
def get_current_vendor_api(
|
||||
authorization: str | None = Header(None, alias="Authorization"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
"""
|
||||
Get current vendor API user from Authorization header.
|
||||
|
||||
Extracts vendor context from JWT token and validates access.
|
||||
"""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise AuthenticationException("Authorization header required for API calls")
|
||||
|
||||
token = authorization.replace("Bearer ", "")
|
||||
user = auth_service.auth_manager.get_current_user(token, db)
|
||||
|
||||
# Validate vendor access if token is vendor-scoped
|
||||
if hasattr(user, "token_vendor_id"):
|
||||
vendor_id = user.token_vendor_id
|
||||
|
||||
# Verify user still has access to this vendor
|
||||
if not user.is_member_of(vendor_id):
|
||||
raise InsufficientPermissionsException(
|
||||
"Access to vendor has been revoked. Please login again."
|
||||
)
|
||||
|
||||
return user
|
||||
```
|
||||
|
||||
#### 4. Endpoint Usage (app/api/v1/vendor/products.py)
|
||||
```python
|
||||
@router.get("", response_model=ProductListResponse)
|
||||
def get_vendor_products(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
current_user: User = Depends(get_current_vendor_api), # ✅ Only need this
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get all products in vendor catalog.
|
||||
|
||||
Vendor is determined from JWT token (vendor_id claim).
|
||||
"""
|
||||
# Extract vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Use vendor_id from token for business logic
|
||||
products, total = product_service.get_vendor_products(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return ProductListResponse(products=products, total=total)
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Step 1: Identify Endpoints Using require_vendor_context()
|
||||
|
||||
Search for all occurrences:
|
||||
```bash
|
||||
grep -r "require_vendor_context" app/api/v1/vendor/
|
||||
```
|
||||
|
||||
### Step 2: Update Endpoint Signature
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
@router.get("/{product_id}")
|
||||
def get_product(
|
||||
product_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()), # ❌ Remove this
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
@router.get("/{product_id}")
|
||||
def get_product(
|
||||
product_id: int,
|
||||
current_user: User = Depends(get_current_vendor_api), # ✅ Only need this
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
```
|
||||
|
||||
### Step 3: Extract Vendor from Token
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
product = product_service.get_product(db, vendor.id, product_id)
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Extract vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Use vendor_id from token
|
||||
product = product_service.get_product(db, vendor_id, product_id)
|
||||
```
|
||||
|
||||
### Step 4: Update Logging References
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
logger.info(f"Product updated for vendor {vendor.vendor_code}")
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
logger.info(f"Product updated for vendor {current_user.token_vendor_code}")
|
||||
```
|
||||
|
||||
### Complete Migration Example
|
||||
|
||||
**Before (URL-based vendor detection):**
|
||||
```python
|
||||
@router.put("/{product_id}", response_model=ProductResponse)
|
||||
def update_product(
|
||||
product_id: int,
|
||||
product_data: ProductUpdate,
|
||||
vendor: Vendor = Depends(require_vendor_context()), # ❌
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update product in vendor catalog."""
|
||||
product = product_service.update_product(
|
||||
db=db,
|
||||
vendor_id=vendor.id, # ❌ From URL
|
||||
product_id=product_id,
|
||||
product_update=product_data
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Product {product_id} updated by {current_user.username} "
|
||||
f"for vendor {vendor.vendor_code}" # ❌ From URL
|
||||
)
|
||||
|
||||
return ProductResponse.model_validate(product)
|
||||
```
|
||||
|
||||
**After (Token-based vendor context):**
|
||||
```python
|
||||
@router.put("/{product_id}", response_model=ProductResponse)
|
||||
def update_product(
|
||||
product_id: int,
|
||||
product_data: ProductUpdate,
|
||||
current_user: User = Depends(get_current_vendor_api), # ✅ Only dependency
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update product in vendor catalog."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Extract vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id # ✅ From token
|
||||
|
||||
product = product_service.update_product(
|
||||
db=db,
|
||||
vendor_id=vendor_id, # ✅ From token
|
||||
product_id=product_id,
|
||||
product_update=product_data
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Product {product_id} updated by {current_user.username} "
|
||||
f"for vendor {current_user.token_vendor_code}" # ✅ From token
|
||||
)
|
||||
|
||||
return ProductResponse.model_validate(product)
|
||||
```
|
||||
|
||||
## Files to Migrate
|
||||
|
||||
Current files still using `require_vendor_context()`:
|
||||
- `app/api/v1/vendor/customers.py`
|
||||
- `app/api/v1/vendor/notifications.py`
|
||||
- `app/api/v1/vendor/media.py`
|
||||
- `app/api/v1/vendor/marketplace.py`
|
||||
- `app/api/v1/vendor/inventory.py`
|
||||
- `app/api/v1/vendor/settings.py`
|
||||
- `app/api/v1/vendor/analytics.py`
|
||||
- `app/api/v1/vendor/payments.py`
|
||||
- `app/api/v1/vendor/profile.py`
|
||||
|
||||
## Benefits of Vendor-in-Token
|
||||
|
||||
### 1. Clean RESTful APIs
|
||||
```
|
||||
✅ /api/v1/vendor/products
|
||||
✅ /api/v1/vendor/orders
|
||||
✅ /api/v1/vendor/customers
|
||||
|
||||
❌ /api/v1/vendor/{vendor_code}/products (unnecessary vendor in URL)
|
||||
```
|
||||
|
||||
### 2. Security
|
||||
- Vendor context cryptographically signed in JWT
|
||||
- Cannot be tampered with by client
|
||||
- Automatic validation on every request
|
||||
- Token revocation possible via database checks
|
||||
|
||||
### 3. Consistency
|
||||
- Same authentication mechanism for all vendor API endpoints
|
||||
- No confusion between page routes and API routes
|
||||
- Single source of truth (the token)
|
||||
|
||||
### 4. Performance
|
||||
- No database lookup for vendor context on every request
|
||||
- Vendor information already in token payload
|
||||
- Optional validation for revoked access
|
||||
|
||||
### 5. Maintainability
|
||||
- Simpler endpoint signatures
|
||||
- Less boilerplate code
|
||||
- Easier to test
|
||||
- Follows architecture rule API-002 (no DB queries in endpoints)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Token Validation
|
||||
The token vendor context is validated on every request:
|
||||
1. JWT signature verification (ensures token not tampered with)
|
||||
2. Token expiration check (typically 30 minutes)
|
||||
3. Optional: Verify user still member of vendor (database check)
|
||||
|
||||
### Access Revocation
|
||||
If a user's vendor access is revoked:
|
||||
1. Existing tokens remain valid until expiration
|
||||
2. `get_current_vendor_api()` performs optional database check
|
||||
3. User forced to re-login after token expires
|
||||
4. New login will fail if access revoked
|
||||
|
||||
### Token Refresh
|
||||
Tokens should be refreshed periodically:
|
||||
- Default: 30 minutes expiration
|
||||
- Refresh before expiration for seamless UX
|
||||
- New login creates new token with current vendor membership
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
```python
|
||||
def test_vendor_in_token():
|
||||
"""Test vendor context in JWT token."""
|
||||
# Create token with vendor context
|
||||
token_data = auth_manager.create_access_token(
|
||||
user=user,
|
||||
vendor_id=123,
|
||||
vendor_code="WIZAMART",
|
||||
vendor_role="Owner",
|
||||
)
|
||||
|
||||
# Verify token contains vendor data
|
||||
payload = jwt.decode(token_data["access_token"], secret_key)
|
||||
assert payload["vendor_id"] == 123
|
||||
assert payload["vendor_code"] == "WIZAMART"
|
||||
assert payload["vendor_role"] == "Owner"
|
||||
|
||||
def test_api_endpoint_uses_token_vendor():
|
||||
"""Test API endpoint extracts vendor from token."""
|
||||
response = client.get(
|
||||
"/api/v1/vendor/products",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
# Verify products are filtered by token vendor_id
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
```python
|
||||
def test_vendor_login_and_api_access():
|
||||
"""Test full vendor login and API access flow."""
|
||||
# Login as vendor user
|
||||
response = client.post("/api/v1/vendor/auth/login", json={
|
||||
"username": "john.doe",
|
||||
"password": "password123"
|
||||
})
|
||||
assert response.status_code == 200
|
||||
token = response.json()["access_token"]
|
||||
|
||||
# Access vendor API with token
|
||||
response = client.get(
|
||||
"/api/v1/vendor/products",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify vendor context from token
|
||||
products = response.json()["products"]
|
||||
# All products should belong to token vendor
|
||||
```
|
||||
|
||||
## Architecture Rules
|
||||
|
||||
See `docs/architecture/rules/API-VND-001.md` for the formal architecture rule enforcing this pattern.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Vendor RBAC System](./vendor-rbac.md) - Role-based access control for vendors
|
||||
- [Vendor Authentication](./vendor-authentication.md) - Complete authentication guide
|
||||
- [Architecture Rules](../architecture/rules/) - All architecture rules
|
||||
- [API Design Guidelines](../architecture/api-design.md) - RESTful API patterns
|
||||
|
||||
## Summary
|
||||
|
||||
The vendor-in-token architecture:
|
||||
- ✅ Embeds vendor context in JWT tokens
|
||||
- ✅ Eliminates URL-based vendor detection
|
||||
- ✅ Enables clean RESTful API endpoints
|
||||
- ✅ Improves security and performance
|
||||
- ✅ Simplifies endpoint implementation
|
||||
- ✅ Follows architecture best practices
|
||||
|
||||
**Migration Status:** In progress - 9 endpoint files remaining to migrate
|
||||
678
docs/backend/vendor-rbac.md
Normal file
678
docs/backend/vendor-rbac.md
Normal file
@@ -0,0 +1,678 @@
|
||||
# Vendor RBAC System - Complete Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The vendor dashboard implements a **Role-Based Access Control (RBAC)** system that distinguishes between **Owners** and **Team Members**, with granular permissions for team members.
|
||||
|
||||
---
|
||||
|
||||
## User Types
|
||||
|
||||
### 1. Vendor Owner
|
||||
|
||||
**Who:** The user who created the vendor account.
|
||||
|
||||
**Characteristics:**
|
||||
- Has **ALL permissions** automatically (no role needed)
|
||||
- Cannot be removed or have permissions restricted
|
||||
- Can invite team members
|
||||
- Can create and manage roles
|
||||
- Identified by `VendorUser.user_type = "owner"`
|
||||
- Linked via `Vendor.owner_user_id → User.id`
|
||||
|
||||
**Database:**
|
||||
```python
|
||||
# VendorUser record for owner
|
||||
{
|
||||
"vendor_id": 1,
|
||||
"user_id": 5,
|
||||
"user_type": "owner", # ✓ Owner
|
||||
"role_id": None, # No role needed
|
||||
"is_active": True
|
||||
}
|
||||
```
|
||||
|
||||
**Permissions:**
|
||||
- ✅ **All 75 permissions** (complete access)
|
||||
- See full list below
|
||||
|
||||
---
|
||||
|
||||
### 2. Team Members
|
||||
|
||||
**Who:** Users invited by the vendor owner to help manage the vendor.
|
||||
|
||||
**Characteristics:**
|
||||
- Have **limited permissions** based on assigned role
|
||||
- Must be invited via email
|
||||
- Invitation must be accepted before activation
|
||||
- Can be assigned one of the pre-defined roles or custom role
|
||||
- Identified by `VendorUser.user_type = "member"`
|
||||
- Permissions come from `VendorUser.role_id → Role.permissions`
|
||||
|
||||
**Database:**
|
||||
```python
|
||||
# VendorUser record for team member
|
||||
{
|
||||
"vendor_id": 1,
|
||||
"user_id": 7,
|
||||
"user_type": "member", # ✓ Team member
|
||||
"role_id": 3, # ✓ Role required
|
||||
"is_active": True,
|
||||
"invitation_token": None, # Accepted
|
||||
"invitation_accepted_at": "2024-11-15 10:30:00"
|
||||
}
|
||||
|
||||
# Role record
|
||||
{
|
||||
"id": 3,
|
||||
"vendor_id": 1,
|
||||
"name": "Manager",
|
||||
"permissions": [
|
||||
"dashboard.view",
|
||||
"products.view",
|
||||
"products.create",
|
||||
"products.edit",
|
||||
"orders.view",
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Permissions:**
|
||||
- 🔒 **Limited** based on assigned role
|
||||
- Can have between 0 and 75 permissions
|
||||
- Common roles: Manager, Staff, Support, Viewer, Marketing
|
||||
|
||||
---
|
||||
|
||||
## Permission System
|
||||
|
||||
### All Available Permissions (75 total)
|
||||
|
||||
```python
|
||||
class VendorPermissions(str, Enum):
|
||||
# Dashboard (1)
|
||||
DASHBOARD_VIEW = "dashboard.view"
|
||||
|
||||
# Products (6)
|
||||
PRODUCTS_VIEW = "products.view"
|
||||
PRODUCTS_CREATE = "products.create"
|
||||
PRODUCTS_EDIT = "products.edit"
|
||||
PRODUCTS_DELETE = "products.delete"
|
||||
PRODUCTS_IMPORT = "products.import"
|
||||
PRODUCTS_EXPORT = "products.export"
|
||||
|
||||
# Stock/Inventory (3)
|
||||
STOCK_VIEW = "stock.view"
|
||||
STOCK_EDIT = "stock.edit"
|
||||
STOCK_TRANSFER = "stock.transfer"
|
||||
|
||||
# Orders (4)
|
||||
ORDERS_VIEW = "orders.view"
|
||||
ORDERS_EDIT = "orders.edit"
|
||||
ORDERS_CANCEL = "orders.cancel"
|
||||
ORDERS_REFUND = "orders.refund"
|
||||
|
||||
# Customers (4)
|
||||
CUSTOMERS_VIEW = "customers.view"
|
||||
CUSTOMERS_EDIT = "customers.edit"
|
||||
CUSTOMERS_DELETE = "customers.delete"
|
||||
CUSTOMERS_EXPORT = "customers.export"
|
||||
|
||||
# Marketing (3)
|
||||
MARKETING_VIEW = "marketing.view"
|
||||
MARKETING_CREATE = "marketing.create"
|
||||
MARKETING_SEND = "marketing.send"
|
||||
|
||||
# Reports (3)
|
||||
REPORTS_VIEW = "reports.view"
|
||||
REPORTS_FINANCIAL = "reports.financial"
|
||||
REPORTS_EXPORT = "reports.export"
|
||||
|
||||
# Settings (4)
|
||||
SETTINGS_VIEW = "settings.view"
|
||||
SETTINGS_EDIT = "settings.edit"
|
||||
SETTINGS_THEME = "settings.theme"
|
||||
SETTINGS_DOMAINS = "settings.domains"
|
||||
|
||||
# Team Management (4)
|
||||
TEAM_VIEW = "team.view"
|
||||
TEAM_INVITE = "team.invite"
|
||||
TEAM_EDIT = "team.edit"
|
||||
TEAM_REMOVE = "team.remove"
|
||||
|
||||
# Marketplace Imports (3)
|
||||
IMPORTS_VIEW = "imports.view"
|
||||
IMPORTS_CREATE = "imports.create"
|
||||
IMPORTS_CANCEL = "imports.cancel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pre-Defined Roles
|
||||
|
||||
### 1. Owner (All 75 permissions)
|
||||
**Use case:** Vendor owner (automatically assigned)
|
||||
- ✅ Full access to everything
|
||||
- ✅ Cannot be restricted
|
||||
- ✅ No role record needed (permissions checked differently)
|
||||
|
||||
---
|
||||
|
||||
### 2. Manager (43 permissions)
|
||||
**Use case:** Senior staff who manage most operations
|
||||
|
||||
**Has access to:**
|
||||
- ✅ Dashboard, Products (all), Stock (all)
|
||||
- ✅ Orders (all), Customers (view, edit, export)
|
||||
- ✅ Marketing (all), Reports (all including financial)
|
||||
- ✅ Settings (view, theme)
|
||||
- ✅ Imports (all)
|
||||
|
||||
**Does NOT have:**
|
||||
- ❌ `customers.delete` - Cannot delete customers
|
||||
- ❌ `settings.edit` - Cannot change core settings
|
||||
- ❌ `settings.domains` - Cannot manage domains
|
||||
- ❌ `team.*` - Cannot manage team members
|
||||
|
||||
---
|
||||
|
||||
### 3. Staff (10 permissions)
|
||||
**Use case:** Daily operations staff
|
||||
|
||||
**Has access to:**
|
||||
- ✅ Dashboard view
|
||||
- ✅ Products (view, create, edit)
|
||||
- ✅ Stock (view, edit)
|
||||
- ✅ Orders (view, edit)
|
||||
- ✅ Customers (view, edit)
|
||||
|
||||
**Does NOT have:**
|
||||
- ❌ Delete anything
|
||||
- ❌ Import/export
|
||||
- ❌ Marketing
|
||||
- ❌ Financial reports
|
||||
- ❌ Settings
|
||||
- ❌ Team management
|
||||
|
||||
---
|
||||
|
||||
### 4. Support (6 permissions)
|
||||
**Use case:** Customer support team
|
||||
|
||||
**Has access to:**
|
||||
- ✅ Dashboard view
|
||||
- ✅ Products (view only)
|
||||
- ✅ Orders (view, edit)
|
||||
- ✅ Customers (view, edit)
|
||||
|
||||
**Does NOT have:**
|
||||
- ❌ Create/delete products
|
||||
- ❌ Stock management
|
||||
- ❌ Marketing
|
||||
- ❌ Reports
|
||||
- ❌ Settings
|
||||
- ❌ Team management
|
||||
|
||||
---
|
||||
|
||||
### 5. Viewer (6 permissions)
|
||||
**Use case:** Read-only access for reporting/audit
|
||||
|
||||
**Has access to:**
|
||||
- ✅ Dashboard (view)
|
||||
- ✅ Products (view)
|
||||
- ✅ Stock (view)
|
||||
- ✅ Orders (view)
|
||||
- ✅ Customers (view)
|
||||
- ✅ Reports (view)
|
||||
|
||||
**Does NOT have:**
|
||||
- ❌ Edit anything
|
||||
- ❌ Create/delete anything
|
||||
- ❌ Marketing
|
||||
- ❌ Financial reports
|
||||
- ❌ Settings
|
||||
- ❌ Team management
|
||||
|
||||
---
|
||||
|
||||
### 6. Marketing (7 permissions)
|
||||
**Use case:** Marketing team focused on campaigns
|
||||
|
||||
**Has access to:**
|
||||
- ✅ Dashboard (view)
|
||||
- ✅ Customers (view, export)
|
||||
- ✅ Marketing (all)
|
||||
- ✅ Reports (view)
|
||||
|
||||
**Does NOT have:**
|
||||
- ❌ Products management
|
||||
- ❌ Orders management
|
||||
- ❌ Stock management
|
||||
- ❌ Financial reports
|
||||
- ❌ Settings
|
||||
- ❌ Team management
|
||||
|
||||
---
|
||||
|
||||
## Permission Checking Logic
|
||||
|
||||
### How Permissions Are Checked
|
||||
|
||||
```python
|
||||
# In User model (models/database/user.py)
|
||||
|
||||
def has_vendor_permission(self, vendor_id: int, permission: str) -> bool:
|
||||
"""Check if user has a specific permission in a vendor."""
|
||||
|
||||
# Step 1: Check if user is owner
|
||||
if self.is_owner_of(vendor_id):
|
||||
return True # ✅ Owners have ALL permissions
|
||||
|
||||
# Step 2: Check team member permissions
|
||||
for vm in self.vendor_memberships:
|
||||
if vm.vendor_id == vendor_id and vm.is_active:
|
||||
if vm.role and permission in vm.role.permissions:
|
||||
return True # ✅ Permission found in role
|
||||
|
||||
# No permission found
|
||||
return False
|
||||
```
|
||||
|
||||
### Permission Checking Flow
|
||||
|
||||
```
|
||||
Request → Middleware → Extract vendor from URL
|
||||
↓
|
||||
Check user authentication
|
||||
↓
|
||||
Check if user is owner
|
||||
├── YES → ✅ Allow (all permissions)
|
||||
└── NO ↓
|
||||
Check if user is team member
|
||||
├── NO → ❌ Deny
|
||||
└── YES ↓
|
||||
Check if membership is active
|
||||
├── NO → ❌ Deny
|
||||
└── YES ↓
|
||||
Check if role has required permission
|
||||
├── NO → ❌ Deny (403 Forbidden)
|
||||
└── YES → ✅ Allow
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Using Permissions in Code
|
||||
|
||||
### 1. Require Specific Permission
|
||||
|
||||
**When to use:** Endpoint needs one specific permission
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, Depends
|
||||
from app.api.deps import require_vendor_permission
|
||||
from app.core.permissions import VendorPermissions
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/products")
|
||||
def create_product(
|
||||
product_data: ProductCreate,
|
||||
user: User = Depends(
|
||||
require_vendor_permission(VendorPermissions.PRODUCTS_CREATE.value)
|
||||
)
|
||||
):
|
||||
"""
|
||||
Create a product.
|
||||
|
||||
Required permission: products.create
|
||||
✅ Owner: Always allowed
|
||||
✅ Manager: Allowed (has products.create)
|
||||
✅ Staff: Allowed (has products.create)
|
||||
❌ Support: Denied (no products.create)
|
||||
❌ Viewer: Denied (no products.create)
|
||||
❌ Marketing: Denied (no products.create)
|
||||
"""
|
||||
# Create product...
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Require ANY Permission
|
||||
|
||||
**When to use:** Endpoint can be accessed with any of several permissions
|
||||
|
||||
```python
|
||||
@router.get("/dashboard")
|
||||
def view_dashboard(
|
||||
user: User = Depends(
|
||||
require_any_vendor_permission(
|
||||
VendorPermissions.DASHBOARD_VIEW.value,
|
||||
VendorPermissions.REPORTS_VIEW.value
|
||||
)
|
||||
)
|
||||
):
|
||||
"""
|
||||
View dashboard.
|
||||
|
||||
Required: dashboard.view OR reports.view
|
||||
✅ Owner: Always allowed
|
||||
✅ Manager: Allowed (has both)
|
||||
✅ Staff: Allowed (has dashboard.view)
|
||||
✅ Support: Allowed (has dashboard.view)
|
||||
✅ Viewer: Allowed (has both)
|
||||
✅ Marketing: Allowed (has both)
|
||||
"""
|
||||
# Show dashboard...
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Require ALL Permissions
|
||||
|
||||
**When to use:** Endpoint needs multiple permissions
|
||||
|
||||
```python
|
||||
@router.post("/products/bulk-delete")
|
||||
def bulk_delete_products(
|
||||
user: User = Depends(
|
||||
require_all_vendor_permissions(
|
||||
VendorPermissions.PRODUCTS_VIEW.value,
|
||||
VendorPermissions.PRODUCTS_DELETE.value
|
||||
)
|
||||
)
|
||||
):
|
||||
"""
|
||||
Bulk delete products.
|
||||
|
||||
Required: products.view AND products.delete
|
||||
✅ Owner: Always allowed
|
||||
✅ Manager: Allowed (has both)
|
||||
❌ Staff: Denied (no products.delete)
|
||||
❌ Support: Denied (no products.delete)
|
||||
❌ Viewer: Denied (no products.delete)
|
||||
❌ Marketing: Denied (no products.delete)
|
||||
"""
|
||||
# Delete products...
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Require Owner Only
|
||||
|
||||
**When to use:** Endpoint is owner-only (team management, critical settings)
|
||||
|
||||
```python
|
||||
from app.api.deps import require_vendor_owner
|
||||
|
||||
@router.post("/team/invite")
|
||||
def invite_team_member(
|
||||
email: str,
|
||||
role_id: int,
|
||||
user: User = Depends(require_vendor_owner)
|
||||
):
|
||||
"""
|
||||
Invite a team member.
|
||||
|
||||
Required: Must be vendor owner
|
||||
✅ Owner: Allowed
|
||||
❌ Manager: Denied (not owner)
|
||||
❌ All team members: Denied (not owner)
|
||||
"""
|
||||
# Invite team member...
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Get User Permissions
|
||||
|
||||
**When to use:** Need to check permissions in business logic
|
||||
|
||||
```python
|
||||
from app.api.deps import get_user_permissions
|
||||
|
||||
@router.get("/my-permissions")
|
||||
def list_my_permissions(
|
||||
permissions: list = Depends(get_user_permissions)
|
||||
):
|
||||
"""
|
||||
Get all permissions for current user.
|
||||
|
||||
Returns:
|
||||
- Owner: All 75 permissions
|
||||
- Team Member: Permissions from their role
|
||||
"""
|
||||
return {"permissions": permissions}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### VendorUser Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE vendor_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
vendor_id INTEGER NOT NULL REFERENCES vendors(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
user_type VARCHAR NOT NULL, -- 'owner' or 'member'
|
||||
role_id INTEGER REFERENCES roles(id), -- NULL for owners
|
||||
invited_by INTEGER REFERENCES users(id),
|
||||
invitation_token VARCHAR,
|
||||
invitation_sent_at TIMESTAMP,
|
||||
invitation_accepted_at TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### Role Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
vendor_id INTEGER NOT NULL REFERENCES vendors(id),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
permissions JSON DEFAULT '[]', -- Array of permission strings
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Team Member Lifecycle
|
||||
|
||||
### 1. Invitation
|
||||
|
||||
```
|
||||
Owner invites user → VendorUser created:
|
||||
{
|
||||
"user_type": "member",
|
||||
"is_active": False,
|
||||
"invitation_token": "abc123...",
|
||||
"invitation_sent_at": "2024-11-29 10:00:00",
|
||||
"invitation_accepted_at": null
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Acceptance
|
||||
|
||||
```
|
||||
User accepts invitation → VendorUser updated:
|
||||
{
|
||||
"is_active": True,
|
||||
"invitation_token": null,
|
||||
"invitation_accepted_at": "2024-11-29 10:30:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Active Member
|
||||
|
||||
```
|
||||
Member can now access vendor dashboard with role permissions
|
||||
```
|
||||
|
||||
### 4. Deactivation
|
||||
|
||||
```
|
||||
Owner deactivates member → VendorUser updated:
|
||||
{
|
||||
"is_active": False
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### Use Case 1: Dashboard Access
|
||||
|
||||
**Q:** Can all users access the dashboard?
|
||||
|
||||
**A:** Yes, if they have `dashboard.view` permission.
|
||||
|
||||
- ✅ Owner: Always
|
||||
- ✅ Manager, Staff, Support, Viewer, Marketing: All have it
|
||||
- ❌ Custom role without `dashboard.view`: No
|
||||
|
||||
---
|
||||
|
||||
### Use Case 2: Product Management
|
||||
|
||||
**Q:** Who can create products?
|
||||
|
||||
**A:** Users with `products.create` permission.
|
||||
|
||||
- ✅ Owner: Always
|
||||
- ✅ Manager: Yes (has permission)
|
||||
- ✅ Staff: Yes (has permission)
|
||||
- ❌ Support, Viewer, Marketing: No
|
||||
|
||||
---
|
||||
|
||||
### Use Case 3: Financial Reports
|
||||
|
||||
**Q:** Who can view financial reports?
|
||||
|
||||
**A:** Users with `reports.financial` permission.
|
||||
|
||||
- ✅ Owner: Always
|
||||
- ✅ Manager: Yes (has permission)
|
||||
- ❌ Staff, Support, Viewer, Marketing: No
|
||||
|
||||
---
|
||||
|
||||
### Use Case 4: Team Management
|
||||
|
||||
**Q:** Who can invite team members?
|
||||
|
||||
**A:** Only the vendor owner.
|
||||
|
||||
- ✅ Owner: Yes (owner-only operation)
|
||||
- ❌ All team members (including Manager): No
|
||||
|
||||
---
|
||||
|
||||
### Use Case 5: Settings Changes
|
||||
|
||||
**Q:** Who can change vendor settings?
|
||||
|
||||
**A:** Users with `settings.edit` permission.
|
||||
|
||||
- ✅ Owner: Always
|
||||
- ❌ Manager: No (doesn't have permission)
|
||||
- ❌ All other roles: No
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
### Missing Permission
|
||||
|
||||
```http
|
||||
HTTP 403 Forbidden
|
||||
|
||||
{
|
||||
"error_code": "INSUFFICIENT_VENDOR_PERMISSIONS",
|
||||
"message": "You don't have permission to perform this action",
|
||||
"details": {
|
||||
"required_permission": "products.delete",
|
||||
"vendor_code": "wizamart"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Not Owner
|
||||
|
||||
```http
|
||||
HTTP 403 Forbidden
|
||||
|
||||
{
|
||||
"error_code": "VENDOR_OWNER_ONLY",
|
||||
"message": "This operation requires vendor owner privileges",
|
||||
"details": {
|
||||
"operation": "team management",
|
||||
"vendor_code": "wizamart"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Inactive Membership
|
||||
|
||||
```http
|
||||
HTTP 403 Forbidden
|
||||
|
||||
{
|
||||
"error_code": "INACTIVE_VENDOR_MEMBERSHIP",
|
||||
"message": "Your vendor membership is inactive"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
### Owner vs Team Member
|
||||
|
||||
| Feature | Owner | Team Member |
|
||||
|---------|-------|-------------|
|
||||
| **Permissions** | All 75 (automatic) | Based on role (0-75) |
|
||||
| **Role Required** | No | Yes |
|
||||
| **Can Be Removed** | No | Yes |
|
||||
| **Team Management** | ✅ Yes | ❌ No |
|
||||
| **Critical Settings** | ✅ Yes | ❌ No (usually) |
|
||||
| **Invitation Required** | No (creates vendor) | Yes |
|
||||
|
||||
### Permission Hierarchy
|
||||
|
||||
```
|
||||
Owner (75 permissions)
|
||||
└─ Manager (43 permissions)
|
||||
└─ Staff (10 permissions)
|
||||
└─ Support (6 permissions)
|
||||
└─ Viewer (6 permissions, read-only)
|
||||
|
||||
Marketing (7 permissions, specialized)
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Use Constants:** Always use `VendorPermissions.PERMISSION_NAME.value`
|
||||
2. **Least Privilege:** Give team members minimum permissions needed
|
||||
3. **Owner Only:** Keep sensitive operations owner-only
|
||||
4. **Custom Roles:** Create custom roles for specific needs
|
||||
5. **Regular Audit:** Review team member permissions regularly
|
||||
|
||||
---
|
||||
|
||||
This RBAC system provides flexible, secure access control for vendor dashboards with clear separation between owners and team members.
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This project uses **Heroicons** (inline SVG) with a custom helper system for clean, maintainable icon usage across the multi-tenant ecommerce platform.
|
||||
This project uses **Heroicons** (inline SVG) with a custom helper system for clean, maintainable icon usage across all **4 frontends**:
|
||||
- **Platform** - Public platform pages
|
||||
- **Admin** - Administrative portal
|
||||
- **Vendor** - Vendor management portal
|
||||
- **Shop** - Customer-facing store
|
||||
|
||||
### Why This Approach?
|
||||
|
||||
|
||||
@@ -225,7 +225,7 @@ app/
|
||||
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
title="Delete"
|
||||
>
|
||||
<span x-html="$icon('trash', 'w-5 h-5')"></span>
|
||||
<span x-html="$icon('delete', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -1308,3 +1308,361 @@ return {
|
||||
---
|
||||
|
||||
This template provides a complete, production-ready pattern for building admin pages with consistent structure, proper initialization, comprehensive logging, and excellent maintainability.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Real-World Examples: Marketplace Import Pages
|
||||
|
||||
The marketplace import system provides two comprehensive real-world implementations demonstrating all best practices.
|
||||
|
||||
### 1. Self-Service Import (`/admin/marketplace`)
|
||||
|
||||
**Purpose**: Admin tool for triggering imports for any vendor
|
||||
|
||||
**Files**:
|
||||
- **Template**: `app/templates/admin/marketplace.html`
|
||||
- **JavaScript**: `static/admin/js/marketplace.js`
|
||||
- **Route**: `app/routes/admin_pages.py` - `admin_marketplace_page()`
|
||||
|
||||
#### Key Features
|
||||
|
||||
##### Vendor Selection with Auto-Load
|
||||
```javascript
|
||||
// Load all vendors
|
||||
async loadVendors() {
|
||||
const response = await apiClient.get('/admin/vendors?limit=1000');
|
||||
this.vendors = response.items || [];
|
||||
}
|
||||
|
||||
// Handle vendor selection change
|
||||
onVendorChange() {
|
||||
const vendorId = parseInt(this.importForm.vendor_id);
|
||||
this.selectedVendor = this.vendors.find(v => v.id === vendorId) || null;
|
||||
}
|
||||
|
||||
// Quick fill from selected vendor's settings
|
||||
quickFill(language) {
|
||||
if (!this.selectedVendor) return;
|
||||
|
||||
const urlMap = {
|
||||
'fr': this.selectedVendor.letzshop_csv_url_fr,
|
||||
'en': this.selectedVendor.letzshop_csv_url_en,
|
||||
'de': this.selectedVendor.letzshop_csv_url_de
|
||||
};
|
||||
|
||||
if (urlMap[language]) {
|
||||
this.importForm.csv_url = urlMap[language];
|
||||
this.importForm.language = language;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### Filter by Current User
|
||||
```javascript
|
||||
async loadJobs() {
|
||||
const params = new URLSearchParams({
|
||||
page: this.page,
|
||||
limit: this.limit,
|
||||
created_by_me: 'true' // Only show jobs I triggered
|
||||
});
|
||||
|
||||
const response = await apiClient.get(
|
||||
`/admin/marketplace-import-jobs?${params.toString()}`
|
||||
);
|
||||
|
||||
this.jobs = response.items || [];
|
||||
}
|
||||
```
|
||||
|
||||
##### Vendor Name Helper
|
||||
```javascript
|
||||
getVendorName(vendorId) {
|
||||
const vendor = this.vendors.find(v => v.id === vendorId);
|
||||
return vendor ? `${vendor.name} (${vendor.vendor_code})` : `Vendor #${vendorId}`;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Platform Monitoring (`/admin/imports`)
|
||||
|
||||
**Purpose**: System-wide oversight of all import jobs
|
||||
|
||||
**Files**:
|
||||
- **Template**: `app/templates/admin/imports.html`
|
||||
- **JavaScript**: `static/admin/js/imports.js`
|
||||
- **Route**: `app/routes/admin_pages.py` - `admin_imports_page()`
|
||||
|
||||
#### Key Features
|
||||
|
||||
##### Statistics Dashboard
|
||||
```javascript
|
||||
async loadStats() {
|
||||
const response = await apiClient.get('/admin/marketplace-import-jobs/stats');
|
||||
this.stats = {
|
||||
total: response.total || 0,
|
||||
active: (response.pending || 0) + (response.processing || 0),
|
||||
completed: response.completed || 0,
|
||||
failed: response.failed || 0
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Template**:
|
||||
```html
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Total Jobs -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full">
|
||||
<span x-html="$icon('cube', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Total Jobs
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Repeat for active, completed, failed -->
|
||||
</div>
|
||||
```
|
||||
|
||||
##### Advanced Filtering
|
||||
```javascript
|
||||
filters: {
|
||||
vendor_id: '',
|
||||
status: '',
|
||||
marketplace: '',
|
||||
created_by: '' // 'me' or empty for all
|
||||
},
|
||||
|
||||
async applyFilters() {
|
||||
this.page = 1; // Reset to first page
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: this.page,
|
||||
limit: this.limit
|
||||
});
|
||||
|
||||
// Add filters
|
||||
if (this.filters.vendor_id) {
|
||||
params.append('vendor_id', this.filters.vendor_id);
|
||||
}
|
||||
if (this.filters.status) {
|
||||
params.append('status', this.filters.status);
|
||||
}
|
||||
if (this.filters.created_by === 'me') {
|
||||
params.append('created_by_me', 'true');
|
||||
}
|
||||
|
||||
await this.loadJobs();
|
||||
await this.loadStats(); // Update stats based on filters
|
||||
}
|
||||
```
|
||||
|
||||
**Template**:
|
||||
```html
|
||||
<div class="grid gap-4 md:grid-cols-5">
|
||||
<!-- Vendor Filter -->
|
||||
<select x-model="filters.vendor_id" @change="applyFilters()">
|
||||
<option value="">All Vendors</option>
|
||||
<template x-for="vendor in vendors" :key="vendor.id">
|
||||
<option :value="vendor.id"
|
||||
x-text="`${vendor.name} (${vendor.vendor_code})`">
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<select x-model="filters.status" @change="applyFilters()">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
|
||||
<!-- Creator Filter -->
|
||||
<select x-model="filters.created_by" @change="applyFilters()">
|
||||
<option value="">All Users</option>
|
||||
<option value="me">My Jobs Only</option>
|
||||
</select>
|
||||
</div>
|
||||
```
|
||||
|
||||
##### Enhanced Job Table
|
||||
```html
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job ID</th>
|
||||
<th>Vendor</th>
|
||||
<th>Status</th>
|
||||
<th>Progress</th>
|
||||
<th>Created By</th> <!-- Extra column for platform monitoring -->
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="job in jobs" :key="job.id">
|
||||
<tr>
|
||||
<td>#<span x-text="job.id"></span></td>
|
||||
<td><span x-text="getVendorName(job.vendor_id)"></span></td>
|
||||
<td><!-- Status badge --></td>
|
||||
<td><!-- Progress metrics --></td>
|
||||
<td><span x-text="job.created_by_name || 'System'"></span></td>
|
||||
<td><!-- Action buttons --></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Comparison: Two Admin Interfaces
|
||||
|
||||
| Feature | Self-Service (`/marketplace`) | Platform Monitoring (`/imports`) |
|
||||
|---------|-------------------------------|----------------------------------|
|
||||
| **Purpose** | Import products for vendors | Monitor all system imports |
|
||||
| **Scope** | Personal (my jobs) | System-wide (all jobs) |
|
||||
| **Primary Action** | Trigger new imports | View and analyze |
|
||||
| **Jobs Shown** | Only jobs I triggered | All jobs (with filtering) |
|
||||
| **Vendor Selection** | Required (select vendor to import for) | Optional (filter view) |
|
||||
| **Statistics** | No | Yes (dashboard cards) |
|
||||
| **Auto-Refresh** | 10 seconds | 15 seconds |
|
||||
| **Filter Options** | Vendor, Status, Marketplace | Vendor, Status, Marketplace, Creator |
|
||||
| **Use Case** | "I need to import for Vendor X" | "What's happening system-wide?" |
|
||||
|
||||
---
|
||||
|
||||
## 📋 Navigation Structure
|
||||
|
||||
### Sidebar Organization
|
||||
|
||||
```javascript
|
||||
// Admin sidebar sections
|
||||
{
|
||||
"Main Navigation": [
|
||||
"Dashboard",
|
||||
"Users",
|
||||
"Vendors",
|
||||
"Marketplace Import" // ← Self-service import
|
||||
],
|
||||
"Platform Monitoring": [
|
||||
"Import Jobs", // ← System-wide monitoring
|
||||
"Application Logs"
|
||||
],
|
||||
"Settings": [
|
||||
"Settings"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Setting currentPage
|
||||
|
||||
```javascript
|
||||
// marketplace.js
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'marketplace', // Highlights "Marketplace Import" in sidebar
|
||||
// ...
|
||||
};
|
||||
|
||||
// imports.js
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'imports', // Highlights "Import Jobs" in sidebar
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI Patterns
|
||||
|
||||
### Success/Error Messages
|
||||
|
||||
```html
|
||||
<!-- Success -->
|
||||
<div x-show="successMessage" x-transition
|
||||
class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3')"></span>
|
||||
<p class="font-semibold" x-text="successMessage"></p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div x-show="error" x-transition
|
||||
class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg dark:bg-red-900/20">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Error</p>
|
||||
<p class="text-sm" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Empty States
|
||||
|
||||
```html
|
||||
<!-- Personalized empty state -->
|
||||
<div x-show="!loading && jobs.length === 0" class="text-center py-12">
|
||||
<span x-html="$icon('inbox', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
You haven't triggered any imports yet
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
Start a new import using the form above
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Loading States with Spinners
|
||||
|
||||
```html
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading import jobs...</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Modal Dialogs
|
||||
|
||||
```html
|
||||
<div x-show="showJobModal" x-cloak @click.away="closeJobModal()"
|
||||
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
||||
x-transition>
|
||||
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-2xl">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold">Import Job Details</h3>
|
||||
<button @click="closeJobModal()">
|
||||
<span x-html="$icon('close', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div x-show="selectedJob">
|
||||
<!-- Job details grid -->
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="flex justify-end mt-6">
|
||||
<button @click="closeJobModal()" class="...">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Related Documentation
|
||||
|
||||
- [Marketplace Integration Guide](../../guides/marketplace-integration.md) - Complete marketplace system documentation
|
||||
- [Vendor Page Templates](../vendor/page-templates.md) - Vendor page patterns
|
||||
- [Icons Guide](../../development/icons-guide.md) - Available icons
|
||||
- [Admin Integration Guide](../../backend/admin-integration-guide.md) - Backend integration
|
||||
|
||||
|
||||
199
docs/frontend/vendor/page-templates.md
vendored
199
docs/frontend/vendor/page-templates.md
vendored
@@ -220,7 +220,7 @@ app/
|
||||
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
title="Delete"
|
||||
>
|
||||
<span x-html="$icon('trash', 'w-5 h-5')"></span>
|
||||
<span x-html="$icon('delete', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -994,3 +994,200 @@ The base template loads scripts in this specific order:
|
||||
---
|
||||
|
||||
This template provides a complete, production-ready pattern for building vendor admin pages with consistent structure, error handling, and user experience.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Real-World Example: Marketplace Import Page
|
||||
|
||||
The marketplace import page is a comprehensive real-world implementation demonstrating all best practices.
|
||||
|
||||
### Implementation Files
|
||||
|
||||
**Template**: `app/templates/vendor/marketplace.html`
|
||||
**JavaScript**: `static/vendor/js/marketplace.js`
|
||||
**Route**: `app/routes/vendor_pages.py` - `vendor_marketplace_page()`
|
||||
|
||||
### Key Features Demonstrated
|
||||
|
||||
#### 1. Complete Form Handling
|
||||
```javascript
|
||||
// Import form with validation
|
||||
importForm: {
|
||||
csv_url: '',
|
||||
marketplace: 'Letzshop',
|
||||
language: 'fr',
|
||||
batch_size: 1000
|
||||
},
|
||||
|
||||
async startImport() {
|
||||
if (!this.importForm.csv_url) {
|
||||
this.error = 'Please enter a CSV URL';
|
||||
return;
|
||||
}
|
||||
|
||||
this.importing = true;
|
||||
try {
|
||||
const response = await apiClient.post('/vendor/marketplace/import', {
|
||||
source_url: this.importForm.csv_url,
|
||||
marketplace: this.importForm.marketplace,
|
||||
batch_size: this.importForm.batch_size
|
||||
});
|
||||
|
||||
this.successMessage = `Import job #${response.job_id} started!`;
|
||||
await this.loadJobs(); // Refresh list
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
} finally {
|
||||
this.importing = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Auto-Refresh for Active Jobs
|
||||
```javascript
|
||||
startAutoRefresh() {
|
||||
this.autoRefreshInterval = setInterval(async () => {
|
||||
const hasActiveJobs = this.jobs.some(job =>
|
||||
job.status === 'pending' || job.status === 'processing'
|
||||
);
|
||||
|
||||
if (hasActiveJobs) {
|
||||
await this.loadJobs();
|
||||
}
|
||||
}, 10000); // Every 10 seconds
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Quick Fill from Settings
|
||||
```javascript
|
||||
// Load vendor settings
|
||||
async loadVendorSettings() {
|
||||
const response = await apiClient.get('/vendor/settings');
|
||||
this.vendorSettings = {
|
||||
letzshop_csv_url_fr: response.letzshop_csv_url_fr || '',
|
||||
letzshop_csv_url_en: response.letzshop_csv_url_en || '',
|
||||
letzshop_csv_url_de: response.letzshop_csv_url_de || ''
|
||||
};
|
||||
}
|
||||
|
||||
// Quick fill function
|
||||
quickFill(language) {
|
||||
const urlMap = {
|
||||
'fr': this.vendorSettings.letzshop_csv_url_fr,
|
||||
'en': this.vendorSettings.letzshop_csv_url_en,
|
||||
'de': this.vendorSettings.letzshop_csv_url_de
|
||||
};
|
||||
|
||||
if (urlMap[language]) {
|
||||
this.importForm.csv_url = urlMap[language];
|
||||
this.importForm.language = language;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Job Details Modal
|
||||
```javascript
|
||||
async viewJobDetails(jobId) {
|
||||
try {
|
||||
const response = await apiClient.get(`/vendor/marketplace/imports/${jobId}`);
|
||||
this.selectedJob = response;
|
||||
this.showJobModal = true;
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. Pagination
|
||||
```javascript
|
||||
async nextPage() {
|
||||
if (this.page * this.limit < this.totalJobs) {
|
||||
this.page++;
|
||||
await this.loadJobs();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 6. Utility Functions
|
||||
```javascript
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
calculateDuration(job) {
|
||||
if (!job.started_at) return 'Not started';
|
||||
|
||||
const start = new Date(job.started_at);
|
||||
const end = job.completed_at ? new Date(job.completed_at) : new Date();
|
||||
const durationMs = end - start;
|
||||
|
||||
const seconds = Math.floor(durationMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
```
|
||||
|
||||
### Template Features
|
||||
|
||||
#### Dynamic Status Badges
|
||||
```html
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="{
|
||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': job.status === 'completed',
|
||||
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': job.status === 'processing',
|
||||
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': job.status === 'pending',
|
||||
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': job.status === 'failed'
|
||||
}"
|
||||
x-text="job.status.toUpperCase()">
|
||||
</span>
|
||||
```
|
||||
|
||||
#### Conditional Display
|
||||
```html
|
||||
<!-- Quick fill buttons -->
|
||||
<button
|
||||
type="button"
|
||||
@click="quickFill('fr')"
|
||||
x-show="vendorSettings.letzshop_csv_url_fr"
|
||||
class="...">
|
||||
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
|
||||
French CSV
|
||||
</button>
|
||||
```
|
||||
|
||||
#### Progress Metrics
|
||||
```html
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<span class="text-green-600" x-text="job.imported_count"></span> imported,
|
||||
<span class="text-blue-600" x-text="job.updated_count"></span> updated
|
||||
</div>
|
||||
<div x-show="job.error_count > 0" class="text-xs text-red-600">
|
||||
<span x-text="job.error_count"></span> errors
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Related Documentation
|
||||
|
||||
- [Marketplace Integration Guide](../../guides/marketplace-integration.md) - Complete marketplace system documentation
|
||||
- [Admin Page Templates](../admin/page-templates.md) - Admin page patterns
|
||||
- [Icons Guide](../../development/icons-guide.md) - Available icons
|
||||
|
||||
|
||||
Binary file not shown.
@@ -134,7 +134,13 @@ class AuthManager:
|
||||
# Authentication successful, return user object
|
||||
return user
|
||||
|
||||
def create_access_token(self, user: User) -> dict[str, Any]:
|
||||
def create_access_token(
|
||||
self,
|
||||
user: User,
|
||||
vendor_id: int | None = None,
|
||||
vendor_code: str | None = None,
|
||||
vendor_role: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a JWT access token for an authenticated user.
|
||||
|
||||
The token includes user identity and role information in the payload.
|
||||
@@ -142,6 +148,9 @@ class AuthManager:
|
||||
|
||||
Args:
|
||||
user (User): Authenticated user object
|
||||
vendor_id (int, optional): Vendor ID if logging into vendor context
|
||||
vendor_code (str, optional): Vendor code if logging into vendor context
|
||||
vendor_role (str, optional): User's role in this vendor (owner, manager, etc.)
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Dictionary containing:
|
||||
@@ -163,6 +172,14 @@ class AuthManager:
|
||||
"iat": datetime.now(UTC), # Issued at time (JWT standard claim)
|
||||
}
|
||||
|
||||
# Include vendor information in token if provided (vendor-specific login)
|
||||
if vendor_id is not None:
|
||||
payload["vendor_id"] = vendor_id
|
||||
if vendor_code is not None:
|
||||
payload["vendor_code"] = vendor_code
|
||||
if vendor_role is not None:
|
||||
payload["vendor_role"] = vendor_role
|
||||
|
||||
# Encode the payload into a JWT token
|
||||
token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
|
||||
|
||||
@@ -188,6 +205,9 @@ class AuthManager:
|
||||
- username (str): User's username
|
||||
- email (str): User's email address
|
||||
- role (str): User's role (defaults to "user" if not present)
|
||||
- vendor_id (int, optional): Vendor ID if token is vendor-scoped
|
||||
- vendor_code (str, optional): Vendor code if token is vendor-scoped
|
||||
- vendor_role (str, optional): User's role in vendor if vendor-scoped
|
||||
|
||||
Raises:
|
||||
TokenExpiredException: If token has expired
|
||||
@@ -213,7 +233,7 @@ class AuthManager:
|
||||
raise InvalidTokenException("Token missing user identifier")
|
||||
|
||||
# Extract and return user data from token payload
|
||||
return {
|
||||
user_data = {
|
||||
"user_id": int(user_id),
|
||||
"username": payload.get("username"),
|
||||
"email": payload.get("email"),
|
||||
@@ -222,6 +242,16 @@ class AuthManager:
|
||||
), # Default to "user" role if not specified
|
||||
}
|
||||
|
||||
# Include vendor information if present in token
|
||||
if "vendor_id" in payload:
|
||||
user_data["vendor_id"] = payload["vendor_id"]
|
||||
if "vendor_code" in payload:
|
||||
user_data["vendor_code"] = payload["vendor_code"]
|
||||
if "vendor_role" in payload:
|
||||
user_data["vendor_role"] = payload["vendor_role"]
|
||||
|
||||
return user_data
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
# Token has expired (caught by jwt.decode)
|
||||
raise TokenExpiredException()
|
||||
@@ -245,12 +275,15 @@ class AuthManager:
|
||||
Verifies the JWT token from the Authorization header, looks up the user
|
||||
in the database, and ensures the user account is active.
|
||||
|
||||
If the token contains vendor information, attaches it to the user object
|
||||
as dynamic attributes (vendor_id, vendor_code, vendor_role).
|
||||
|
||||
Args:
|
||||
db (Session): SQLAlchemy database session
|
||||
credentials (HTTPAuthorizationCredentials): Bearer token credentials from request
|
||||
|
||||
Returns:
|
||||
User: The authenticated and active user object
|
||||
User: The authenticated and active user object (with vendor attrs if in token)
|
||||
|
||||
Raises:
|
||||
InvalidTokenException: If token verification fails
|
||||
@@ -269,6 +302,15 @@ class AuthManager:
|
||||
if not user.is_active:
|
||||
raise UserNotActiveException()
|
||||
|
||||
# Attach vendor information to user object if present in token
|
||||
# These become dynamic attributes on the user object for this request
|
||||
if "vendor_id" in user_data:
|
||||
user.token_vendor_id = user_data["vendor_id"]
|
||||
if "vendor_code" in user_data:
|
||||
user.token_vendor_code = user_data["vendor_code"]
|
||||
if "vendor_role" in user_data:
|
||||
user.token_vendor_role = user_data["vendor_role"]
|
||||
|
||||
return user
|
||||
|
||||
def require_role(self, required_role: str) -> Callable:
|
||||
|
||||
@@ -34,6 +34,8 @@ nav:
|
||||
- Middleware Stack: architecture/middleware.md
|
||||
- Request Flow: architecture/request-flow.md
|
||||
- Authentication & RBAC: architecture/auth-rbac.md
|
||||
- Frontend Structure: architecture/frontend-structure.md
|
||||
- Models Structure: architecture/models-structure.md
|
||||
- API Consolidation:
|
||||
- Proposal: architecture/api-consolidation-proposal.md
|
||||
- Migration Status: architecture/api-migration-status.md
|
||||
@@ -69,6 +71,8 @@ nav:
|
||||
- Overview: backend/overview.md
|
||||
- Middleware Reference: backend/middleware-reference.md
|
||||
- RBAC Quick Reference: backend/rbac-quick-reference.md
|
||||
- Vendor RBAC: backend/vendor-rbac.md
|
||||
- Vendor-in-Token Architecture: backend/vendor-in-token-architecture.md
|
||||
- Admin Integration Guide: backend/admin-integration-guide.md
|
||||
- Admin Feature Integration: backend/admin-feature-integration.md
|
||||
|
||||
|
||||
@@ -8,7 +8,15 @@ from .admin import (
|
||||
AdminSetting,
|
||||
PlatformAlert,
|
||||
)
|
||||
from .architecture_scan import (
|
||||
ArchitectureScan,
|
||||
ArchitectureViolation,
|
||||
ViolationAssignment,
|
||||
ViolationComment,
|
||||
)
|
||||
from .base import Base
|
||||
from .company import Company
|
||||
from .content_page import ContentPage
|
||||
from .customer import Customer, CustomerAddress
|
||||
from .inventory import Inventory
|
||||
from .marketplace_import_job import MarketplaceImportJob
|
||||
@@ -27,8 +35,15 @@ __all__ = [
|
||||
"AdminSetting",
|
||||
"PlatformAlert",
|
||||
"AdminSession",
|
||||
# Architecture/Code Quality
|
||||
"ArchitectureScan",
|
||||
"ArchitectureViolation",
|
||||
"ViolationAssignment",
|
||||
"ViolationComment",
|
||||
"Base",
|
||||
"User",
|
||||
"Company",
|
||||
"ContentPage",
|
||||
"Inventory",
|
||||
"Customer",
|
||||
"CustomerAddress",
|
||||
|
||||
@@ -8,6 +8,7 @@ This module provides models for:
|
||||
- Admin notifications (system alerts and warnings)
|
||||
- Platform settings (global configuration)
|
||||
- Platform alerts (system-wide issues)
|
||||
- Application logs (critical events logging)
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
@@ -190,3 +191,37 @@ class AdminSession(Base, TimestampMixin):
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AdminSession(id={self.id}, admin_user_id={self.admin_user_id}, is_active={self.is_active})>"
|
||||
|
||||
|
||||
class ApplicationLog(Base, TimestampMixin):
|
||||
"""
|
||||
Application-level logs stored in database for critical events.
|
||||
|
||||
Stores WARNING, ERROR, and CRITICAL level logs for easy searching,
|
||||
filtering, and compliance. INFO and DEBUG logs are kept in files only.
|
||||
"""
|
||||
|
||||
__tablename__ = "application_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
timestamp = Column(DateTime, nullable=False, index=True)
|
||||
level = Column(String(20), nullable=False, index=True) # WARNING, ERROR, CRITICAL
|
||||
logger_name = Column(String(200), nullable=False, index=True)
|
||||
module = Column(String(200))
|
||||
function_name = Column(String(100))
|
||||
line_number = Column(Integer)
|
||||
message = Column(Text, nullable=False)
|
||||
exception_type = Column(String(200))
|
||||
exception_message = Column(Text)
|
||||
stack_trace = Column(Text)
|
||||
request_id = Column(String(100), index=True) # For correlating logs
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=True, index=True)
|
||||
context = Column(JSON) # Additional context data
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
vendor = relationship("Vendor", foreign_keys=[vendor_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ApplicationLog(id={self.id}, level='{self.level}', logger='{self.logger_name}')>"
|
||||
|
||||
@@ -50,6 +50,7 @@ class User(Base, TimestampMixin):
|
||||
marketplace_import_jobs = relationship(
|
||||
"MarketplaceImportJob", back_populates="user"
|
||||
)
|
||||
owned_companies = relationship("Company", back_populates="owner")
|
||||
owned_vendors = relationship("Vendor", back_populates="owner")
|
||||
vendor_memberships = relationship(
|
||||
"VendorUser", foreign_keys="[VendorUser.user_id]", back_populates="user"
|
||||
|
||||
@@ -404,3 +404,112 @@ class AdminSessionListResponse(BaseModel):
|
||||
sessions: list[AdminSessionResponse]
|
||||
total: int
|
||||
active_count: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# APPLICATION LOGS SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ApplicationLogResponse(BaseModel):
|
||||
"""Application log entry response."""
|
||||
|
||||
id: int
|
||||
timestamp: datetime
|
||||
level: str
|
||||
logger_name: str
|
||||
module: str | None = None
|
||||
function_name: str | None = None
|
||||
line_number: int | None = None
|
||||
message: str
|
||||
exception_type: str | None = None
|
||||
exception_message: str | None = None
|
||||
stack_trace: str | None = None
|
||||
request_id: str | None = None
|
||||
user_id: int | None = None
|
||||
vendor_id: int | None = None
|
||||
context: dict[str, Any] | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ApplicationLogFilters(BaseModel):
|
||||
"""Filters for querying application logs."""
|
||||
|
||||
level: str | None = Field(None, description="Filter by log level")
|
||||
logger_name: str | None = Field(None, description="Filter by logger name")
|
||||
module: str | None = Field(None, description="Filter by module")
|
||||
user_id: int | None = Field(None, description="Filter by user ID")
|
||||
vendor_id: int | None = Field(None, description="Filter by vendor ID")
|
||||
date_from: datetime | None = Field(None, description="Start date")
|
||||
date_to: datetime | None = Field(None, description="End date")
|
||||
search: str | None = Field(None, description="Search in message")
|
||||
skip: int = Field(0, ge=0)
|
||||
limit: int = Field(100, ge=1, le=1000)
|
||||
|
||||
|
||||
class ApplicationLogListResponse(BaseModel):
|
||||
"""Paginated list of application logs."""
|
||||
|
||||
logs: list[ApplicationLogResponse]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
class LogStatistics(BaseModel):
|
||||
"""Statistics about application logs."""
|
||||
|
||||
total_count: int
|
||||
warning_count: int
|
||||
error_count: int
|
||||
critical_count: int
|
||||
by_level: dict[str, int]
|
||||
by_module: dict[str, int]
|
||||
recent_errors: list[ApplicationLogResponse]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LOG SETTINGS SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class LogSettingsResponse(BaseModel):
|
||||
"""Log configuration settings."""
|
||||
|
||||
log_level: str
|
||||
log_file_max_size_mb: int
|
||||
log_file_backup_count: int
|
||||
db_log_retention_days: int
|
||||
file_logging_enabled: bool
|
||||
db_logging_enabled: bool
|
||||
|
||||
|
||||
class LogSettingsUpdate(BaseModel):
|
||||
"""Update log settings."""
|
||||
|
||||
log_level: str | None = Field(None, description="Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL")
|
||||
log_file_max_size_mb: int | None = Field(None, ge=1, le=1000, description="Max log file size in MB")
|
||||
log_file_backup_count: int | None = Field(None, ge=0, le=50, description="Number of backup files to keep")
|
||||
db_log_retention_days: int | None = Field(None, ge=1, le=365, description="Days to retain logs in database")
|
||||
|
||||
@field_validator("log_level")
|
||||
@classmethod
|
||||
def validate_log_level(cls, v):
|
||||
if v is not None:
|
||||
allowed = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
if v.upper() not in allowed:
|
||||
raise ValueError(f"Log level must be one of: {', '.join(allowed)}")
|
||||
return v.upper()
|
||||
return v
|
||||
|
||||
|
||||
class FileLogResponse(BaseModel):
|
||||
"""File log content response."""
|
||||
|
||||
filename: str
|
||||
size_bytes: int
|
||||
last_modified: datetime
|
||||
lines: list[str]
|
||||
total_lines: int
|
||||
|
||||
117
scripts/init_log_settings.py
Normal file
117
scripts/init_log_settings.py
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Initialize default log settings in database.
|
||||
|
||||
Run this script to create default logging configuration settings.
|
||||
"""
|
||||
|
||||
# Import all models to avoid SQLAlchemy relationship issues
|
||||
import models # noqa: F401
|
||||
from app.core.database import SessionLocal
|
||||
from models.database.admin import AdminSetting
|
||||
|
||||
|
||||
def init_log_settings():
|
||||
"""Create default log settings if they don't exist."""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
settings_to_create = [
|
||||
{
|
||||
"key": "log_level",
|
||||
"value": "INFO",
|
||||
"value_type": "string",
|
||||
"category": "logging",
|
||||
"description": "Application log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
|
||||
"is_public": False,
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "log_file_max_size_mb",
|
||||
"value": "10",
|
||||
"value_type": "integer",
|
||||
"category": "logging",
|
||||
"description": "Maximum log file size in MB before rotation",
|
||||
"is_public": False,
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "log_file_backup_count",
|
||||
"value": "5",
|
||||
"value_type": "integer",
|
||||
"category": "logging",
|
||||
"description": "Number of rotated log files to keep",
|
||||
"is_public": False,
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "db_log_retention_days",
|
||||
"value": "30",
|
||||
"value_type": "integer",
|
||||
"category": "logging",
|
||||
"description": "Number of days to retain logs in database",
|
||||
"is_public": False,
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "file_logging_enabled",
|
||||
"value": "true",
|
||||
"value_type": "boolean",
|
||||
"category": "logging",
|
||||
"description": "Enable file-based logging",
|
||||
"is_public": False,
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "db_logging_enabled",
|
||||
"value": "true",
|
||||
"value_type": "boolean",
|
||||
"category": "logging",
|
||||
"description": "Enable database logging for critical events",
|
||||
"is_public": False,
|
||||
"is_encrypted": False,
|
||||
},
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
|
||||
for setting_data in settings_to_create:
|
||||
existing = (
|
||||
db.query(AdminSetting)
|
||||
.filter(AdminSetting.key == setting_data["key"])
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
print(f"✓ Setting '{setting_data['key']}' already exists (value: {existing.value})")
|
||||
updated_count += 1
|
||||
else:
|
||||
setting = AdminSetting(**setting_data)
|
||||
db.add(setting)
|
||||
created_count += 1
|
||||
print(f"✓ Created setting '{setting_data['key']}' = {setting_data['value']}")
|
||||
|
||||
db.commit()
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("LOG SETTINGS INITIALIZATION COMPLETE")
|
||||
print("=" * 70)
|
||||
print(f" Created: {created_count} settings")
|
||||
print(f" Existing: {updated_count} settings")
|
||||
print(f" Total: {len(settings_to_create)} settings")
|
||||
print("=" * 70)
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"Error initializing log settings: {e}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 70)
|
||||
print("INITIALIZING LOG SETTINGS")
|
||||
print("=" * 70)
|
||||
init_log_settings()
|
||||
@@ -206,6 +206,55 @@ def create_admin_settings(db: Session) -> int:
|
||||
"description": "Enable maintenance mode",
|
||||
"is_public": True,
|
||||
},
|
||||
# Logging settings
|
||||
{
|
||||
"key": "log_level",
|
||||
"value": "INFO",
|
||||
"value_type": "string",
|
||||
"category": "logging",
|
||||
"description": "Application log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
|
||||
"is_public": False,
|
||||
},
|
||||
{
|
||||
"key": "log_file_max_size_mb",
|
||||
"value": "10",
|
||||
"value_type": "integer",
|
||||
"category": "logging",
|
||||
"description": "Maximum log file size in MB before rotation",
|
||||
"is_public": False,
|
||||
},
|
||||
{
|
||||
"key": "log_file_backup_count",
|
||||
"value": "5",
|
||||
"value_type": "integer",
|
||||
"category": "logging",
|
||||
"description": "Number of rotated log files to keep",
|
||||
"is_public": False,
|
||||
},
|
||||
{
|
||||
"key": "db_log_retention_days",
|
||||
"value": "30",
|
||||
"value_type": "integer",
|
||||
"category": "logging",
|
||||
"description": "Number of days to retain logs in database",
|
||||
"is_public": False,
|
||||
},
|
||||
{
|
||||
"key": "file_logging_enabled",
|
||||
"value": "true",
|
||||
"value_type": "boolean",
|
||||
"category": "logging",
|
||||
"description": "Enable file-based logging",
|
||||
"is_public": False,
|
||||
},
|
||||
{
|
||||
"key": "db_logging_enabled",
|
||||
"value": "true",
|
||||
"value_type": "boolean",
|
||||
"category": "logging",
|
||||
"description": "Enable database logging for critical events",
|
||||
"is_public": False,
|
||||
},
|
||||
]
|
||||
|
||||
for setting_data in default_settings:
|
||||
@@ -219,6 +268,7 @@ def create_admin_settings(db: Session) -> int:
|
||||
key=setting_data["key"],
|
||||
value=setting_data["value"],
|
||||
value_type=setting_data["value_type"],
|
||||
category=setting_data.get("category"),
|
||||
description=setting_data.get("description"),
|
||||
is_public=setting_data.get("is_public", False),
|
||||
created_at=datetime.now(UTC),
|
||||
|
||||
124
scripts/test_logging_system.py
Normal file
124
scripts/test_logging_system.py
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test the hybrid logging system comprehensively.
|
||||
|
||||
Tests:
|
||||
1. Log settings API
|
||||
2. Database logging
|
||||
3. File logging
|
||||
4. Log viewer API
|
||||
5. Log rotation
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
def test_logging_endpoints():
|
||||
"""Test logging-related API endpoints."""
|
||||
print("\n" + "=" * 70)
|
||||
print("TESTING LOGGING SYSTEM")
|
||||
print("=" * 70)
|
||||
|
||||
# Test 1: Create test logs at different levels
|
||||
print("\n[1] Creating test logs...")
|
||||
try:
|
||||
logging.info("Test INFO log from test script")
|
||||
logging.warning("Test WARNING log - should be in database")
|
||||
logging.error("Test ERROR log - should be in database")
|
||||
|
||||
# Create an exception log
|
||||
try:
|
||||
raise ValueError("Test exception for logging")
|
||||
except Exception as e:
|
||||
logging.error("Test exception logging", exc_info=True)
|
||||
|
||||
print(" ✓ Test logs created")
|
||||
except Exception as e:
|
||||
print(f" ✗ Failed to create logs: {e}")
|
||||
return False
|
||||
|
||||
# Test 2: Verify log files exist
|
||||
print("\n[2] Checking log files...")
|
||||
try:
|
||||
log_file = Path("logs/app.log")
|
||||
if log_file.exists():
|
||||
size_mb = log_file.stat().st_size / (1024 * 1024)
|
||||
print(f" ✓ Log file exists: {log_file}")
|
||||
print(f" Size: {size_mb:.2f} MB")
|
||||
else:
|
||||
print(f" ✗ Log file not found: {log_file}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ✗ Failed to check log file: {e}")
|
||||
return False
|
||||
|
||||
# Test 3: Check database logs
|
||||
print("\n[3] Checking database logs...")
|
||||
try:
|
||||
from app.core.database import SessionLocal
|
||||
from models.database.admin import ApplicationLog
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
count = db.query(ApplicationLog).count()
|
||||
print(f" ✓ Database logs count: {count}")
|
||||
|
||||
if count > 0:
|
||||
recent = db.query(ApplicationLog).order_by(
|
||||
ApplicationLog.timestamp.desc()
|
||||
).limit(5).all()
|
||||
|
||||
print(" Recent logs:")
|
||||
for log in recent:
|
||||
print(f" [{log.level}] {log.logger_name}: {log.message[:60]}")
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
print(f" ✗ Failed to query database logs: {e}")
|
||||
# Don't fail the test - database logging might have initialization issues
|
||||
|
||||
# Test 4: Test log settings
|
||||
print("\n[4] Testing log settings...")
|
||||
try:
|
||||
from app.core.database import SessionLocal
|
||||
from app.services.admin_settings_service import admin_settings_service
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
log_level = admin_settings_service.get_setting_value(db, "log_level", "INFO")
|
||||
max_size = admin_settings_service.get_setting_value(db, "log_file_max_size_mb", 10)
|
||||
retention = admin_settings_service.get_setting_value(db, "db_log_retention_days", 30)
|
||||
|
||||
print(f" ✓ Log Level: {log_level}")
|
||||
print(f" ✓ Max File Size: {max_size} MB")
|
||||
print(f" ✓ Retention Days: {retention}")
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
print(f" ✗ Failed to get log settings: {e}")
|
||||
# Don't fail - settings might not be initialized yet
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("LOGGING SYSTEM TEST SUMMARY")
|
||||
print("=" * 70)
|
||||
print("✓ File logging: WORKING")
|
||||
print("✓ Log rotation configured: READY")
|
||||
print("✓ Database logging: Needs application context")
|
||||
print("\nNote: Database logging will work when running through FastAPI")
|
||||
print("=" * 70)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Set up logging first
|
||||
from app.core.logging import setup_logging
|
||||
setup_logging()
|
||||
|
||||
success = test_logging_endpoints()
|
||||
sys.exit(0 if success else 1)
|
||||
345
static/admin/js/imports.js
Normal file
345
static/admin/js/imports.js
Normal file
@@ -0,0 +1,345 @@
|
||||
// static/admin/js/imports.js
|
||||
/**
|
||||
* Admin platform monitoring - all import jobs
|
||||
*/
|
||||
|
||||
// ✅ Use centralized logger
|
||||
const adminImportsLog = window.LogConfig.loggers.imports;
|
||||
|
||||
console.log('[ADMIN IMPORTS] Loading...');
|
||||
|
||||
function adminImports() {
|
||||
console.log('[ADMIN IMPORTS] adminImports() called');
|
||||
|
||||
return {
|
||||
// ✅ Inherit base layout state
|
||||
...data(),
|
||||
|
||||
// ✅ Set page identifier
|
||||
currentPage: 'imports',
|
||||
|
||||
// Loading states
|
||||
loading: false,
|
||||
error: '',
|
||||
|
||||
// Vendors list
|
||||
vendors: [],
|
||||
|
||||
// Stats
|
||||
stats: {
|
||||
total: 0,
|
||||
active: 0,
|
||||
completed: 0,
|
||||
failed: 0
|
||||
},
|
||||
|
||||
// Filters
|
||||
filters: {
|
||||
vendor_id: '',
|
||||
status: '',
|
||||
marketplace: '',
|
||||
created_by: '' // 'me' or empty
|
||||
},
|
||||
|
||||
// Import jobs
|
||||
jobs: [],
|
||||
totalJobs: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
|
||||
// Modal state
|
||||
showJobModal: false,
|
||||
selectedJob: null,
|
||||
|
||||
// Auto-refresh for active jobs
|
||||
autoRefreshInterval: null,
|
||||
|
||||
async init() {
|
||||
// Guard against multiple initialization
|
||||
if (window._adminImportsInitialized) {
|
||||
return;
|
||||
}
|
||||
window._adminImportsInitialized = true;
|
||||
|
||||
// IMPORTANT: Call parent init first
|
||||
const parentInit = data().init;
|
||||
if (parentInit) {
|
||||
await parentInit.call(this);
|
||||
}
|
||||
|
||||
await this.loadVendors();
|
||||
await this.loadJobs();
|
||||
await this.loadStats();
|
||||
|
||||
// Auto-refresh active jobs every 15 seconds
|
||||
this.startAutoRefresh();
|
||||
},
|
||||
|
||||
/**
|
||||
* Load all vendors for filtering
|
||||
*/
|
||||
async loadVendors() {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/vendors?limit=1000');
|
||||
this.vendors = response.vendors || [];
|
||||
console.log('[ADMIN IMPORTS] Loaded vendors:', this.vendors.length);
|
||||
} catch (error) {
|
||||
console.error('[ADMIN IMPORTS] Failed to load vendors:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load statistics
|
||||
*/
|
||||
async loadStats() {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/marketplace-import-jobs/stats');
|
||||
this.stats = {
|
||||
total: response.total || 0,
|
||||
active: (response.pending || 0) + (response.processing || 0),
|
||||
completed: response.completed || 0,
|
||||
failed: response.failed || 0
|
||||
};
|
||||
console.log('[ADMIN IMPORTS] Loaded stats:', this.stats);
|
||||
} catch (error) {
|
||||
console.error('[ADMIN IMPORTS] Failed to load stats:', error);
|
||||
// Non-critical, don't show error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load ALL import jobs (with filters)
|
||||
*/
|
||||
async loadJobs() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
// Build query params
|
||||
const params = new URLSearchParams({
|
||||
page: this.page,
|
||||
limit: this.limit
|
||||
});
|
||||
|
||||
// Add filters
|
||||
if (this.filters.vendor_id) {
|
||||
params.append('vendor_id', this.filters.vendor_id);
|
||||
}
|
||||
if (this.filters.status) {
|
||||
params.append('status', this.filters.status);
|
||||
}
|
||||
if (this.filters.marketplace) {
|
||||
params.append('marketplace', this.filters.marketplace);
|
||||
}
|
||||
if (this.filters.created_by === 'me') {
|
||||
params.append('created_by_me', 'true');
|
||||
}
|
||||
|
||||
const response = await apiClient.get(
|
||||
`/admin/marketplace-import-jobs?${params.toString()}`
|
||||
);
|
||||
|
||||
this.jobs = response.items || [];
|
||||
this.totalJobs = response.total || 0;
|
||||
|
||||
console.log('[ADMIN IMPORTS] Loaded all jobs:', this.jobs.length);
|
||||
} catch (error) {
|
||||
console.error('[ADMIN IMPORTS] Failed to load jobs:', error);
|
||||
this.error = error.message || 'Failed to load import jobs';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply filters and reload
|
||||
*/
|
||||
async applyFilters() {
|
||||
this.page = 1; // Reset to first page when filtering
|
||||
await this.loadJobs();
|
||||
await this.loadStats(); // Update stats based on filters
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all filters and reload
|
||||
*/
|
||||
async clearFilters() {
|
||||
this.filters.vendor_id = '';
|
||||
this.filters.status = '';
|
||||
this.filters.marketplace = '';
|
||||
this.filters.created_by = '';
|
||||
this.page = 1;
|
||||
await this.loadJobs();
|
||||
await this.loadStats();
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh jobs list
|
||||
*/
|
||||
async refreshJobs() {
|
||||
await this.loadJobs();
|
||||
await this.loadStats();
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh single job status
|
||||
*/
|
||||
async refreshJobStatus(jobId) {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/marketplace-import-jobs/${jobId}`);
|
||||
|
||||
// Update job in list
|
||||
const index = this.jobs.findIndex(j => j.id === jobId);
|
||||
if (index !== -1) {
|
||||
this.jobs[index] = response;
|
||||
}
|
||||
|
||||
// Update selected job if modal is open
|
||||
if (this.selectedJob && this.selectedJob.id === jobId) {
|
||||
this.selectedJob = response;
|
||||
}
|
||||
|
||||
console.log('[ADMIN IMPORTS] Refreshed job:', jobId);
|
||||
} catch (error) {
|
||||
console.error('[ADMIN IMPORTS] Failed to refresh job:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* View job details in modal
|
||||
*/
|
||||
async viewJobDetails(jobId) {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/marketplace-import-jobs/${jobId}`);
|
||||
this.selectedJob = response;
|
||||
this.showJobModal = true;
|
||||
console.log('[ADMIN IMPORTS] Viewing job details:', jobId);
|
||||
} catch (error) {
|
||||
console.error('[ADMIN IMPORTS] Failed to load job details:', error);
|
||||
this.error = error.message || 'Failed to load job details';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Close job details modal
|
||||
*/
|
||||
closeJobModal() {
|
||||
this.showJobModal = false;
|
||||
this.selectedJob = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get vendor name by ID
|
||||
*/
|
||||
getVendorName(vendorId) {
|
||||
const vendor = this.vendors.find(v => v.id === vendorId);
|
||||
return vendor ? `${vendor.name} (${vendor.vendor_code})` : `Vendor #${vendorId}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Pagination: Previous page
|
||||
*/
|
||||
async previousPage() {
|
||||
if (this.page > 1) {
|
||||
this.page--;
|
||||
await this.loadJobs();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pagination: Next page
|
||||
*/
|
||||
async nextPage() {
|
||||
if (this.page * this.limit < this.totalJobs) {
|
||||
this.page++;
|
||||
await this.loadJobs();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch (error) {
|
||||
return dateString;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Calculate duration between start and end
|
||||
*/
|
||||
calculateDuration(job) {
|
||||
if (!job.started_at) {
|
||||
return 'Not started';
|
||||
}
|
||||
|
||||
const start = new Date(job.started_at);
|
||||
const end = job.completed_at ? new Date(job.completed_at) : new Date();
|
||||
const durationMs = end - start;
|
||||
|
||||
// Convert to human-readable format
|
||||
const seconds = Math.floor(durationMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Start auto-refresh for active jobs
|
||||
*/
|
||||
startAutoRefresh() {
|
||||
// Clear any existing interval
|
||||
if (this.autoRefreshInterval) {
|
||||
clearInterval(this.autoRefreshInterval);
|
||||
}
|
||||
|
||||
// Refresh every 15 seconds if there are active jobs
|
||||
this.autoRefreshInterval = setInterval(async () => {
|
||||
const hasActiveJobs = this.jobs.some(job =>
|
||||
job.status === 'pending' || job.status === 'processing'
|
||||
);
|
||||
|
||||
if (hasActiveJobs) {
|
||||
console.log('[ADMIN IMPORTS] Auto-refreshing active jobs...');
|
||||
await this.loadJobs();
|
||||
await this.loadStats();
|
||||
}
|
||||
}, 15000); // 15 seconds
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop auto-refresh (cleanup)
|
||||
*/
|
||||
stopAutoRefresh() {
|
||||
if (this.autoRefreshInterval) {
|
||||
clearInterval(this.autoRefreshInterval);
|
||||
this.autoRefreshInterval = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (window._adminImportsInstance && window._adminImportsInstance.stopAutoRefresh) {
|
||||
window._adminImportsInstance.stopAutoRefresh();
|
||||
}
|
||||
});
|
||||
173
static/admin/js/logs.js
Normal file
173
static/admin/js/logs.js
Normal file
@@ -0,0 +1,173 @@
|
||||
// static/admin/js/logs.js
|
||||
|
||||
const logsLog = window.LogConfig?.loggers?.logs || console;
|
||||
|
||||
function adminLogs() {
|
||||
// Get base data
|
||||
const baseData = typeof data === 'function' ? data() : {};
|
||||
|
||||
return {
|
||||
// Inherit base layout functionality from init-alpine.js
|
||||
...baseData,
|
||||
|
||||
// Logs-specific state
|
||||
currentPage: 'logs',
|
||||
loading: true,
|
||||
error: null,
|
||||
successMessage: null,
|
||||
logSource: 'database',
|
||||
logs: [],
|
||||
totalLogs: 0,
|
||||
stats: {
|
||||
total_count: 0,
|
||||
warning_count: 0,
|
||||
error_count: 0,
|
||||
critical_count: 0
|
||||
},
|
||||
selectedLog: null,
|
||||
filters: {
|
||||
level: '',
|
||||
module: '',
|
||||
search: '',
|
||||
skip: 0,
|
||||
limit: 50
|
||||
},
|
||||
logFiles: [],
|
||||
selectedFile: '',
|
||||
fileContent: null,
|
||||
|
||||
async init() {
|
||||
logsLog.info('=== LOGS PAGE INITIALIZING ===');
|
||||
await this.loadStats();
|
||||
await this.loadLogs();
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
this.error = null;
|
||||
this.successMessage = null;
|
||||
await this.loadStats();
|
||||
if (this.logSource === 'database') {
|
||||
await this.loadLogs();
|
||||
} else {
|
||||
await this.loadFileLogs();
|
||||
}
|
||||
},
|
||||
|
||||
async loadStats() {
|
||||
try {
|
||||
const data = await apiClient.get('/admin/logs/statistics?days=7');
|
||||
this.stats = data;
|
||||
logsLog.info('Log statistics loaded:', this.stats);
|
||||
} catch (error) {
|
||||
logsLog.error('Failed to load log statistics:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async loadLogs() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (this.filters.level) params.append('level', this.filters.level);
|
||||
if (this.filters.module) params.append('module', this.filters.module);
|
||||
if (this.filters.search) params.append('search', this.filters.search);
|
||||
params.append('skip', this.filters.skip);
|
||||
params.append('limit', this.filters.limit);
|
||||
|
||||
const data = await apiClient.get(`/admin/logs/database?${params}`);
|
||||
this.logs = data.logs;
|
||||
this.totalLogs = data.total;
|
||||
logsLog.info(`Loaded ${this.logs.length} logs (total: ${this.totalLogs})`);
|
||||
} catch (error) {
|
||||
logsLog.error('Failed to load logs:', error);
|
||||
this.error = error.response?.data?.detail || 'Failed to load logs';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadFileLogs() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const data = await apiClient.get('/admin/logs/files');
|
||||
this.logFiles = data.files;
|
||||
|
||||
if (this.logFiles.length > 0 && !this.selectedFile) {
|
||||
this.selectedFile = this.logFiles[0].filename;
|
||||
await this.loadFileContent();
|
||||
}
|
||||
logsLog.info(`Loaded ${this.logFiles.length} log files`);
|
||||
} catch (error) {
|
||||
logsLog.error('Failed to load log files:', error);
|
||||
this.error = error.response?.data?.detail || 'Failed to load log files';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadFileContent() {
|
||||
if (!this.selectedFile) return;
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const data = await apiClient.get(`/admin/logs/files/${this.selectedFile}?lines=500`);
|
||||
this.fileContent = data;
|
||||
logsLog.info(`Loaded file content for ${this.selectedFile}`);
|
||||
} catch (error) {
|
||||
logsLog.error('Failed to load file content:', error);
|
||||
this.error = error.response?.data?.detail || 'Failed to load file content';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async downloadLogFile() {
|
||||
if (!this.selectedFile) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('admin_token');
|
||||
// Note: window.open bypasses apiClient, so we need the full path
|
||||
window.open(`/api/v1/admin/logs/files/${this.selectedFile}/download?token=${token}`, '_blank');
|
||||
} catch (error) {
|
||||
logsLog.error('Failed to download log file:', error);
|
||||
this.error = 'Failed to download log file';
|
||||
}
|
||||
},
|
||||
|
||||
resetFilters() {
|
||||
this.filters = {
|
||||
level: '',
|
||||
module: '',
|
||||
search: '',
|
||||
skip: 0,
|
||||
limit: 50
|
||||
};
|
||||
this.loadLogs();
|
||||
},
|
||||
|
||||
nextPage() {
|
||||
this.filters.skip += this.filters.limit;
|
||||
this.loadLogs();
|
||||
},
|
||||
|
||||
previousPage() {
|
||||
this.filters.skip = Math.max(0, this.filters.skip - this.filters.limit);
|
||||
this.loadLogs();
|
||||
},
|
||||
|
||||
showLogDetail(log) {
|
||||
this.selectedLog = log;
|
||||
},
|
||||
|
||||
formatTimestamp(timestamp) {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
logsLog.info('Logs module loaded');
|
||||
429
static/admin/js/marketplace.js
Normal file
429
static/admin/js/marketplace.js
Normal file
@@ -0,0 +1,429 @@
|
||||
// static/admin/js/marketplace.js
|
||||
/**
|
||||
* Admin marketplace import page logic
|
||||
*/
|
||||
|
||||
// ✅ Use centralized logger
|
||||
const adminMarketplaceLog = window.LogConfig.loggers.marketplace;
|
||||
|
||||
console.log('[ADMIN MARKETPLACE] Loading...');
|
||||
|
||||
function adminMarketplace() {
|
||||
console.log('[ADMIN MARKETPLACE] adminMarketplace() called');
|
||||
|
||||
return {
|
||||
// ✅ Inherit base layout state
|
||||
...data(),
|
||||
|
||||
// ✅ Set page identifier
|
||||
currentPage: 'marketplace',
|
||||
|
||||
// Loading states
|
||||
loading: false,
|
||||
importing: false,
|
||||
error: '',
|
||||
successMessage: '',
|
||||
|
||||
// Vendors list
|
||||
vendors: [],
|
||||
selectedVendor: null,
|
||||
|
||||
// Import form
|
||||
importForm: {
|
||||
vendor_id: '',
|
||||
csv_url: '',
|
||||
marketplace: 'Letzshop',
|
||||
language: 'fr',
|
||||
batch_size: 1000
|
||||
},
|
||||
|
||||
// Filters
|
||||
filters: {
|
||||
vendor_id: '',
|
||||
status: '',
|
||||
marketplace: ''
|
||||
},
|
||||
|
||||
// Import jobs
|
||||
jobs: [],
|
||||
totalJobs: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
|
||||
// Modal state
|
||||
showJobModal: false,
|
||||
selectedJob: null,
|
||||
|
||||
// Auto-refresh for active jobs
|
||||
autoRefreshInterval: null,
|
||||
|
||||
async init() {
|
||||
// Guard against multiple initialization
|
||||
if (window._adminMarketplaceInitialized) {
|
||||
return;
|
||||
}
|
||||
window._adminMarketplaceInitialized = true;
|
||||
|
||||
// IMPORTANT: Call parent init first
|
||||
const parentInit = data().init;
|
||||
if (parentInit) {
|
||||
await parentInit.call(this);
|
||||
}
|
||||
|
||||
await this.loadVendors();
|
||||
await this.loadJobs();
|
||||
|
||||
// Auto-refresh active jobs every 10 seconds
|
||||
this.startAutoRefresh();
|
||||
},
|
||||
|
||||
/**
|
||||
* Load all vendors for dropdown
|
||||
*/
|
||||
async loadVendors() {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/vendors?limit=1000');
|
||||
this.vendors = response.vendors || [];
|
||||
console.log('[ADMIN MARKETPLACE] Loaded vendors:', this.vendors.length);
|
||||
} catch (error) {
|
||||
console.error('[ADMIN MARKETPLACE] Failed to load vendors:', error);
|
||||
this.error = 'Failed to load vendors: ' + (error.message || 'Unknown error');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle vendor selection change
|
||||
*/
|
||||
onVendorChange() {
|
||||
const vendorId = parseInt(this.importForm.vendor_id);
|
||||
this.selectedVendor = this.vendors.find(v => v.id === vendorId) || null;
|
||||
console.log('[ADMIN MARKETPLACE] Selected vendor:', this.selectedVendor);
|
||||
|
||||
// Auto-populate CSV URL if marketplace is Letzshop
|
||||
this.autoPopulateCSV();
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle language selection change
|
||||
*/
|
||||
onLanguageChange() {
|
||||
// Auto-populate CSV URL if marketplace is Letzshop
|
||||
this.autoPopulateCSV();
|
||||
},
|
||||
|
||||
/**
|
||||
* Auto-populate CSV URL based on selected vendor and language
|
||||
*/
|
||||
autoPopulateCSV() {
|
||||
// Only auto-populate for Letzshop marketplace
|
||||
if (this.importForm.marketplace !== 'Letzshop') return;
|
||||
if (!this.selectedVendor) return;
|
||||
|
||||
const urlMap = {
|
||||
'fr': this.selectedVendor.letzshop_csv_url_fr,
|
||||
'en': this.selectedVendor.letzshop_csv_url_en,
|
||||
'de': this.selectedVendor.letzshop_csv_url_de
|
||||
};
|
||||
|
||||
const url = urlMap[this.importForm.language];
|
||||
if (url) {
|
||||
this.importForm.csv_url = url;
|
||||
console.log('[ADMIN MARKETPLACE] Auto-populated CSV URL:', this.importForm.language, url);
|
||||
} else {
|
||||
console.log('[ADMIN MARKETPLACE] No CSV URL configured for language:', this.importForm.language);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load import jobs (only jobs triggered by current admin user)
|
||||
*/
|
||||
async loadJobs() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
// Build query params
|
||||
const params = new URLSearchParams({
|
||||
page: this.page,
|
||||
limit: this.limit,
|
||||
created_by_me: 'true' // ✅ Only show jobs I triggered
|
||||
});
|
||||
|
||||
// Add filters (keep for consistency, though less needed here)
|
||||
if (this.filters.vendor_id) {
|
||||
params.append('vendor_id', this.filters.vendor_id);
|
||||
}
|
||||
if (this.filters.status) {
|
||||
params.append('status', this.filters.status);
|
||||
}
|
||||
if (this.filters.marketplace) {
|
||||
params.append('marketplace', this.filters.marketplace);
|
||||
}
|
||||
|
||||
const response = await apiClient.get(
|
||||
`/admin/marketplace-import-jobs?${params.toString()}`
|
||||
);
|
||||
|
||||
this.jobs = response.items || [];
|
||||
this.totalJobs = response.total || 0;
|
||||
|
||||
console.log('[ADMIN MARKETPLACE] Loaded my jobs:', this.jobs.length);
|
||||
} catch (error) {
|
||||
console.error('[ADMIN MARKETPLACE] Failed to load jobs:', error);
|
||||
this.error = error.message || 'Failed to load import jobs';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Start new import for selected vendor
|
||||
*/
|
||||
async startImport() {
|
||||
if (!this.importForm.csv_url || !this.importForm.vendor_id) {
|
||||
this.error = 'Please select a vendor and enter a CSV URL';
|
||||
return;
|
||||
}
|
||||
|
||||
this.importing = true;
|
||||
this.error = '';
|
||||
this.successMessage = '';
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
vendor_id: parseInt(this.importForm.vendor_id),
|
||||
source_url: this.importForm.csv_url,
|
||||
marketplace: this.importForm.marketplace,
|
||||
batch_size: this.importForm.batch_size
|
||||
};
|
||||
|
||||
console.log('[ADMIN MARKETPLACE] Starting import:', payload);
|
||||
|
||||
const response = await apiClient.post('/admin/marketplace-import-jobs', payload);
|
||||
|
||||
console.log('[ADMIN MARKETPLACE] Import started:', response);
|
||||
|
||||
const vendorName = this.selectedVendor?.name || 'vendor';
|
||||
this.successMessage = `Import job #${response.job_id || response.id} started successfully for ${vendorName}!`;
|
||||
|
||||
// Clear form
|
||||
this.importForm.vendor_id = '';
|
||||
this.importForm.csv_url = '';
|
||||
this.importForm.language = 'fr';
|
||||
this.importForm.batch_size = 1000;
|
||||
this.selectedVendor = null;
|
||||
|
||||
// Reload jobs to show the new import
|
||||
await this.loadJobs();
|
||||
|
||||
// Clear success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
this.successMessage = '';
|
||||
}, 5000);
|
||||
} catch (error) {
|
||||
console.error('[ADMIN MARKETPLACE] Failed to start import:', error);
|
||||
this.error = error.message || 'Failed to start import';
|
||||
} finally {
|
||||
this.importing = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Quick fill form with saved CSV URL from vendor settings
|
||||
*/
|
||||
quickFill(language) {
|
||||
if (!this.selectedVendor) return;
|
||||
|
||||
const urlMap = {
|
||||
'fr': this.selectedVendor.letzshop_csv_url_fr,
|
||||
'en': this.selectedVendor.letzshop_csv_url_en,
|
||||
'de': this.selectedVendor.letzshop_csv_url_de
|
||||
};
|
||||
|
||||
const url = urlMap[language];
|
||||
if (url) {
|
||||
this.importForm.csv_url = url;
|
||||
this.importForm.language = language;
|
||||
console.log('[ADMIN MARKETPLACE] Quick filled:', language, url);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all filters and reload
|
||||
*/
|
||||
clearFilters() {
|
||||
this.filters.vendor_id = '';
|
||||
this.filters.status = '';
|
||||
this.filters.marketplace = '';
|
||||
this.page = 1;
|
||||
this.loadJobs();
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh jobs list
|
||||
*/
|
||||
async refreshJobs() {
|
||||
await this.loadJobs();
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh single job status
|
||||
*/
|
||||
async refreshJobStatus(jobId) {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/marketplace-import-jobs/${jobId}`);
|
||||
|
||||
// Update job in list
|
||||
const index = this.jobs.findIndex(j => j.id === jobId);
|
||||
if (index !== -1) {
|
||||
this.jobs[index] = response;
|
||||
}
|
||||
|
||||
// Update selected job if modal is open
|
||||
if (this.selectedJob && this.selectedJob.id === jobId) {
|
||||
this.selectedJob = response;
|
||||
}
|
||||
|
||||
console.log('[ADMIN MARKETPLACE] Refreshed job:', jobId);
|
||||
} catch (error) {
|
||||
console.error('[ADMIN MARKETPLACE] Failed to refresh job:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* View job details in modal
|
||||
*/
|
||||
async viewJobDetails(jobId) {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/marketplace-import-jobs/${jobId}`);
|
||||
this.selectedJob = response;
|
||||
this.showJobModal = true;
|
||||
console.log('[ADMIN MARKETPLACE] Viewing job details:', jobId);
|
||||
} catch (error) {
|
||||
console.error('[ADMIN MARKETPLACE] Failed to load job details:', error);
|
||||
this.error = error.message || 'Failed to load job details';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Close job details modal
|
||||
*/
|
||||
closeJobModal() {
|
||||
this.showJobModal = false;
|
||||
this.selectedJob = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get vendor name by ID
|
||||
*/
|
||||
getVendorName(vendorId) {
|
||||
const vendor = this.vendors.find(v => v.id === vendorId);
|
||||
return vendor ? `${vendor.name} (${vendor.vendor_code})` : `Vendor #${vendorId}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Pagination: Previous page
|
||||
*/
|
||||
async previousPage() {
|
||||
if (this.page > 1) {
|
||||
this.page--;
|
||||
await this.loadJobs();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pagination: Next page
|
||||
*/
|
||||
async nextPage() {
|
||||
if (this.page * this.limit < this.totalJobs) {
|
||||
this.page++;
|
||||
await this.loadJobs();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch (error) {
|
||||
return dateString;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Calculate duration between start and end
|
||||
*/
|
||||
calculateDuration(job) {
|
||||
if (!job.started_at) {
|
||||
return 'Not started';
|
||||
}
|
||||
|
||||
const start = new Date(job.started_at);
|
||||
const end = job.completed_at ? new Date(job.completed_at) : new Date();
|
||||
const durationMs = end - start;
|
||||
|
||||
// Convert to human-readable format
|
||||
const seconds = Math.floor(durationMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Start auto-refresh for active jobs
|
||||
*/
|
||||
startAutoRefresh() {
|
||||
// Clear any existing interval
|
||||
if (this.autoRefreshInterval) {
|
||||
clearInterval(this.autoRefreshInterval);
|
||||
}
|
||||
|
||||
// Refresh every 10 seconds if there are active jobs
|
||||
this.autoRefreshInterval = setInterval(async () => {
|
||||
const hasActiveJobs = this.jobs.some(job =>
|
||||
job.status === 'pending' || job.status === 'processing'
|
||||
);
|
||||
|
||||
if (hasActiveJobs) {
|
||||
console.log('[ADMIN MARKETPLACE] Auto-refreshing active jobs...');
|
||||
await this.loadJobs();
|
||||
}
|
||||
}, 10000); // 10 seconds
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop auto-refresh (cleanup)
|
||||
*/
|
||||
stopAutoRefresh() {
|
||||
if (this.autoRefreshInterval) {
|
||||
clearInterval(this.autoRefreshInterval);
|
||||
this.autoRefreshInterval = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (window._adminMarketplaceInstance && window._adminMarketplaceInstance.stopAutoRefresh) {
|
||||
window._adminMarketplaceInstance.stopAutoRefresh();
|
||||
}
|
||||
});
|
||||
112
static/admin/js/settings.js
Normal file
112
static/admin/js/settings.js
Normal file
@@ -0,0 +1,112 @@
|
||||
// static/admin/js/settings.js
|
||||
|
||||
const settingsLog = window.LogConfig?.loggers?.settings || console;
|
||||
|
||||
function adminSettings() {
|
||||
// Get base data
|
||||
const baseData = typeof data === 'function' ? data() : {};
|
||||
|
||||
return {
|
||||
// Inherit base layout functionality from init-alpine.js
|
||||
...baseData,
|
||||
|
||||
// Settings-specific state
|
||||
currentPage: 'settings',
|
||||
loading: true,
|
||||
saving: false,
|
||||
error: null,
|
||||
successMessage: null,
|
||||
activeTab: 'logging',
|
||||
logSettings: {
|
||||
log_level: 'INFO',
|
||||
log_file_max_size_mb: 10,
|
||||
log_file_backup_count: 5,
|
||||
db_log_retention_days: 30,
|
||||
file_logging_enabled: true,
|
||||
db_logging_enabled: true
|
||||
},
|
||||
|
||||
async init() {
|
||||
try {
|
||||
settingsLog.info('=== SETTINGS PAGE INITIALIZING ===');
|
||||
await this.loadLogSettings();
|
||||
} catch (error) {
|
||||
console.error('[Settings] Init failed:', error);
|
||||
this.error = 'Failed to initialize settings page';
|
||||
}
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
this.error = null;
|
||||
this.successMessage = null;
|
||||
await this.loadLogSettings();
|
||||
},
|
||||
|
||||
async loadLogSettings() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const data = await apiClient.get('/admin/logs/settings');
|
||||
this.logSettings = data;
|
||||
settingsLog.info('Log settings loaded:', this.logSettings);
|
||||
} catch (error) {
|
||||
settingsLog.error('Failed to load log settings:', error);
|
||||
this.error = error.response?.data?.detail || 'Failed to load log settings';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async saveLogSettings() {
|
||||
this.saving = true;
|
||||
this.error = null;
|
||||
this.successMessage = null;
|
||||
|
||||
try {
|
||||
const data = await apiClient.put('/admin/logs/settings', this.logSettings);
|
||||
this.successMessage = data.message || 'Log settings saved successfully';
|
||||
|
||||
// Auto-hide success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
this.successMessage = null;
|
||||
}, 5000);
|
||||
|
||||
settingsLog.info('Log settings saved successfully');
|
||||
} catch (error) {
|
||||
settingsLog.error('Failed to save log settings:', error);
|
||||
this.error = error.response?.data?.detail || 'Failed to save log settings';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async cleanupOldLogs() {
|
||||
if (!confirm(`This will delete all logs older than ${this.logSettings.db_log_retention_days} days. Continue?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.error = null;
|
||||
this.successMessage = null;
|
||||
|
||||
try {
|
||||
const data = await apiClient.delete(
|
||||
`/admin/logs/database/cleanup?retention_days=${this.logSettings.db_log_retention_days}&confirm=true`
|
||||
);
|
||||
this.successMessage = data.message || 'Old logs cleaned up successfully';
|
||||
|
||||
// Auto-hide success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
this.successMessage = null;
|
||||
}, 5000);
|
||||
|
||||
settingsLog.info('Old logs cleaned up successfully');
|
||||
} catch (error) {
|
||||
settingsLog.error('Failed to cleanup logs:', error);
|
||||
this.error = error.response?.data?.detail || 'Failed to cleanup old logs';
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
settingsLog.info('Settings module loaded');
|
||||
@@ -73,7 +73,10 @@ function adminVendorEdit() {
|
||||
contact_phone: response.contact_phone || '',
|
||||
website: response.website || '',
|
||||
business_address: response.business_address || '',
|
||||
tax_number: response.tax_number || ''
|
||||
tax_number: response.tax_number || '',
|
||||
letzshop_csv_url_fr: response.letzshop_csv_url_fr || '',
|
||||
letzshop_csv_url_en: response.letzshop_csv_url_en || '',
|
||||
letzshop_csv_url_de: response.letzshop_csv_url_de || ''
|
||||
};
|
||||
|
||||
editLog.info(`Vendor loaded in ${duration}ms`, {
|
||||
|
||||
65
static/admin/js/vendor-themes.js
Normal file
65
static/admin/js/vendor-themes.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// static/admin/js/vendor-themes.js
|
||||
/**
|
||||
* Admin vendor themes selection page
|
||||
*/
|
||||
|
||||
console.log('[ADMIN VENDOR THEMES] Loading...');
|
||||
|
||||
function adminVendorThemes() {
|
||||
console.log('[ADMIN VENDOR THEMES] adminVendorThemes() called');
|
||||
|
||||
return {
|
||||
// Inherit base layout state
|
||||
...data(),
|
||||
|
||||
// Set page identifier
|
||||
currentPage: 'vendor-theme',
|
||||
|
||||
// State
|
||||
loading: false,
|
||||
error: '',
|
||||
vendors: [],
|
||||
selectedVendorCode: '',
|
||||
|
||||
async init() {
|
||||
// Guard against multiple initialization
|
||||
if (window._adminVendorThemesInitialized) {
|
||||
return;
|
||||
}
|
||||
window._adminVendorThemesInitialized = true;
|
||||
|
||||
// Call parent init first
|
||||
const parentInit = data().init;
|
||||
if (parentInit) {
|
||||
await parentInit.call(this);
|
||||
}
|
||||
|
||||
await this.loadVendors();
|
||||
},
|
||||
|
||||
async loadVendors() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const response = await apiClient.get('/admin/vendors?limit=1000');
|
||||
this.vendors = response.vendors || [];
|
||||
console.log('[ADMIN VENDOR THEMES] Loaded vendors:', this.vendors.length);
|
||||
} catch (error) {
|
||||
console.error('[ADMIN VENDOR THEMES] Failed to load vendors:', error);
|
||||
this.error = error.message || 'Failed to load vendors';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
navigateToTheme() {
|
||||
if (!this.selectedVendorCode) {
|
||||
return;
|
||||
}
|
||||
window.location.href = `/admin/vendors/${this.selectedVendorCode}/theme`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
console.log('[ADMIN VENDOR THEMES] Module loaded');
|
||||
0
static/platform/css/.gitkeep
Normal file
0
static/platform/css/.gitkeep
Normal file
0
static/platform/img/.gitkeep
Normal file
0
static/platform/img/.gitkeep
Normal file
0
static/platform/js/.gitkeep
Normal file
0
static/platform/js/.gitkeep
Normal file
@@ -24,7 +24,8 @@ const Icons = {
|
||||
'user-group': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>`,
|
||||
'identification': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2"/></svg>`,
|
||||
'badge-check': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"/></svg>`,
|
||||
|
||||
'shield-check': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>`,
|
||||
|
||||
// Actions
|
||||
'edit': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>`,
|
||||
'delete': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>`,
|
||||
@@ -44,6 +45,7 @@ const Icons = {
|
||||
'shopping-cart': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/></svg>`,
|
||||
'credit-card': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/></svg>`,
|
||||
'currency-dollar': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>`,
|
||||
'currency-euro': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.121 15.536c-1.171 1.952-3.07 1.952-4.242 0-1.172-1.953-1.172-5.119 0-7.072 1.171-1.952 3.07-1.952 4.242 0M8 10.5h4m-4 3h4m9-1.5a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>`,
|
||||
'gift': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7"/></svg>`,
|
||||
'tag': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>`,
|
||||
'truck': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0"/></svg>`,
|
||||
@@ -65,6 +67,7 @@ const Icons = {
|
||||
|
||||
// Files & Documents
|
||||
'document': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>`,
|
||||
'document-text': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>`,
|
||||
'folder': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>`,
|
||||
'folder-open': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z"/></svg>`,
|
||||
'download': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>`,
|
||||
|
||||
341
static/vendor/js/marketplace.js
vendored
Normal file
341
static/vendor/js/marketplace.js
vendored
Normal file
@@ -0,0 +1,341 @@
|
||||
// static/vendor/js/marketplace.js
|
||||
/**
|
||||
* Vendor marketplace import page logic
|
||||
*/
|
||||
|
||||
// ✅ Use centralized logger
|
||||
const vendorMarketplaceLog = window.LogConfig.loggers.marketplace;
|
||||
|
||||
console.log('[VENDOR MARKETPLACE] Loading...');
|
||||
|
||||
function vendorMarketplace() {
|
||||
console.log('[VENDOR MARKETPLACE] vendorMarketplace() called');
|
||||
|
||||
return {
|
||||
// ✅ Inherit base layout state
|
||||
...data(),
|
||||
|
||||
// ✅ Set page identifier
|
||||
currentPage: 'marketplace',
|
||||
|
||||
// Loading states
|
||||
loading: false,
|
||||
importing: false,
|
||||
error: '',
|
||||
successMessage: '',
|
||||
|
||||
// Import form
|
||||
importForm: {
|
||||
csv_url: '',
|
||||
marketplace: 'Letzshop',
|
||||
language: 'fr',
|
||||
batch_size: 1000
|
||||
},
|
||||
|
||||
// Vendor settings (for quick fill)
|
||||
vendorSettings: {
|
||||
letzshop_csv_url_fr: '',
|
||||
letzshop_csv_url_en: '',
|
||||
letzshop_csv_url_de: ''
|
||||
},
|
||||
|
||||
// Import jobs
|
||||
jobs: [],
|
||||
totalJobs: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
|
||||
// Modal state
|
||||
showJobModal: false,
|
||||
selectedJob: null,
|
||||
|
||||
// Auto-refresh for active jobs
|
||||
autoRefreshInterval: null,
|
||||
|
||||
async init() {
|
||||
// Guard against multiple initialization
|
||||
if (window._vendorMarketplaceInitialized) {
|
||||
return;
|
||||
}
|
||||
window._vendorMarketplaceInitialized = true;
|
||||
|
||||
// IMPORTANT: Call parent init first to set vendorCode from URL
|
||||
const parentInit = data().init;
|
||||
if (parentInit) {
|
||||
await parentInit.call(this);
|
||||
}
|
||||
|
||||
await this.loadVendorSettings();
|
||||
await this.loadJobs();
|
||||
|
||||
// Auto-refresh active jobs every 10 seconds
|
||||
this.startAutoRefresh();
|
||||
},
|
||||
|
||||
/**
|
||||
* Load vendor settings (for quick fill)
|
||||
*/
|
||||
async loadVendorSettings() {
|
||||
try {
|
||||
const response = await apiClient.get('/vendor/settings');
|
||||
this.vendorSettings = {
|
||||
letzshop_csv_url_fr: response.letzshop_csv_url_fr || '',
|
||||
letzshop_csv_url_en: response.letzshop_csv_url_en || '',
|
||||
letzshop_csv_url_de: response.letzshop_csv_url_de || ''
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[VENDOR MARKETPLACE] Failed to load vendor settings:', error);
|
||||
// Non-critical, don't show error to user
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load import jobs
|
||||
*/
|
||||
async loadJobs() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/vendor/marketplace/imports?page=${this.page}&limit=${this.limit}`
|
||||
);
|
||||
|
||||
this.jobs = response.items || [];
|
||||
this.totalJobs = response.total || 0;
|
||||
|
||||
console.log('[VENDOR MARKETPLACE] Loaded jobs:', this.jobs.length);
|
||||
} catch (error) {
|
||||
console.error('[VENDOR MARKETPLACE] Failed to load jobs:', error);
|
||||
this.error = error.message || 'Failed to load import jobs';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Start new import
|
||||
*/
|
||||
async startImport() {
|
||||
if (!this.importForm.csv_url) {
|
||||
this.error = 'Please enter a CSV URL';
|
||||
return;
|
||||
}
|
||||
|
||||
this.importing = true;
|
||||
this.error = '';
|
||||
this.successMessage = '';
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
source_url: this.importForm.csv_url,
|
||||
marketplace: this.importForm.marketplace,
|
||||
batch_size: this.importForm.batch_size
|
||||
};
|
||||
|
||||
console.log('[VENDOR MARKETPLACE] Starting import:', payload);
|
||||
|
||||
const response = await apiClient.post('/vendor/marketplace/import', payload);
|
||||
|
||||
console.log('[VENDOR MARKETPLACE] Import started:', response);
|
||||
|
||||
this.successMessage = `Import job #${response.job_id} started successfully!`;
|
||||
|
||||
// Clear form
|
||||
this.importForm.csv_url = '';
|
||||
this.importForm.language = 'fr';
|
||||
this.importForm.batch_size = 1000;
|
||||
|
||||
// Reload jobs to show the new import
|
||||
await this.loadJobs();
|
||||
|
||||
// Clear success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
this.successMessage = '';
|
||||
}, 5000);
|
||||
} catch (error) {
|
||||
console.error('[VENDOR MARKETPLACE] Failed to start import:', error);
|
||||
this.error = error.message || 'Failed to start import';
|
||||
} finally {
|
||||
this.importing = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Quick fill form with saved CSV URL
|
||||
*/
|
||||
quickFill(language) {
|
||||
const urlMap = {
|
||||
'fr': this.vendorSettings.letzshop_csv_url_fr,
|
||||
'en': this.vendorSettings.letzshop_csv_url_en,
|
||||
'de': this.vendorSettings.letzshop_csv_url_de
|
||||
};
|
||||
|
||||
const url = urlMap[language];
|
||||
if (url) {
|
||||
this.importForm.csv_url = url;
|
||||
this.importForm.language = language;
|
||||
console.log('[VENDOR MARKETPLACE] Quick filled:', language, url);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh jobs list
|
||||
*/
|
||||
async refreshJobs() {
|
||||
await this.loadJobs();
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh single job status
|
||||
*/
|
||||
async refreshJobStatus(jobId) {
|
||||
try {
|
||||
const response = await apiClient.get(`/vendor/marketplace/imports/${jobId}`);
|
||||
|
||||
// Update job in list
|
||||
const index = this.jobs.findIndex(j => j.id === jobId);
|
||||
if (index !== -1) {
|
||||
this.jobs[index] = response;
|
||||
}
|
||||
|
||||
// Update selected job if modal is open
|
||||
if (this.selectedJob && this.selectedJob.id === jobId) {
|
||||
this.selectedJob = response;
|
||||
}
|
||||
|
||||
console.log('[VENDOR MARKETPLACE] Refreshed job:', jobId);
|
||||
} catch (error) {
|
||||
console.error('[VENDOR MARKETPLACE] Failed to refresh job:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* View job details in modal
|
||||
*/
|
||||
async viewJobDetails(jobId) {
|
||||
try {
|
||||
const response = await apiClient.get(`/vendor/marketplace/imports/${jobId}`);
|
||||
this.selectedJob = response;
|
||||
this.showJobModal = true;
|
||||
console.log('[VENDOR MARKETPLACE] Viewing job details:', jobId);
|
||||
} catch (error) {
|
||||
console.error('[VENDOR MARKETPLACE] Failed to load job details:', error);
|
||||
this.error = error.message || 'Failed to load job details';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Close job details modal
|
||||
*/
|
||||
closeJobModal() {
|
||||
this.showJobModal = false;
|
||||
this.selectedJob = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Pagination: Previous page
|
||||
*/
|
||||
async previousPage() {
|
||||
if (this.page > 1) {
|
||||
this.page--;
|
||||
await this.loadJobs();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pagination: Next page
|
||||
*/
|
||||
async nextPage() {
|
||||
if (this.page * this.limit < this.totalJobs) {
|
||||
this.page++;
|
||||
await this.loadJobs();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch (error) {
|
||||
return dateString;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Calculate duration between start and end
|
||||
*/
|
||||
calculateDuration(job) {
|
||||
if (!job.started_at) {
|
||||
return 'Not started';
|
||||
}
|
||||
|
||||
const start = new Date(job.started_at);
|
||||
const end = job.completed_at ? new Date(job.completed_at) : new Date();
|
||||
const durationMs = end - start;
|
||||
|
||||
// Convert to human-readable format
|
||||
const seconds = Math.floor(durationMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Start auto-refresh for active jobs
|
||||
*/
|
||||
startAutoRefresh() {
|
||||
// Clear any existing interval
|
||||
if (this.autoRefreshInterval) {
|
||||
clearInterval(this.autoRefreshInterval);
|
||||
}
|
||||
|
||||
// Refresh every 10 seconds if there are active jobs
|
||||
this.autoRefreshInterval = setInterval(async () => {
|
||||
const hasActiveJobs = this.jobs.some(job =>
|
||||
job.status === 'pending' || job.status === 'processing'
|
||||
);
|
||||
|
||||
if (hasActiveJobs) {
|
||||
console.log('[VENDOR MARKETPLACE] Auto-refreshing active jobs...');
|
||||
await this.loadJobs();
|
||||
}
|
||||
}, 10000); // 10 seconds
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop auto-refresh (cleanup)
|
||||
*/
|
||||
stopAutoRefresh() {
|
||||
if (this.autoRefreshInterval) {
|
||||
clearInterval(this.autoRefreshInterval);
|
||||
this.autoRefreshInterval = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (window._vendorMarketplaceInstance && window._vendorMarketplaceInstance.stopAutoRefresh) {
|
||||
window._vendorMarketplaceInstance.stopAutoRefresh();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user