Compare commits
29 Commits
edd55cd2fd
...
71b5eb1758
| Author | SHA1 | Date | |
|---|---|---|---|
| 71b5eb1758 | |||
| b4f01210d9 | |||
| 9bceeaac9c | |||
| 332960de30 | |||
| 0455e63a2e | |||
| aaed1b2d01 | |||
| 9dee534b2f | |||
| beef3ce76b | |||
| 884a694718 | |||
| 4cafbe9610 | |||
| 19923ed26b | |||
| 46f8d227b8 | |||
| 95e4956216 | |||
| 77e520bbce | |||
| 518bace534 | |||
| fcde2d68fc | |||
| 5a33f68743 | |||
| 040cbd1962 | |||
| b679c9687d | |||
| 314360a394 | |||
| 44a0c38016 | |||
| da9e1ab293 | |||
| 5de297a804 | |||
| 4429674100 | |||
| 316ec42566 | |||
| 894832c62b | |||
| 1d90bfe044 | |||
| ce0caa5685 | |||
| 33f823aba0 |
118
alembic/versions/softdelete_001_add_soft_delete.py
Normal file
118
alembic/versions/softdelete_001_add_soft_delete.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Add soft delete columns (deleted_at, deleted_by_id) to business-critical tables.
|
||||
|
||||
Also converts unique constraints on users.email, users.username,
|
||||
stores.store_code, stores.subdomain to partial unique indexes
|
||||
that only apply to non-deleted rows.
|
||||
|
||||
Revision ID: softdelete_001
|
||||
Revises: remove_is_primary_001, customers_002, dev_tools_002, orders_002, tenancy_004
|
||||
Create Date: 2026-03-28
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "softdelete_001"
|
||||
down_revision = (
|
||||
"remove_is_primary_001",
|
||||
"customers_002",
|
||||
"dev_tools_002",
|
||||
"orders_002",
|
||||
"tenancy_004",
|
||||
)
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Tables receiving soft-delete columns
|
||||
SOFT_DELETE_TABLES = [
|
||||
"users",
|
||||
"merchants",
|
||||
"stores",
|
||||
"customers",
|
||||
"store_users",
|
||||
"orders",
|
||||
"products",
|
||||
"loyalty_programs",
|
||||
"loyalty_cards",
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ======================================================================
|
||||
# Step 1: Add deleted_at and deleted_by_id to all soft-delete tables
|
||||
# ======================================================================
|
||||
for table in SOFT_DELETE_TABLES:
|
||||
op.add_column(table, sa.Column("deleted_at", sa.DateTime(), nullable=True))
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column(
|
||||
"deleted_by_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_index(f"ix_{table}_deleted_at", table, ["deleted_at"])
|
||||
|
||||
# ======================================================================
|
||||
# Step 2: Replace simple unique constraints with partial unique indexes
|
||||
# (only enforce uniqueness among non-deleted rows)
|
||||
# ======================================================================
|
||||
|
||||
# users.email: drop old unique index, create partial
|
||||
op.drop_index("ix_users_email", table_name="users")
|
||||
op.execute(
|
||||
'CREATE UNIQUE INDEX uq_users_email_active ON users (email) '
|
||||
'WHERE deleted_at IS NULL'
|
||||
)
|
||||
# Keep a non-unique index for lookups on all rows (including deleted)
|
||||
op.create_index("ix_users_email", "users", ["email"])
|
||||
|
||||
# users.username: drop old unique index, create partial
|
||||
op.drop_index("ix_users_username", table_name="users")
|
||||
op.execute(
|
||||
'CREATE UNIQUE INDEX uq_users_username_active ON users (username) '
|
||||
'WHERE deleted_at IS NULL'
|
||||
)
|
||||
op.create_index("ix_users_username", "users", ["username"])
|
||||
|
||||
# stores.store_code: drop old unique index, create partial
|
||||
op.drop_index("ix_stores_store_code", table_name="stores")
|
||||
op.execute(
|
||||
'CREATE UNIQUE INDEX uq_stores_store_code_active ON stores (store_code) '
|
||||
'WHERE deleted_at IS NULL'
|
||||
)
|
||||
op.create_index("ix_stores_store_code", "stores", ["store_code"])
|
||||
|
||||
# stores.subdomain: drop old unique index, create partial
|
||||
op.drop_index("ix_stores_subdomain", table_name="stores")
|
||||
op.execute(
|
||||
'CREATE UNIQUE INDEX uq_stores_subdomain_active ON stores (subdomain) '
|
||||
'WHERE deleted_at IS NULL'
|
||||
)
|
||||
op.create_index("ix_stores_subdomain", "stores", ["subdomain"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Reverse partial unique indexes back to simple unique indexes
|
||||
op.drop_index("ix_stores_subdomain", table_name="stores")
|
||||
op.execute("DROP INDEX IF EXISTS uq_stores_subdomain_active")
|
||||
op.create_index("ix_stores_subdomain", "stores", ["subdomain"], unique=True)
|
||||
|
||||
op.drop_index("ix_stores_store_code", table_name="stores")
|
||||
op.execute("DROP INDEX IF EXISTS uq_stores_store_code_active")
|
||||
op.create_index("ix_stores_store_code", "stores", ["store_code"], unique=True)
|
||||
|
||||
op.drop_index("ix_users_username", table_name="users")
|
||||
op.execute("DROP INDEX IF EXISTS uq_users_username_active")
|
||||
op.create_index("ix_users_username", "users", ["username"], unique=True)
|
||||
|
||||
op.drop_index("ix_users_email", table_name="users")
|
||||
op.execute("DROP INDEX IF EXISTS uq_users_email_active")
|
||||
op.create_index("ix_users_email", "users", ["email"], unique=True)
|
||||
|
||||
# Remove soft-delete columns from all tables
|
||||
for table in reversed(SOFT_DELETE_TABLES):
|
||||
op.drop_index(f"ix_{table}_deleted_at", table_name=table)
|
||||
op.drop_column(table, "deleted_by_id")
|
||||
op.drop_column(table, "deleted_at")
|
||||
@@ -12,8 +12,8 @@ Note: This project uses PostgreSQL only. SQLite is not supported.
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker, with_loader_criteria
|
||||
from sqlalchemy.pool import QueuePool
|
||||
|
||||
from .config import settings, validate_database_url
|
||||
@@ -38,6 +38,45 @@ Base = declarative_base()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Soft-delete automatic query filter
|
||||
# ---------------------------------------------------------------------------
|
||||
# Any model that inherits SoftDeleteMixin will automatically have
|
||||
# `WHERE deleted_at IS NULL` appended to SELECT queries.
|
||||
# Bypass with: db.execute(stmt, execution_options={"include_deleted": True})
|
||||
# or db.query(Model).execution_options(include_deleted=True).all()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def register_soft_delete_filter(session_factory):
|
||||
"""Register the soft-delete query filter on a session factory.
|
||||
|
||||
Call this for any sessionmaker that should auto-exclude soft-deleted records.
|
||||
Used for both the production SessionLocal and test session factories.
|
||||
"""
|
||||
|
||||
@event.listens_for(session_factory, "do_orm_execute")
|
||||
def _soft_delete_filter(orm_execute_state):
|
||||
if (
|
||||
orm_execute_state.is_select
|
||||
and not orm_execute_state.execution_options.get("include_deleted", False)
|
||||
):
|
||||
from models.database.base import SoftDeleteMixin
|
||||
|
||||
orm_execute_state.statement = orm_execute_state.statement.options(
|
||||
with_loader_criteria(
|
||||
SoftDeleteMixin,
|
||||
lambda cls: cls.deleted_at.is_(None),
|
||||
include_aliases=True,
|
||||
)
|
||||
)
|
||||
|
||||
return _soft_delete_filter
|
||||
|
||||
|
||||
# Register on the production session factory
|
||||
register_soft_delete_filter(SessionLocal)
|
||||
|
||||
|
||||
def get_db():
|
||||
"""
|
||||
Database session dependency for FastAPI routes.
|
||||
|
||||
143
app/core/soft_delete.py
Normal file
143
app/core/soft_delete.py
Normal file
@@ -0,0 +1,143 @@
|
||||
# app/core/soft_delete.py
|
||||
"""
|
||||
Soft-delete utility functions.
|
||||
|
||||
Provides helpers for soft-deleting, restoring, and cascade soft-deleting
|
||||
records that use the SoftDeleteMixin.
|
||||
|
||||
Usage:
|
||||
from app.core.soft_delete import soft_delete, restore, soft_delete_cascade
|
||||
|
||||
# Simple soft delete
|
||||
soft_delete(db, user, deleted_by_id=admin.id)
|
||||
|
||||
# Cascade soft delete (merchant + all stores + their children)
|
||||
soft_delete_cascade(db, merchant, deleted_by_id=admin.id, cascade_rels=[
|
||||
("stores", [("products", []), ("customers", []), ("orders", []), ("store_users", [])]),
|
||||
])
|
||||
|
||||
# Restore a soft-deleted record
|
||||
from app.modules.tenancy.models import User
|
||||
restore(db, User, entity_id=42, restored_by_id=admin.id)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def soft_delete(db: Session, entity, deleted_by_id: int | None = None) -> None:
|
||||
"""
|
||||
Mark an entity as soft-deleted.
|
||||
|
||||
Sets deleted_at to now and deleted_by_id to the actor.
|
||||
Does NOT call db.commit() — caller is responsible.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
entity: SQLAlchemy model instance with SoftDeleteMixin.
|
||||
deleted_by_id: ID of the user performing the deletion.
|
||||
"""
|
||||
entity.deleted_at = datetime.now(UTC)
|
||||
entity.deleted_by_id = deleted_by_id
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Soft-deleted {entity.__class__.__name__} id={entity.id} "
|
||||
f"by user_id={deleted_by_id}"
|
||||
)
|
||||
|
||||
|
||||
def restore(
|
||||
db: Session,
|
||||
model_class,
|
||||
entity_id: int,
|
||||
restored_by_id: int | None = None,
|
||||
):
|
||||
"""
|
||||
Restore a soft-deleted entity.
|
||||
|
||||
Queries with include_deleted=True to find the record, then clears
|
||||
deleted_at and deleted_by_id.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
model_class: SQLAlchemy model class.
|
||||
entity_id: ID of the entity to restore.
|
||||
restored_by_id: ID of the user performing the restore (for logging).
|
||||
|
||||
Returns:
|
||||
The restored entity.
|
||||
|
||||
Raises:
|
||||
ValueError: If entity not found.
|
||||
"""
|
||||
entity = db.execute(
|
||||
select(model_class).filter(model_class.id == entity_id),
|
||||
execution_options={"include_deleted": True},
|
||||
).scalar_one_or_none()
|
||||
|
||||
if entity is None:
|
||||
raise ValueError(f"{model_class.__name__} with id={entity_id} not found")
|
||||
|
||||
if entity.deleted_at is None:
|
||||
raise ValueError(f"{model_class.__name__} with id={entity_id} is not deleted")
|
||||
|
||||
entity.deleted_at = None
|
||||
entity.deleted_by_id = None
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Restored {model_class.__name__} id={entity_id} "
|
||||
f"by user_id={restored_by_id}"
|
||||
)
|
||||
return entity
|
||||
|
||||
|
||||
def soft_delete_cascade(
|
||||
db: Session,
|
||||
entity,
|
||||
deleted_by_id: int | None = None,
|
||||
cascade_rels: list[tuple[str, list]] | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
Soft-delete an entity and recursively soft-delete its children.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
entity: SQLAlchemy model instance with SoftDeleteMixin.
|
||||
deleted_by_id: ID of the user performing the deletion.
|
||||
cascade_rels: List of (relationship_name, child_cascade_rels) tuples.
|
||||
Example: [("stores", [("products", []), ("customers", [])])]
|
||||
|
||||
Returns:
|
||||
Total number of records soft-deleted (including the root entity).
|
||||
"""
|
||||
count = 0
|
||||
|
||||
# Soft-delete the entity itself
|
||||
soft_delete(db, entity, deleted_by_id)
|
||||
count += 1
|
||||
|
||||
# Recursively soft-delete children
|
||||
if cascade_rels:
|
||||
for rel_name, child_cascade in cascade_rels:
|
||||
children = getattr(entity, rel_name, None)
|
||||
if children is None:
|
||||
continue
|
||||
|
||||
# Handle both collections and single items (uselist=False)
|
||||
if not isinstance(children, list):
|
||||
children = [children]
|
||||
|
||||
for child in children:
|
||||
if hasattr(child, "deleted_at") and child.deleted_at is None:
|
||||
count += soft_delete_cascade(
|
||||
db, child, deleted_by_id, child_cascade
|
||||
)
|
||||
|
||||
return count
|
||||
@@ -208,7 +208,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
// Initialize
|
||||
async init() {
|
||||
console.log('[SHOP] Cart page initializing...');
|
||||
console.log('[STOREFRONT] Cart page initializing...');
|
||||
|
||||
// Call parent init to set up sessionId
|
||||
if (baseData.init) {
|
||||
@@ -223,17 +223,17 @@ document.addEventListener('alpine:init', () => {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
console.log(`[SHOP] Loading cart for session ${this.sessionId}...`);
|
||||
console.log(`[STOREFRONT] Loading cart for session ${this.sessionId}...`);
|
||||
const response = await fetch(`/api/v1/storefront/cart/${this.sessionId}`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.items = data.items || [];
|
||||
this.cartCount = this.totalItems;
|
||||
console.log('[SHOP] Cart loaded:', this.items.length, 'items');
|
||||
console.log('[STOREFRONT] Cart loaded:', this.items.length, 'items');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Failed to load cart:', error);
|
||||
console.error('[STOREFRONT] Failed to load cart:', error);
|
||||
this.showToast('Failed to load cart', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
@@ -249,7 +249,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.updating = true;
|
||||
|
||||
try {
|
||||
console.log('[SHOP] Updating quantity:', productId, newQuantity);
|
||||
console.log('[STOREFRONT] Updating quantity:', productId, newQuantity);
|
||||
const response = await fetch(
|
||||
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
||||
{
|
||||
@@ -268,7 +268,7 @@ document.addEventListener('alpine:init', () => {
|
||||
throw new Error('Failed to update quantity');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Update quantity error:', error);
|
||||
console.error('[STOREFRONT] Update quantity error:', error);
|
||||
this.showToast('Failed to update quantity', 'error');
|
||||
} finally {
|
||||
this.updating = false;
|
||||
@@ -280,7 +280,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.updating = true;
|
||||
|
||||
try {
|
||||
console.log('[SHOP] Removing item:', productId);
|
||||
console.log('[STOREFRONT] Removing item:', productId);
|
||||
const response = await fetch(
|
||||
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
||||
{
|
||||
@@ -295,7 +295,7 @@ document.addEventListener('alpine:init', () => {
|
||||
throw new Error('Failed to remove item');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Remove item error:', error);
|
||||
console.error('[STOREFRONT] Remove item error:', error);
|
||||
this.showToast('Failed to remove item', 'error');
|
||||
} finally {
|
||||
this.updating = false;
|
||||
|
||||
@@ -26,10 +26,10 @@ from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.utils.money import cents_to_euros, euros_to_cents
|
||||
from models.database.base import TimestampMixin
|
||||
from models.database.base import SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class Product(Base, TimestampMixin):
|
||||
class Product(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""Store-specific product.
|
||||
|
||||
Products can be created from marketplace imports or directly by stores.
|
||||
|
||||
@@ -192,9 +192,11 @@ class ProductService:
|
||||
True if deleted
|
||||
"""
|
||||
try:
|
||||
from app.core.soft_delete import soft_delete
|
||||
|
||||
product = self.get_product(db, store_id, product_id)
|
||||
|
||||
db.delete(product)
|
||||
soft_delete(db, product, deleted_by_id=None)
|
||||
|
||||
logger.info(f"Deleted product {product_id} from store {store_id} catalog")
|
||||
return True
|
||||
|
||||
@@ -187,8 +187,8 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('[SHOP] Category page initializing...');
|
||||
console.log('[SHOP] Category slug:', this.categorySlug);
|
||||
console.log('[STOREFRONT] Category page initializing...');
|
||||
console.log('[STOREFRONT] Category slug:', this.categorySlug);
|
||||
|
||||
// Convert slug to display name
|
||||
this.categoryName = this.categorySlug
|
||||
@@ -213,7 +213,7 @@ document.addEventListener('alpine:init', () => {
|
||||
params.append('sort', this.sortBy);
|
||||
}
|
||||
|
||||
console.log(`[SHOP] Loading category products from /api/v1/storefront/products?${params}`);
|
||||
console.log(`[STOREFRONT] Loading category products from /api/v1/storefront/products?${params}`);
|
||||
|
||||
const response = await fetch(`/api/v1/storefront/products?${params}`);
|
||||
|
||||
@@ -223,12 +223,12 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log(`[SHOP] Loaded ${data.products.length} products (total: ${data.total})`);
|
||||
console.log(`[STOREFRONT] Loaded ${data.products.length} products (total: ${data.total})`);
|
||||
|
||||
this.products = data.products;
|
||||
this.total = data.total;
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Failed to load category products:', error);
|
||||
console.error('[STOREFRONT] Failed to load category products:', error);
|
||||
this.showToast('Failed to load products', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
@@ -243,7 +243,7 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
async addToCart(product) {
|
||||
console.log('[SHOP] Adding to cart:', product);
|
||||
console.log('[STOREFRONT] Adding to cart:', product);
|
||||
|
||||
try {
|
||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||
@@ -262,16 +262,16 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('[SHOP] Add to cart success:', result);
|
||||
console.log('[STOREFRONT] Add to cart success:', result);
|
||||
this.cartCount += 1;
|
||||
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('[SHOP] Add to cart error:', error);
|
||||
console.error('[STOREFRONT] Add to cart error:', error);
|
||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Add to cart exception:', error);
|
||||
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||
this.showToast('Failed to add to cart', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,16 +256,16 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
// Initialize
|
||||
async init() {
|
||||
console.log('[SHOP] Product detail page initializing...');
|
||||
console.log('[STOREFRONT] Product detail page initializing...');
|
||||
|
||||
// Call parent init to set up sessionId
|
||||
if (baseData.init) {
|
||||
baseData.init.call(this);
|
||||
}
|
||||
|
||||
console.log('[SHOP] Product ID:', this.productId);
|
||||
console.log('[SHOP] Store ID:', this.storeId);
|
||||
console.log('[SHOP] Session ID:', this.sessionId);
|
||||
console.log('[STOREFRONT] Product ID:', this.productId);
|
||||
console.log('[STOREFRONT] Store ID:', this.storeId);
|
||||
console.log('[STOREFRONT] Session ID:', this.sessionId);
|
||||
|
||||
await this.loadProduct();
|
||||
},
|
||||
@@ -275,7 +275,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
console.log(`[SHOP] Loading product ${this.productId}...`);
|
||||
console.log(`[STOREFRONT] Loading product ${this.productId}...`);
|
||||
const response = await fetch(`/api/v1/storefront/products/${this.productId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -283,7 +283,7 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
|
||||
this.product = await response.json();
|
||||
console.log('[SHOP] Product loaded:', this.product);
|
||||
console.log('[STOREFRONT] Product loaded:', this.product);
|
||||
|
||||
// Set default image
|
||||
if (this.product?.marketplace_product?.image_link) {
|
||||
@@ -297,7 +297,7 @@ document.addEventListener('alpine:init', () => {
|
||||
await this.loadRelatedProducts();
|
||||
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Failed to load product:', error);
|
||||
console.error('[STOREFRONT] Failed to load product:', error);
|
||||
this.showToast('Failed to load product', 'error');
|
||||
// Redirect back to products after error
|
||||
setTimeout(() => {
|
||||
@@ -320,10 +320,10 @@ document.addEventListener('alpine:init', () => {
|
||||
.filter(p => p.id !== parseInt(this.productId))
|
||||
.slice(0, 4);
|
||||
|
||||
console.log('[SHOP] Loaded related products:', this.relatedProducts.length);
|
||||
console.log('[STOREFRONT] Loaded related products:', this.relatedProducts.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Failed to load related products:', error);
|
||||
console.error('[STOREFRONT] Failed to load related products:', error);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -356,7 +356,7 @@ document.addEventListener('alpine:init', () => {
|
||||
// Add to cart
|
||||
async addToCart() {
|
||||
if (!this.canAddToCart) {
|
||||
console.warn('[SHOP] Cannot add to cart:', {
|
||||
console.warn('[STOREFRONT] Cannot add to cart:', {
|
||||
canAddToCart: this.canAddToCart,
|
||||
isActive: this.product?.is_active,
|
||||
inventory: this.product?.available_inventory,
|
||||
@@ -374,7 +374,7 @@ document.addEventListener('alpine:init', () => {
|
||||
quantity: this.quantity
|
||||
};
|
||||
|
||||
console.log('[SHOP] Adding to cart:', {
|
||||
console.log('[STOREFRONT] Adding to cart:', {
|
||||
url,
|
||||
sessionId: this.sessionId,
|
||||
productId: this.productId,
|
||||
@@ -390,14 +390,14 @@ document.addEventListener('alpine:init', () => {
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
console.log('[SHOP] Add to cart response:', {
|
||||
console.log('[STOREFRONT] Add to cart response:', {
|
||||
status: response.status,
|
||||
ok: response.ok
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('[SHOP] Add to cart success:', result);
|
||||
console.log('[STOREFRONT] Add to cart success:', result);
|
||||
|
||||
this.cartCount += this.quantity;
|
||||
this.showToast(
|
||||
@@ -409,11 +409,11 @@ document.addEventListener('alpine:init', () => {
|
||||
this.quantity = this.product?.min_quantity || 1;
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('[SHOP] Add to cart error response:', error);
|
||||
console.error('[STOREFRONT] Add to cart error response:', error);
|
||||
throw new Error(error.detail || 'Failed to add to cart');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Add to cart exception:', error);
|
||||
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||
} finally {
|
||||
this.addingToCart = false;
|
||||
|
||||
@@ -160,7 +160,7 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('[SHOP] Products page initializing...');
|
||||
console.log('[STOREFRONT] Products page initializing...');
|
||||
await this.loadProducts();
|
||||
},
|
||||
|
||||
@@ -178,7 +178,7 @@ document.addEventListener('alpine:init', () => {
|
||||
params.append('search', this.filters.search);
|
||||
}
|
||||
|
||||
console.log(`[SHOP] Loading products from /api/v1/storefront/products?${params}`);
|
||||
console.log(`[STOREFRONT] Loading products from /api/v1/storefront/products?${params}`);
|
||||
|
||||
const response = await fetch(`/api/v1/storefront/products?${params}`);
|
||||
|
||||
@@ -188,12 +188,12 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log(`[SHOP] Loaded ${data.products.length} products (total: ${data.total})`);
|
||||
console.log(`[STOREFRONT] Loaded ${data.products.length} products (total: ${data.total})`);
|
||||
|
||||
this.products = data.products;
|
||||
this.pagination.total = data.total;
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Failed to load products:', error);
|
||||
console.error('[STOREFRONT] Failed to load products:', error);
|
||||
this.showToast('Failed to load products', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
@@ -208,7 +208,7 @@ document.addEventListener('alpine:init', () => {
|
||||
// formatPrice is inherited from storefrontLayoutData() via spread operator
|
||||
|
||||
async addToCart(product) {
|
||||
console.log('[SHOP] Adding to cart:', product);
|
||||
console.log('[STOREFRONT] Adding to cart:', product);
|
||||
|
||||
try {
|
||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||
@@ -227,16 +227,16 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('[SHOP] Add to cart success:', result);
|
||||
console.log('[STOREFRONT] Add to cart success:', result);
|
||||
this.cartCount += 1;
|
||||
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('[SHOP] Add to cart error:', error);
|
||||
console.error('[STOREFRONT] Add to cart error:', error);
|
||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Add to cart exception:', error);
|
||||
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||
this.showToast('Failed to add to cart', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('[SHOP] Search page initializing...');
|
||||
console.log('[STOREFRONT] Search page initializing...');
|
||||
|
||||
// Check for query parameter in URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -254,7 +254,7 @@ document.addEventListener('alpine:init', () => {
|
||||
limit: this.perPage
|
||||
});
|
||||
|
||||
console.log(`[SHOP] Searching: /api/v1/storefront/products/search?${params}`);
|
||||
console.log(`[STOREFRONT] Searching: /api/v1/storefront/products/search?${params}`);
|
||||
|
||||
const response = await fetch(`/api/v1/storefront/products/search?${params}`);
|
||||
|
||||
@@ -264,12 +264,12 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log(`[SHOP] Search found ${data.total} results`);
|
||||
console.log(`[STOREFRONT] Search found ${data.total} results`);
|
||||
|
||||
this.products = data.products;
|
||||
this.total = data.total;
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Search failed:', error);
|
||||
console.error('[STOREFRONT] Search failed:', error);
|
||||
this.showToast('Search failed. Please try again.', 'error');
|
||||
this.products = [];
|
||||
this.total = 0;
|
||||
@@ -289,7 +289,7 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
async addToCart(product) {
|
||||
console.log('[SHOP] Adding to cart:', product);
|
||||
console.log('[STOREFRONT] Adding to cart:', product);
|
||||
|
||||
try {
|
||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||
@@ -308,16 +308,16 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('[SHOP] Add to cart success:', result);
|
||||
console.log('[STOREFRONT] Add to cart success:', result);
|
||||
this.cartCount += 1;
|
||||
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('[SHOP] Add to cart error:', error);
|
||||
console.error('[STOREFRONT] Add to cart error:', error);
|
||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Add to cart exception:', error);
|
||||
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||
this.showToast('Failed to add to cart', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ document.addEventListener('alpine:init', () => {
|
||||
isLoggedIn: false,
|
||||
|
||||
async init() {
|
||||
console.log('[SHOP] Wishlist page initializing...');
|
||||
console.log('[STOREFRONT] Wishlist page initializing...');
|
||||
|
||||
// Check if user is logged in
|
||||
this.isLoggedIn = await this.checkLoginStatus();
|
||||
@@ -168,7 +168,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
console.log('[SHOP] Loading wishlist...');
|
||||
console.log('[STOREFRONT] Loading wishlist...');
|
||||
|
||||
const response = await fetch('/api/v1/storefront/wishlist');
|
||||
|
||||
@@ -182,11 +182,11 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log(`[SHOP] Loaded ${data.items?.length || 0} wishlist items`);
|
||||
console.log(`[STOREFRONT] Loaded ${data.items?.length || 0} wishlist items`);
|
||||
|
||||
this.items = data.items || [];
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Failed to load wishlist:', error);
|
||||
console.error('[STOREFRONT] Failed to load wishlist:', error);
|
||||
this.showToast('Failed to load wishlist', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
@@ -195,7 +195,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
async removeFromWishlist(item) {
|
||||
try {
|
||||
console.log('[SHOP] Removing from wishlist:', item);
|
||||
console.log('[STOREFRONT] Removing from wishlist:', item);
|
||||
|
||||
const response = await fetch(`/api/v1/storefront/wishlist/${item.id}`, {
|
||||
method: 'DELETE'
|
||||
@@ -208,13 +208,13 @@ document.addEventListener('alpine:init', () => {
|
||||
throw new Error('Failed to remove from wishlist');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Failed to remove from wishlist:', error);
|
||||
console.error('[STOREFRONT] Failed to remove from wishlist:', error);
|
||||
this.showToast('Failed to remove from wishlist', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async addToCart(product) {
|
||||
console.log('[SHOP] Adding to cart:', product);
|
||||
console.log('[STOREFRONT] Adding to cart:', product);
|
||||
|
||||
try {
|
||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||
@@ -233,16 +233,16 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('[SHOP] Add to cart success:', result);
|
||||
console.log('[STOREFRONT] Add to cart success:', result);
|
||||
this.cartCount += 1;
|
||||
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('[SHOP] Add to cart error:', error);
|
||||
console.error('[STOREFRONT] Add to cart error:', error);
|
||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Add to cart exception:', error);
|
||||
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||
this.showToast('Failed to add to cart', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ async def admin_login_page(
|
||||
context = {
|
||||
"request": request,
|
||||
"current_language": language,
|
||||
"frontend_type": "admin",
|
||||
**get_jinja2_globals(language),
|
||||
}
|
||||
return templates.TemplateResponse("tenancy/admin/login.html", context)
|
||||
|
||||
@@ -72,6 +72,7 @@ async def merchant_login_page(
|
||||
context = {
|
||||
"request": request,
|
||||
"current_language": language,
|
||||
"frontend_type": "merchant",
|
||||
**get_jinja2_globals(language),
|
||||
}
|
||||
return templates.TemplateResponse("tenancy/merchant/login.html", context)
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
* Works with store-specific themes
|
||||
*/
|
||||
|
||||
const shopLog = {
|
||||
info: (...args) => console.info('🛒 [SHOP]', ...args),
|
||||
warn: (...args) => console.warn('⚠️ [SHOP]', ...args),
|
||||
error: (...args) => console.error('❌ [SHOP]', ...args),
|
||||
debug: (...args) => console.log('🔍 [SHOP]', ...args)
|
||||
const shopLog = window.LogConfig?.createLogger('STOREFRONT') || {
|
||||
info: (...args) => console.info('🛒 [STOREFRONT]', ...args),
|
||||
warn: (...args) => console.warn('⚠️ [STOREFRONT]', ...args),
|
||||
error: (...args) => console.error('❌ [STOREFRONT]', ...args),
|
||||
debug: (...args) => console.log('🔍 [STOREFRONT]', ...args)
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -149,6 +149,9 @@ def get_context_for_frontend(
|
||||
# Pass enabled module codes to templates for conditional rendering
|
||||
context["enabled_modules"] = enabled_module_codes
|
||||
|
||||
# Pass frontend type to templates (used by JS for logging, dev toolbar, etc.)
|
||||
context["frontend_type"] = frontend_type.value
|
||||
|
||||
# For storefront, build nav menu structure from module declarations
|
||||
if frontend_type == FrontendType.STOREFRONT:
|
||||
from app.modules.core.services.menu_discovery_service import (
|
||||
|
||||
@@ -17,10 +17,10 @@ from sqlalchemy import (
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
from models.database.base import SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class Customer(Base, TimestampMixin):
|
||||
class Customer(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""Customer model with store isolation."""
|
||||
|
||||
__tablename__ = "customers"
|
||||
|
||||
@@ -132,16 +132,17 @@ loyalty_module = ModuleDefinition(
|
||||
FrontendType.STORE: [
|
||||
"terminal", # Loyalty terminal
|
||||
"cards", # Customer cards
|
||||
"loyalty-program", # Program config
|
||||
"loyalty-analytics", # Store analytics
|
||||
"pins", # Staff PINs
|
||||
"program", # Program config
|
||||
"analytics", # Store analytics
|
||||
],
|
||||
FrontendType.MERCHANT: [
|
||||
"loyalty-program", # Merchant loyalty program
|
||||
"loyalty-cards", # Customer cards
|
||||
"loyalty-analytics", # Merchant loyalty analytics
|
||||
"loyalty-transactions", # Transaction feed
|
||||
"loyalty-pins", # Staff PINs
|
||||
"loyalty-settings", # Settings (read-only)
|
||||
"program", # Merchant loyalty program
|
||||
"cards", # Customer cards
|
||||
"analytics", # Merchant loyalty analytics
|
||||
"transactions", # Transaction feed
|
||||
"pins", # Staff PINs
|
||||
"settings", # Settings (read-only)
|
||||
],
|
||||
},
|
||||
# New module-driven menu definitions
|
||||
@@ -210,7 +211,7 @@ loyalty_module = ModuleDefinition(
|
||||
requires_permission="loyalty.view_programs",
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="loyalty-program",
|
||||
id="program",
|
||||
label_key="loyalty.menu.program",
|
||||
icon="cog",
|
||||
route="/store/{store_code}/loyalty/program",
|
||||
@@ -218,7 +219,7 @@ loyalty_module = ModuleDefinition(
|
||||
requires_permission="loyalty.view_programs",
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="loyalty-analytics",
|
||||
id="analytics",
|
||||
label_key="loyalty.menu.analytics",
|
||||
icon="chart-bar",
|
||||
route="/store/{store_code}/loyalty/analytics",
|
||||
@@ -236,42 +237,42 @@ loyalty_module = ModuleDefinition(
|
||||
order=60,
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="loyalty-program",
|
||||
id="program",
|
||||
label_key="loyalty.menu.program",
|
||||
icon="gift",
|
||||
route="/merchants/loyalty/program",
|
||||
order=10,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="loyalty-cards",
|
||||
id="cards",
|
||||
label_key="loyalty.menu.customer_cards",
|
||||
icon="identification",
|
||||
route="/merchants/loyalty/cards",
|
||||
order=15,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="loyalty-analytics",
|
||||
id="analytics",
|
||||
label_key="loyalty.menu.analytics",
|
||||
icon="chart-bar",
|
||||
route="/merchants/loyalty/analytics",
|
||||
order=20,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="loyalty-transactions",
|
||||
id="transactions",
|
||||
label_key="loyalty.menu.transactions",
|
||||
icon="clock",
|
||||
route="/merchants/loyalty/transactions",
|
||||
order=25,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="loyalty-pins",
|
||||
id="pins",
|
||||
label_key="loyalty.menu.staff_pins",
|
||||
icon="key",
|
||||
route="/merchants/loyalty/pins",
|
||||
order=30,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="loyalty-settings",
|
||||
id="settings",
|
||||
label_key="loyalty.menu.settings",
|
||||
icon="cog",
|
||||
route="/merchants/loyalty/settings",
|
||||
|
||||
@@ -168,7 +168,10 @@
|
||||
"saving": "Speichern...",
|
||||
"total": "GESAMT",
|
||||
"view": "Anzeigen",
|
||||
"yes": "Ja"
|
||||
"yes": "Ja",
|
||||
"contact_admin_setup": "Kontaktieren Sie Ihren Administrator um das Treueprogramm einzurichten",
|
||||
"setup_program": "Programm einrichten",
|
||||
"unknown": "Unbekannt"
|
||||
},
|
||||
"transactions": {
|
||||
"card_created": "Angemeldet",
|
||||
@@ -353,7 +356,10 @@
|
||||
"pin_unlocked": "PIN erfolgreich entsperrt",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"read_only_notice": "PINs sind in der Admin-Ansicht schreibgeschützt"
|
||||
"read_only_notice": "PINs sind in der Admin-Ansicht schreibgeschützt",
|
||||
"save_changes": "Speichern",
|
||||
"unlock": "Entsperren",
|
||||
"no_staff_found": "Keine Mitarbeiter gefunden"
|
||||
},
|
||||
"program_form": {
|
||||
"program_type": "Programmtyp",
|
||||
@@ -677,11 +683,11 @@
|
||||
"points_after": "Punkte danach:",
|
||||
"search_to_process": "Suchen Sie einen Kunden, um eine Transaktion zu verarbeiten",
|
||||
"recent_transactions": "Letzte Transaktionen an diesem Standort",
|
||||
"table_time": "Zeit",
|
||||
"table_customer": "Kunde",
|
||||
"table_type": "Typ",
|
||||
"table_points": "Punkte",
|
||||
"table_notes": "Notizen",
|
||||
"col_time": "Zeit",
|
||||
"col_customer": "Kunde",
|
||||
"col_type": "Typ",
|
||||
"col_points": "Punkte",
|
||||
"col_notes": "Notizen",
|
||||
"no_recent_transactions": "Keine aktuellen Transaktionen",
|
||||
"enter_staff_pin": "Mitarbeiter-PIN eingeben",
|
||||
"pin_authorize": "Geben Sie Ihren Mitarbeiter-PIN ein, um diese Transaktion zu autorisieren.",
|
||||
@@ -693,7 +699,13 @@
|
||||
"stamp_added": "Stempel hinzugefügt!",
|
||||
"stamps_redeemed": "Stempel eingelöst! Prämie erhalten.",
|
||||
"x_points_awarded": "{points} Punkte vergeben!",
|
||||
"reward_redeemed": "Prämie eingelöst: {name}"
|
||||
"reward_redeemed": "Prämie eingelöst: {name}",
|
||||
"card_label": "Karte",
|
||||
"confirm": "Bestätigen",
|
||||
"pin_authorize_text": "Geben Sie Ihre Mitarbeiter-PIN ein um diese Transaktion zu autorisieren",
|
||||
"free_item": "Gratis-Artikel",
|
||||
"reward_label": "Belohnung",
|
||||
"search_empty_state": "Suchen Sie einen Kunden um zu beginnen"
|
||||
},
|
||||
"cards": {
|
||||
"title": "Treue-Mitglieder",
|
||||
@@ -707,12 +719,12 @@
|
||||
"total_points_balance": "Gesamtpunktestand",
|
||||
"search_placeholder": "Nach Name, E-Mail, Telefon oder Karte suchen...",
|
||||
"all_status": "Alle Status",
|
||||
"table_member": "Mitglied",
|
||||
"table_card_number": "Kartennummer",
|
||||
"table_points_balance": "Punktestand",
|
||||
"table_last_activity": "Letzte Aktivität",
|
||||
"table_status": "Status",
|
||||
"table_actions": "Aktionen",
|
||||
"col_member": "Mitglied",
|
||||
"col_card_number": "Kartennummer",
|
||||
"col_points_balance": "Punktestand",
|
||||
"col_last_activity": "Letzte Aktivität",
|
||||
"col_status": "Status",
|
||||
"col_actions": "Aktionen",
|
||||
"no_members": "Keine Mitglieder gefunden",
|
||||
"adjust_search": "Versuchen Sie, Ihre Suche anzupassen",
|
||||
"enroll_first": "Melden Sie Ihren ersten Kunden an"
|
||||
@@ -736,12 +748,13 @@
|
||||
"last_activity": "Letzte Aktivität",
|
||||
"enrolled_at": "Angemeldet am",
|
||||
"transaction_history": "Transaktionsverlauf",
|
||||
"table_date": "Datum",
|
||||
"table_type": "Typ",
|
||||
"table_points": "Punkte",
|
||||
"table_location": "Standort",
|
||||
"table_notes": "Notizen",
|
||||
"no_transactions": "Noch keine Transaktionen"
|
||||
"col_date": "Datum",
|
||||
"col_type": "Typ",
|
||||
"col_points": "Punkte",
|
||||
"col_location": "Standort",
|
||||
"col_notes": "Notizen",
|
||||
"no_transactions": "Noch keine Transaktionen",
|
||||
"card_label": "Karte"
|
||||
},
|
||||
"enroll": {
|
||||
"title": "Kunde anmelden",
|
||||
@@ -768,7 +781,10 @@
|
||||
"x_points": "{count} Punkte",
|
||||
"back_to_terminal": "Zurück zum Terminal",
|
||||
"enroll_another": "Weiteren anmelden",
|
||||
"enrollment_failed": "Anmeldung fehlgeschlagen: {message}"
|
||||
"enrollment_failed": "Anmeldung fehlgeschlagen: {message}",
|
||||
"bonus_points": "Bonuspunkte",
|
||||
"card_number_label": "Kartennummer",
|
||||
"points": "Punkte"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Treue-Analytik",
|
||||
@@ -813,7 +829,11 @@
|
||||
"program_updated": "Programm erfolgreich aktualisiert",
|
||||
"program_deleted": "Treueprogramm gelöscht",
|
||||
"save_failed": "Speichern fehlgeschlagen: {message}",
|
||||
"delete_failed": "Löschen fehlgeschlagen: {message}"
|
||||
"delete_failed": "Löschen fehlgeschlagen: {message}",
|
||||
"access_restricted_desc": "Nur der Inhaber kann die Einstellungen ändern",
|
||||
"delete_program_confirm": "Löschen",
|
||||
"delete_program_desc": "Dies löscht das Treueprogramm und alle zugehörigen Daten dauerhaft",
|
||||
"delete_program_title": "Treueprogramm löschen?"
|
||||
}
|
||||
},
|
||||
"storefront": {
|
||||
@@ -869,6 +889,17 @@
|
||||
"save_failed": "Speichern fehlgeschlagen: {message}",
|
||||
"settings_save_failed": "Einstellungen konnten nicht gespeichert werden: {message}",
|
||||
"create_failed": "Programm konnte nicht erstellt werden: {message}",
|
||||
"logo_required": "Logo-URL ist für die Wallet-Integration erforderlich."
|
||||
"logo_required": "Logo-URL ist für die Wallet-Integration erforderlich.",
|
||||
"pin_created": "PIN erfolgreich erstellt",
|
||||
"pin_updated": "PIN erfolgreich aktualisiert",
|
||||
"pin_deleted": "PIN erfolgreich gelöscht",
|
||||
"pin_unlocked": "PIN erfolgreich entsperrt",
|
||||
"pin_create_error": "PIN konnte nicht erstellt werden",
|
||||
"pin_update_error": "PIN konnte nicht aktualisiert werden",
|
||||
"pin_delete_error": "PIN konnte nicht gelöscht werden",
|
||||
"pin_unlock_error": "PIN konnte nicht entsperrt werden"
|
||||
},
|
||||
"errors": {
|
||||
"card_not_found": "Karte nicht gefunden"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +168,10 @@
|
||||
"saving": "Saving...",
|
||||
"total": "TOTAL",
|
||||
"view": "View",
|
||||
"yes": "Yes"
|
||||
"yes": "Yes",
|
||||
"contact_admin_setup": "Contact your administrator to set up the loyalty program",
|
||||
"setup_program": "Set Up Program",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"transactions": {
|
||||
"card_created": "Enrolled",
|
||||
@@ -353,7 +356,10 @@
|
||||
"pin_unlocked": "PIN unlocked successfully",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"read_only_notice": "PINs are read-only in admin view"
|
||||
"read_only_notice": "PINs are read-only in admin view",
|
||||
"save_changes": "Save Changes",
|
||||
"unlock": "Unlock",
|
||||
"no_staff_found": "No staff members found"
|
||||
},
|
||||
"program_form": {
|
||||
"program_type": "Program Type",
|
||||
@@ -677,11 +683,11 @@
|
||||
"points_after": "Points after:",
|
||||
"search_to_process": "Search for a customer to process a transaction",
|
||||
"recent_transactions": "Recent Transactions at This Location",
|
||||
"table_time": "Time",
|
||||
"table_customer": "Customer",
|
||||
"table_type": "Type",
|
||||
"table_points": "Points",
|
||||
"table_notes": "Notes",
|
||||
"col_time": "Time",
|
||||
"col_customer": "Customer",
|
||||
"col_type": "Type",
|
||||
"col_points": "Points",
|
||||
"col_notes": "Notes",
|
||||
"no_recent_transactions": "No recent transactions",
|
||||
"enter_staff_pin": "Enter Staff PIN",
|
||||
"pin_authorize": "Enter your staff PIN to authorize this transaction.",
|
||||
@@ -693,7 +699,13 @@
|
||||
"stamp_added": "Stamp added!",
|
||||
"stamps_redeemed": "Stamps redeemed! Reward earned.",
|
||||
"x_points_awarded": "{points} points awarded!",
|
||||
"reward_redeemed": "Reward redeemed: {name}"
|
||||
"reward_redeemed": "Reward redeemed: {name}",
|
||||
"card_label": "Card",
|
||||
"confirm": "Confirm",
|
||||
"pin_authorize_text": "Enter your staff PIN to authorize this transaction",
|
||||
"free_item": "Free item",
|
||||
"reward_label": "Reward",
|
||||
"search_empty_state": "Search for a customer to get started"
|
||||
},
|
||||
"cards": {
|
||||
"title": "Loyalty Members",
|
||||
@@ -707,12 +719,12 @@
|
||||
"total_points_balance": "Total Points Balance",
|
||||
"search_placeholder": "Search by name, email, phone, or card...",
|
||||
"all_status": "All Status",
|
||||
"table_member": "Member",
|
||||
"table_card_number": "Card Number",
|
||||
"table_points_balance": "Points Balance",
|
||||
"table_last_activity": "Last Activity",
|
||||
"table_status": "Status",
|
||||
"table_actions": "Actions",
|
||||
"col_member": "Member",
|
||||
"col_card_number": "Card Number",
|
||||
"col_points_balance": "Points Balance",
|
||||
"col_last_activity": "Last Activity",
|
||||
"col_status": "Status",
|
||||
"col_actions": "Actions",
|
||||
"no_members": "No members found",
|
||||
"adjust_search": "Try adjusting your search",
|
||||
"enroll_first": "Enroll your first customer to get started"
|
||||
@@ -736,12 +748,13 @@
|
||||
"last_activity": "Last Activity",
|
||||
"enrolled_at": "Enrolled At",
|
||||
"transaction_history": "Transaction History",
|
||||
"table_date": "Date",
|
||||
"table_type": "Type",
|
||||
"table_points": "Points",
|
||||
"table_location": "Location",
|
||||
"table_notes": "Notes",
|
||||
"no_transactions": "No transactions yet"
|
||||
"col_date": "Date",
|
||||
"col_type": "Type",
|
||||
"col_points": "Points",
|
||||
"col_location": "Location",
|
||||
"col_notes": "Notes",
|
||||
"no_transactions": "No transactions yet",
|
||||
"card_label": "Card"
|
||||
},
|
||||
"enroll": {
|
||||
"title": "Enroll Customer",
|
||||
@@ -768,7 +781,10 @@
|
||||
"x_points": "{count} points",
|
||||
"back_to_terminal": "Back to Terminal",
|
||||
"enroll_another": "Enroll Another",
|
||||
"enrollment_failed": "Enrollment failed: {message}"
|
||||
"enrollment_failed": "Enrollment failed: {message}",
|
||||
"bonus_points": "Bonus Points",
|
||||
"card_number_label": "Card Number",
|
||||
"points": "points"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Loyalty Analytics",
|
||||
@@ -813,7 +829,11 @@
|
||||
"program_updated": "Program updated successfully",
|
||||
"program_deleted": "Loyalty program deleted",
|
||||
"save_failed": "Failed to save: {message}",
|
||||
"delete_failed": "Failed to delete: {message}"
|
||||
"delete_failed": "Failed to delete: {message}",
|
||||
"access_restricted_desc": "Only the merchant owner can modify program settings",
|
||||
"delete_program_confirm": "Delete",
|
||||
"delete_program_desc": "This will permanently delete the loyalty program and all associated data",
|
||||
"delete_program_title": "Delete Loyalty Program?"
|
||||
}
|
||||
},
|
||||
"storefront": {
|
||||
@@ -869,6 +889,17 @@
|
||||
"save_failed": "Failed to save: {message}",
|
||||
"settings_save_failed": "Failed to save settings: {message}",
|
||||
"create_failed": "Failed to create program: {message}",
|
||||
"logo_required": "Logo URL is required for wallet integration."
|
||||
"logo_required": "Logo URL is required for wallet integration.",
|
||||
"pin_created": "PIN created successfully",
|
||||
"pin_updated": "PIN updated successfully",
|
||||
"pin_deleted": "PIN deleted successfully",
|
||||
"pin_unlocked": "PIN unlocked successfully",
|
||||
"pin_create_error": "Failed to create PIN",
|
||||
"pin_update_error": "Failed to update PIN",
|
||||
"pin_delete_error": "Failed to delete PIN",
|
||||
"pin_unlock_error": "Failed to unlock PIN"
|
||||
},
|
||||
"errors": {
|
||||
"card_not_found": "Card not found"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +168,10 @@
|
||||
"saving": "Enregistrement...",
|
||||
"total": "TOTAL",
|
||||
"view": "Voir",
|
||||
"yes": "Oui"
|
||||
"yes": "Oui",
|
||||
"contact_admin_setup": "Contactez votre administrateur pour configurer le programme fidélité",
|
||||
"setup_program": "Configurer le programme",
|
||||
"unknown": "Inconnu"
|
||||
},
|
||||
"transactions": {
|
||||
"card_created": "Inscrit",
|
||||
@@ -353,7 +356,10 @@
|
||||
"pin_unlocked": "PIN déverrouillé avec succès",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"read_only_notice": "Les PINs sont en lecture seule en mode admin"
|
||||
"read_only_notice": "Les PINs sont en lecture seule en mode admin",
|
||||
"save_changes": "Enregistrer",
|
||||
"unlock": "Déverrouiller",
|
||||
"no_staff_found": "Aucun membre du personnel trouvé"
|
||||
},
|
||||
"program_form": {
|
||||
"program_type": "Type de programme",
|
||||
@@ -677,11 +683,11 @@
|
||||
"points_after": "Points après :",
|
||||
"search_to_process": "Recherchez un client pour traiter une transaction",
|
||||
"recent_transactions": "Transactions récentes dans ce point de vente",
|
||||
"table_time": "Heure",
|
||||
"table_customer": "Client",
|
||||
"table_type": "Type",
|
||||
"table_points": "Points",
|
||||
"table_notes": "Notes",
|
||||
"col_time": "Heure",
|
||||
"col_customer": "Client",
|
||||
"col_type": "Type",
|
||||
"col_points": "Points",
|
||||
"col_notes": "Notes",
|
||||
"no_recent_transactions": "Aucune transaction récente",
|
||||
"enter_staff_pin": "Saisir le PIN du personnel",
|
||||
"pin_authorize": "Saisissez votre PIN personnel pour autoriser cette transaction.",
|
||||
@@ -693,7 +699,13 @@
|
||||
"stamp_added": "Tampon ajouté !",
|
||||
"stamps_redeemed": "Tampons échangés ! Récompense obtenue.",
|
||||
"x_points_awarded": "{points} points attribués !",
|
||||
"reward_redeemed": "Récompense échangée : {name}"
|
||||
"reward_redeemed": "Récompense échangée : {name}",
|
||||
"card_label": "Carte",
|
||||
"confirm": "Confirmer",
|
||||
"pin_authorize_text": "Entrez votre PIN personnel pour autoriser cette transaction",
|
||||
"free_item": "Article gratuit",
|
||||
"reward_label": "Récompense",
|
||||
"search_empty_state": "Recherchez un client pour commencer"
|
||||
},
|
||||
"cards": {
|
||||
"title": "Membres fidélité",
|
||||
@@ -707,12 +719,12 @@
|
||||
"total_points_balance": "Solde total de points",
|
||||
"search_placeholder": "Rechercher par nom, e-mail, téléphone ou carte...",
|
||||
"all_status": "Tous les statuts",
|
||||
"table_member": "Membre",
|
||||
"table_card_number": "Numéro de carte",
|
||||
"table_points_balance": "Solde de points",
|
||||
"table_last_activity": "Dernière activité",
|
||||
"table_status": "Statut",
|
||||
"table_actions": "Actions",
|
||||
"col_member": "Membre",
|
||||
"col_card_number": "Numéro de carte",
|
||||
"col_points_balance": "Solde de points",
|
||||
"col_last_activity": "Dernière activité",
|
||||
"col_status": "Statut",
|
||||
"col_actions": "Actions",
|
||||
"no_members": "Aucun membre trouvé",
|
||||
"adjust_search": "Essayez d'ajuster votre recherche",
|
||||
"enroll_first": "Inscrivez votre premier client pour commencer"
|
||||
@@ -736,12 +748,13 @@
|
||||
"last_activity": "Dernière activité",
|
||||
"enrolled_at": "Inscrit le",
|
||||
"transaction_history": "Historique des transactions",
|
||||
"table_date": "Date",
|
||||
"table_type": "Type",
|
||||
"table_points": "Points",
|
||||
"table_location": "Point de vente",
|
||||
"table_notes": "Notes",
|
||||
"no_transactions": "Aucune transaction"
|
||||
"col_date": "Date",
|
||||
"col_type": "Type",
|
||||
"col_points": "Points",
|
||||
"col_location": "Point de vente",
|
||||
"col_notes": "Notes",
|
||||
"no_transactions": "Aucune transaction",
|
||||
"card_label": "Carte"
|
||||
},
|
||||
"enroll": {
|
||||
"title": "Inscrire un client",
|
||||
@@ -768,7 +781,10 @@
|
||||
"x_points": "{count} points",
|
||||
"back_to_terminal": "Retour au terminal",
|
||||
"enroll_another": "Inscrire un autre",
|
||||
"enrollment_failed": "Inscription échouée : {message}"
|
||||
"enrollment_failed": "Inscription échouée : {message}",
|
||||
"bonus_points": "Points bonus",
|
||||
"card_number_label": "Numéro de carte",
|
||||
"points": "points"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytique fidélité",
|
||||
@@ -813,7 +829,11 @@
|
||||
"program_updated": "Programme mis à jour avec succès",
|
||||
"program_deleted": "Programme de fidélité supprimé",
|
||||
"save_failed": "Échec de l'enregistrement : {message}",
|
||||
"delete_failed": "Échec de la suppression : {message}"
|
||||
"delete_failed": "Échec de la suppression : {message}",
|
||||
"access_restricted_desc": "Seul le propriétaire peut modifier les paramètres",
|
||||
"delete_program_confirm": "Supprimer",
|
||||
"delete_program_desc": "Cela supprimera définitivement le programme et toutes les données associées",
|
||||
"delete_program_title": "Supprimer le programme fidélité ?"
|
||||
}
|
||||
},
|
||||
"storefront": {
|
||||
@@ -869,6 +889,17 @@
|
||||
"save_failed": "Échec de l'enregistrement : {message}",
|
||||
"settings_save_failed": "Échec de l'enregistrement des paramètres : {message}",
|
||||
"create_failed": "Échec de la création du programme : {message}",
|
||||
"logo_required": "L'URL du logo est requise pour l'intégration wallet."
|
||||
"logo_required": "L'URL du logo est requise pour l'intégration wallet.",
|
||||
"pin_created": "PIN créé avec succès",
|
||||
"pin_updated": "PIN modifié avec succès",
|
||||
"pin_deleted": "PIN supprimé avec succès",
|
||||
"pin_unlocked": "PIN déverrouillé avec succès",
|
||||
"pin_create_error": "Erreur lors de la création du PIN",
|
||||
"pin_update_error": "Erreur lors de la modification du PIN",
|
||||
"pin_delete_error": "Erreur lors de la suppression du PIN",
|
||||
"pin_unlock_error": "Erreur lors du déverrouillage du PIN"
|
||||
},
|
||||
"errors": {
|
||||
"card_not_found": "Carte non trouvée"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +168,10 @@
|
||||
"saving": "Späicheren...",
|
||||
"total": "TOTAL",
|
||||
"view": "Kucken",
|
||||
"yes": "Jo"
|
||||
"yes": "Jo",
|
||||
"contact_admin_setup": "Kontaktéiert Ären Administrator fir d'Treieprogramm anzeriichten",
|
||||
"setup_program": "Programm ariichten",
|
||||
"unknown": "Onbekannt"
|
||||
},
|
||||
"transactions": {
|
||||
"card_created": "Ageschriwwen",
|
||||
@@ -353,7 +356,10 @@
|
||||
"pin_unlocked": "PIN erfollegräich entspäert",
|
||||
"save": "Späicheren",
|
||||
"cancel": "Ofbriechen",
|
||||
"read_only_notice": "PINen sinn an der Admin-Usiicht nëmmen ze liesen"
|
||||
"read_only_notice": "PINen sinn an der Admin-Usiicht nëmmen ze liesen",
|
||||
"save_changes": "Späicheren",
|
||||
"unlock": "Entspären",
|
||||
"no_staff_found": "Keng Mataarbechter fonnt"
|
||||
},
|
||||
"program_form": {
|
||||
"program_type": "Programmtyp",
|
||||
@@ -677,11 +683,11 @@
|
||||
"points_after": "Punkten duerno:",
|
||||
"search_to_process": "Sicht e Client fir eng Transaktioun ze veraarbechten",
|
||||
"recent_transactions": "Rezent Transaktiounen un dësem Standuert",
|
||||
"table_time": "Zäit",
|
||||
"table_customer": "Client",
|
||||
"table_type": "Typ",
|
||||
"table_points": "Punkten",
|
||||
"table_notes": "Notizen",
|
||||
"col_time": "Zäit",
|
||||
"col_customer": "Client",
|
||||
"col_type": "Typ",
|
||||
"col_points": "Punkten",
|
||||
"col_notes": "Notizen",
|
||||
"no_recent_transactions": "Keng rezent Transaktiounen",
|
||||
"enter_staff_pin": "Personal-PIN aginn",
|
||||
"pin_authorize": "Gitt Äre Personal-PIN an fir dës Transaktioun ze autoriséieren.",
|
||||
@@ -693,7 +699,13 @@
|
||||
"stamp_added": "Stempel derbäigesat!",
|
||||
"stamps_redeemed": "Stempelen agelées! Belounung kritt.",
|
||||
"x_points_awarded": "{points} Punkten vergi!",
|
||||
"reward_redeemed": "Belounung agelées: {name}"
|
||||
"reward_redeemed": "Belounung agelées: {name}",
|
||||
"card_label": "Kaart",
|
||||
"confirm": "Bestätegen",
|
||||
"pin_authorize_text": "Gitt Ären Mataarbechter-PIN an fir dës Transaktioun ze autorisieren",
|
||||
"free_item": "Gratis Artikel",
|
||||
"reward_label": "Belounung",
|
||||
"search_empty_state": "Sicht e Client fir unzefänken"
|
||||
},
|
||||
"cards": {
|
||||
"title": "Treie-Memberen",
|
||||
@@ -707,12 +719,12 @@
|
||||
"total_points_balance": "Gesamtpunktestand",
|
||||
"search_placeholder": "No Numm, E-Mail, Telefon oder Kaart sichen...",
|
||||
"all_status": "All Status",
|
||||
"table_member": "Member",
|
||||
"table_card_number": "Kaartnummer",
|
||||
"table_points_balance": "Punktestand",
|
||||
"table_last_activity": "Lescht Aktivitéit",
|
||||
"table_status": "Status",
|
||||
"table_actions": "Aktiounen",
|
||||
"col_member": "Member",
|
||||
"col_card_number": "Kaartnummer",
|
||||
"col_points_balance": "Punktestand",
|
||||
"col_last_activity": "Lescht Aktivitéit",
|
||||
"col_status": "Status",
|
||||
"col_actions": "Aktiounen",
|
||||
"no_members": "Keng Membere fonnt",
|
||||
"adjust_search": "Probéiert Är Sich unzepassen",
|
||||
"enroll_first": "Mellt Ären éischte Client un"
|
||||
@@ -736,12 +748,13 @@
|
||||
"last_activity": "Lescht Aktivitéit",
|
||||
"enrolled_at": "Ugemellt um",
|
||||
"transaction_history": "Transaktiounsverlaf",
|
||||
"table_date": "Datum",
|
||||
"table_type": "Typ",
|
||||
"table_points": "Punkten",
|
||||
"table_location": "Standuert",
|
||||
"table_notes": "Notizen",
|
||||
"no_transactions": "Nach keng Transaktiounen"
|
||||
"col_date": "Datum",
|
||||
"col_type": "Typ",
|
||||
"col_points": "Punkten",
|
||||
"col_location": "Standuert",
|
||||
"col_notes": "Notizen",
|
||||
"no_transactions": "Nach keng Transaktiounen",
|
||||
"card_label": "Kaart"
|
||||
},
|
||||
"enroll": {
|
||||
"title": "Client umellen",
|
||||
@@ -768,7 +781,10 @@
|
||||
"x_points": "{count} Punkten",
|
||||
"back_to_terminal": "Zréck zum Terminal",
|
||||
"enroll_another": "Weider umellen",
|
||||
"enrollment_failed": "Umeldung feelgeschloen: {message}"
|
||||
"enrollment_failed": "Umeldung feelgeschloen: {message}",
|
||||
"bonus_points": "Bonuspunkten",
|
||||
"card_number_label": "Kaartennummer",
|
||||
"points": "Punkten"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Treie-Analytik",
|
||||
@@ -813,7 +829,11 @@
|
||||
"program_updated": "Programm erfollegräich aktualiséiert",
|
||||
"program_deleted": "Treieprogramm geläscht",
|
||||
"save_failed": "Späichere feelgeschloen: {message}",
|
||||
"delete_failed": "Läsche feelgeschloen: {message}"
|
||||
"delete_failed": "Läsche feelgeschloen: {message}",
|
||||
"access_restricted_desc": "Nëmmen den Besëtzer kann d'Astellungen änneren",
|
||||
"delete_program_confirm": "Läschen",
|
||||
"delete_program_desc": "Dëst läscht d'Treieprogramm an all verbonnen Daten permanent",
|
||||
"delete_program_title": "Treieprogramm läschen?"
|
||||
}
|
||||
},
|
||||
"storefront": {
|
||||
@@ -869,6 +889,17 @@
|
||||
"save_failed": "Späichere feelgeschloen: {message}",
|
||||
"settings_save_failed": "Astellunge konnten net gespäichert ginn: {message}",
|
||||
"create_failed": "Programm konnt net erstellt ginn: {message}",
|
||||
"logo_required": "Logo-URL ass erfuerderlech fir d'Wallet-Integratioun."
|
||||
"logo_required": "Logo-URL ass erfuerderlech fir d'Wallet-Integratioun.",
|
||||
"pin_created": "PIN erfollegräich erstallt",
|
||||
"pin_updated": "PIN erfollegräich aktualiséiert",
|
||||
"pin_deleted": "PIN erfollegräich geläscht",
|
||||
"pin_unlocked": "PIN erfollegräich entspäert",
|
||||
"pin_create_error": "PIN konnt net erstallt ginn",
|
||||
"pin_update_error": "PIN konnt net aktualiséiert ginn",
|
||||
"pin_delete_error": "PIN konnt net geläscht ginn",
|
||||
"pin_unlock_error": "PIN konnt net entspäert ginn"
|
||||
},
|
||||
"errors": {
|
||||
"card_not_found": "Kaart net fonnt"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ from sqlalchemy import (
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
from models.database.base import SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
def generate_card_number() -> str:
|
||||
@@ -48,7 +48,7 @@ def generate_apple_auth_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
class LoyaltyCard(Base, TimestampMixin):
|
||||
class LoyaltyCard(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""
|
||||
Customer's loyalty card (PassObject).
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
from models.database.base import SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class LoyaltyType(str, enum.Enum):
|
||||
@@ -44,7 +44,7 @@ class LoyaltyType(str, enum.Enum):
|
||||
HYBRID = "hybrid" # Both stamps and points
|
||||
|
||||
|
||||
class LoyaltyProgram(Base, TimestampMixin):
|
||||
class LoyaltyProgram(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""
|
||||
Merchant's loyalty program configuration.
|
||||
|
||||
|
||||
@@ -393,12 +393,14 @@ class CardService:
|
||||
query = query.filter(LoyaltyCard.is_active == is_active)
|
||||
|
||||
if search:
|
||||
from sqlalchemy import func
|
||||
|
||||
# Normalize search term for card number matching
|
||||
search_normalized = search.replace("-", "").replace(" ", "")
|
||||
# Use relationship-based join to avoid direct Customer model import
|
||||
CustomerModel = LoyaltyCard.customer.property.mapper.class_
|
||||
query = query.join(LoyaltyCard.customer).filter(
|
||||
(LoyaltyCard.card_number.replace("-", "").ilike(f"%{search_normalized}%"))
|
||||
(func.replace(LoyaltyCard.card_number, "-", "").ilike(f"%{search_normalized}%"))
|
||||
| (CustomerModel.email.ilike(f"%{search}%"))
|
||||
| (CustomerModel.first_name.ilike(f"%{search}%"))
|
||||
| (CustomerModel.last_name.ilike(f"%{search}%"))
|
||||
|
||||
@@ -568,19 +568,23 @@ class ProgramService:
|
||||
return program
|
||||
|
||||
def delete_program(self, db: Session, program_id: int) -> None:
|
||||
"""Delete a loyalty program and all associated data."""
|
||||
"""Soft-delete a loyalty program and associated cards."""
|
||||
from app.core.soft_delete import soft_delete_cascade
|
||||
|
||||
program = self.require_program(db, program_id)
|
||||
merchant_id = program.merchant_id
|
||||
|
||||
# Also delete merchant settings
|
||||
# Hard delete merchant settings (config data, not business records)
|
||||
db.query(MerchantLoyaltySettings).filter(
|
||||
MerchantLoyaltySettings.merchant_id == merchant_id
|
||||
).delete()
|
||||
|
||||
db.delete(program)
|
||||
soft_delete_cascade(db, program, deleted_by_id=None, cascade_rels=[
|
||||
("cards", []),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Deleted loyalty program {program_id} for merchant {merchant_id}")
|
||||
logger.info(f"Soft-deleted loyalty program {program_id} for merchant {merchant_id}")
|
||||
|
||||
# =========================================================================
|
||||
# Merchant Settings
|
||||
|
||||
@@ -6,7 +6,7 @@ const loyaltyAnalyticsLog = window.LogConfig.loggers.loyaltyAnalytics || window.
|
||||
function merchantLoyaltyAnalytics() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'loyalty-analytics',
|
||||
currentPage: 'analytics',
|
||||
|
||||
program: null,
|
||||
locations: [],
|
||||
|
||||
@@ -7,7 +7,7 @@ function merchantLoyaltyCardDetail() {
|
||||
return loyaltyCardDetailView({
|
||||
apiPrefix: '/merchants/loyalty',
|
||||
backUrl: '/merchants/loyalty/cards',
|
||||
currentPage: 'loyalty-cards',
|
||||
currentPage: 'cards',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ function merchantLoyaltyCards() {
|
||||
apiPrefix: '/merchants/loyalty',
|
||||
baseUrl: '/merchants/loyalty/cards',
|
||||
showStoreFilter: true,
|
||||
currentPage: 'loyalty-cards',
|
||||
currentPage: 'cards',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const merchantSettingsViewLog = window.LogConfig.loggers.merchantSettingsView ||
|
||||
function merchantLoyaltyMerchantSettings() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'loyalty-settings',
|
||||
currentPage: 'settings',
|
||||
|
||||
settings: null,
|
||||
loading: false,
|
||||
|
||||
@@ -8,7 +8,7 @@ function merchantLoyaltyPins() {
|
||||
apiPrefix: '/merchants/loyalty',
|
||||
showStoreFilter: true,
|
||||
showCrud: true,
|
||||
currentPage: 'loyalty-pins',
|
||||
currentPage: 'pins',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ function merchantLoyaltySettings() {
|
||||
return {
|
||||
...data(),
|
||||
...createProgramFormMixin(),
|
||||
currentPage: 'loyalty-program',
|
||||
currentPage: 'program',
|
||||
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
@@ -7,7 +7,7 @@ function merchantLoyaltyTransactions() {
|
||||
return loyaltyTransactionsList({
|
||||
apiPrefix: '/merchants/loyalty',
|
||||
showStoreFilter: true,
|
||||
currentPage: 'loyalty-transactions',
|
||||
currentPage: 'transactions',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ function loyaltyPinsList(config) {
|
||||
pins: [],
|
||||
program: null,
|
||||
locations: [],
|
||||
staffMembers: [],
|
||||
|
||||
// Filters
|
||||
filters: {
|
||||
@@ -57,6 +58,53 @@ function loyaltyPinsList(config) {
|
||||
store_id: ''
|
||||
},
|
||||
|
||||
// Staff autocomplete state
|
||||
staffSearch: '',
|
||||
staffSearchResults: [],
|
||||
showStaffDropdown: false,
|
||||
searchingStaff: false,
|
||||
_selectedStaffName: '',
|
||||
|
||||
searchStaff() {
|
||||
// Client-side filter of loaded staff members
|
||||
if (!this.staffSearch || this.staffSearch.length < 1) {
|
||||
this.staffSearchResults = [];
|
||||
this.showStaffDropdown = false;
|
||||
return;
|
||||
}
|
||||
const q = this.staffSearch.toLowerCase();
|
||||
this.staffSearchResults = this.staffMembers.filter(m =>
|
||||
(m.full_name && m.full_name.toLowerCase().includes(q)) ||
|
||||
(m.email && m.email.toLowerCase().includes(q))
|
||||
);
|
||||
this.showStaffDropdown = this.staffSearchResults.length > 0;
|
||||
|
||||
// If user modified the text away from the selected member, clear staff_id
|
||||
if (this._selectedStaffName && this.staffSearch !== this._selectedStaffName) {
|
||||
this.pinForm.staff_id = '';
|
||||
this._selectedStaffName = '';
|
||||
}
|
||||
this.pinForm.name = this.staffSearch;
|
||||
},
|
||||
|
||||
selectStaffMember(item) {
|
||||
this.pinForm.name = item.full_name;
|
||||
this.pinForm.staff_id = item.email;
|
||||
this.staffSearch = item.full_name;
|
||||
this._selectedStaffName = item.full_name;
|
||||
this.showStaffDropdown = false;
|
||||
this.staffSearchResults = [];
|
||||
},
|
||||
|
||||
clearStaffSelection() {
|
||||
this.staffSearch = '';
|
||||
this.pinForm.name = '';
|
||||
this.pinForm.staff_id = '';
|
||||
this._selectedStaffName = '';
|
||||
this.showStaffDropdown = false;
|
||||
this.staffSearchResults = [];
|
||||
},
|
||||
|
||||
// Action state
|
||||
saving: false,
|
||||
deleting: false,
|
||||
@@ -88,6 +136,9 @@ function loyaltyPinsList(config) {
|
||||
if (config.showStoreFilter) {
|
||||
parallel.push(this.loadLocations());
|
||||
}
|
||||
if (config.showCrud && config.staffApiPrefix) {
|
||||
parallel.push(this.loadStaffMembers());
|
||||
}
|
||||
await Promise.all(parallel);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -137,6 +188,18 @@ function loyaltyPinsList(config) {
|
||||
}
|
||||
},
|
||||
|
||||
async loadStaffMembers() {
|
||||
try {
|
||||
const response = await apiClient.get(config.staffApiPrefix + '/team/members');
|
||||
if (response && response.members) {
|
||||
this.staffMembers = response.members.filter(m => m.is_active);
|
||||
loyaltyPinsListLog.info('Loaded', this.staffMembers.length, 'staff members');
|
||||
}
|
||||
} catch (error) {
|
||||
loyaltyPinsListLog.warn('Failed to load staff members:', error.message);
|
||||
}
|
||||
},
|
||||
|
||||
computeStats() {
|
||||
this.stats.total = this.pins.length;
|
||||
this.stats.active = this.pins.filter(p => p.is_active && !p.is_locked).length;
|
||||
@@ -156,6 +219,8 @@ function loyaltyPinsList(config) {
|
||||
pin: '',
|
||||
store_id: ''
|
||||
};
|
||||
this.staffSearch = '';
|
||||
this.showStaffDropdown = false;
|
||||
this.showCreateModal = true;
|
||||
},
|
||||
|
||||
@@ -167,6 +232,8 @@ function loyaltyPinsList(config) {
|
||||
pin: '',
|
||||
store_id: pin.store_id || ''
|
||||
};
|
||||
this.staffSearch = pin.name || '';
|
||||
this.showStaffDropdown = false;
|
||||
this.showEditModal = true;
|
||||
},
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const loyaltyAnalyticsLog = window.LogConfig.loggers.loyaltyAnalytics || window.
|
||||
function storeLoyaltyAnalytics() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'loyalty-analytics',
|
||||
currentPage: 'analytics',
|
||||
|
||||
program: null,
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const loyaltyCardDetailLog = window.LogConfig.loggers.loyaltyCardDetail || windo
|
||||
function storeLoyaltyCardDetail() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'loyalty-card-detail',
|
||||
currentPage: 'cards',
|
||||
|
||||
cardId: null,
|
||||
card: null,
|
||||
|
||||
@@ -6,7 +6,7 @@ const loyaltyCardsLog = window.LogConfig.loggers.loyaltyCards || window.LogConfi
|
||||
function storeLoyaltyCards() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'loyalty-cards',
|
||||
currentPage: 'cards',
|
||||
|
||||
// Data
|
||||
cards: [],
|
||||
|
||||
@@ -6,7 +6,7 @@ const loyaltyEnrollLog = window.LogConfig.loggers.loyaltyEnroll || window.LogCon
|
||||
function storeLoyaltyEnroll() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'loyalty-enroll',
|
||||
currentPage: 'terminal',
|
||||
|
||||
program: null,
|
||||
form: {
|
||||
|
||||
@@ -6,6 +6,7 @@ const storePinsLog = window.LogConfig.loggers.storePins || window.LogConfig.crea
|
||||
function storeLoyaltyPins() {
|
||||
return loyaltyPinsList({
|
||||
apiPrefix: '/store/loyalty',
|
||||
staffApiPrefix: '/store',
|
||||
showStoreFilter: false,
|
||||
showCrud: true,
|
||||
currentPage: 'pins',
|
||||
|
||||
@@ -13,7 +13,7 @@ function loyaltySettings() {
|
||||
...createProgramFormMixin(),
|
||||
|
||||
// Page identifier
|
||||
currentPage: 'loyalty-program',
|
||||
currentPage: 'program',
|
||||
|
||||
// State
|
||||
loading: false,
|
||||
|
||||
@@ -13,7 +13,7 @@ function storeLoyaltyTerminal() {
|
||||
...data(),
|
||||
|
||||
// Page identifier
|
||||
currentPage: 'loyalty-terminal',
|
||||
currentPage: 'terminal',
|
||||
|
||||
// Program state
|
||||
program: null,
|
||||
@@ -23,6 +23,10 @@ function storeLoyaltyTerminal() {
|
||||
searchQuery: '',
|
||||
lookingUp: false,
|
||||
selectedCard: null,
|
||||
searchResults: [],
|
||||
showSearchDropdown: false,
|
||||
searchingCustomers: false,
|
||||
_searchTimeout: null,
|
||||
|
||||
// Transaction inputs
|
||||
earnAmount: null,
|
||||
@@ -146,6 +150,61 @@ function storeLoyaltyTerminal() {
|
||||
}
|
||||
},
|
||||
|
||||
// Debounced search for autocomplete suggestions
|
||||
debouncedSearchCustomers() {
|
||||
if (this._searchTimeout) clearTimeout(this._searchTimeout);
|
||||
if (!this.searchQuery || this.searchQuery.length < 2) {
|
||||
this.searchResults = [];
|
||||
this.showSearchDropdown = false;
|
||||
return;
|
||||
}
|
||||
this._searchTimeout = setTimeout(() => this.searchCustomers(), 300);
|
||||
},
|
||||
|
||||
async searchCustomers() {
|
||||
this.searchingCustomers = true;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
search: this.searchQuery,
|
||||
limit: '5',
|
||||
is_active: 'true'
|
||||
});
|
||||
const response = await apiClient.get(`/store/loyalty/cards?${params}`);
|
||||
if (response && response.cards) {
|
||||
this.searchResults = response.cards;
|
||||
this.showSearchDropdown = this.searchResults.length > 0;
|
||||
}
|
||||
} catch (error) {
|
||||
loyaltyTerminalLog.warn('Search failed:', error.message);
|
||||
this.searchResults = [];
|
||||
this.showSearchDropdown = false;
|
||||
} finally {
|
||||
this.searchingCustomers = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Select a customer from autocomplete dropdown
|
||||
async selectCustomer(card) {
|
||||
this.showSearchDropdown = false;
|
||||
this.searchResults = [];
|
||||
this.lookingUp = true;
|
||||
|
||||
try {
|
||||
// Use the lookup endpoint to get full card details
|
||||
const response = await apiClient.get(`/store/loyalty/cards/lookup?q=${encodeURIComponent(card.card_number)}`);
|
||||
if (response) {
|
||||
this.selectedCard = response;
|
||||
this.searchQuery = '';
|
||||
loyaltyTerminalLog.info('Customer selected:', this.selectedCard.customer_name);
|
||||
}
|
||||
} catch (error) {
|
||||
Utils.showToast(I18n.t('loyalty.store.terminal.error_lookup', {message: error.message}), 'error');
|
||||
loyaltyTerminalLog.error('Lookup failed:', error);
|
||||
} finally {
|
||||
this.lookingUp = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Clear selected customer
|
||||
clearCustomer() {
|
||||
this.selectedCard = null;
|
||||
@@ -225,7 +284,7 @@ function storeLoyaltyTerminal() {
|
||||
loyaltyTerminalLog.info('Adding stamp...');
|
||||
|
||||
await apiClient.post('/store/loyalty/stamp', {
|
||||
card_id: this.selectedCard.id,
|
||||
card_id: this.selectedCard.card_id,
|
||||
staff_pin: this.pinDigits
|
||||
});
|
||||
|
||||
@@ -237,7 +296,7 @@ function storeLoyaltyTerminal() {
|
||||
loyaltyTerminalLog.info('Redeeming stamps...');
|
||||
|
||||
await apiClient.post('/store/loyalty/stamp/redeem', {
|
||||
card_id: this.selectedCard.id,
|
||||
card_id: this.selectedCard.card_id,
|
||||
staff_pin: this.pinDigits
|
||||
});
|
||||
|
||||
@@ -249,7 +308,7 @@ function storeLoyaltyTerminal() {
|
||||
loyaltyTerminalLog.info('Earning points...', { amount: this.earnAmount });
|
||||
|
||||
const response = await apiClient.post('/store/loyalty/points/earn', {
|
||||
card_id: this.selectedCard.id,
|
||||
card_id: this.selectedCard.card_id,
|
||||
purchase_amount_cents: Math.round(this.earnAmount * 100),
|
||||
staff_pin: this.pinDigits
|
||||
});
|
||||
@@ -268,7 +327,7 @@ function storeLoyaltyTerminal() {
|
||||
loyaltyTerminalLog.info('Redeeming reward...', { reward: reward.name });
|
||||
|
||||
await apiClient.post('/store/loyalty/points/redeem', {
|
||||
card_id: this.selectedCard.id,
|
||||
card_id: this.selectedCard.card_id,
|
||||
reward_id: this.selectedReward,
|
||||
staff_pin: this.pinDigits
|
||||
});
|
||||
@@ -281,7 +340,7 @@ function storeLoyaltyTerminal() {
|
||||
// Refresh card data
|
||||
async refreshCard() {
|
||||
try {
|
||||
const response = await apiClient.get(`/store/loyalty/cards/${this.selectedCard.id}`);
|
||||
const response = await apiClient.get(`/store/loyalty/cards/${this.selectedCard.card_id}`);
|
||||
if (response) {
|
||||
this.selectedCard = response;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
|
||||
{% from 'shared/macros/modals.html' import modal, confirm_modal %}
|
||||
{% from 'shared/macros/inputs.html' import search_autocomplete %}
|
||||
|
||||
<!-- Stats Summary -->
|
||||
<div x-show="!loading" class="mb-6 flex flex-wrap items-center gap-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
@@ -102,11 +103,11 @@
|
||||
<td class="px-4 py-3">
|
||||
{% if show_crud %}
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="openEditPin(pin)"
|
||||
<button @click="openEditModal(pin)"
|
||||
class="text-purple-600 hover:text-purple-700 dark:text-purple-400 text-sm">
|
||||
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button @click="confirmDeletePin(pin)"
|
||||
<button @click="openDeleteModal(pin)"
|
||||
class="text-red-600 hover:text-red-700 dark:text-red-400 text-sm">
|
||||
<span x-html="$icon('trash', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
@@ -124,8 +125,6 @@
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
|
||||
{{ pagination() }}
|
||||
</div>
|
||||
|
||||
{% if show_crud %}
|
||||
@@ -133,17 +132,30 @@
|
||||
{% call modal('createPinModal', _('loyalty.shared.pins.create_pin'), 'showCreateModal', size='md', show_footer=false) %}
|
||||
<form @submit.prevent="createPin()">
|
||||
<div class="space-y-4">
|
||||
<!-- Staff Member Autocomplete -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.pin_name') }}</label>
|
||||
<input type="text" x-model="pinForm.name" required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="{{ _('loyalty.shared.pins.pin_name') }}">
|
||||
{{ search_autocomplete(
|
||||
search_var='staffSearch',
|
||||
results_var='staffSearchResults',
|
||||
show_dropdown_var='showStaffDropdown',
|
||||
loading_var='searchingStaff',
|
||||
search_action='searchStaff()',
|
||||
select_action='selectStaffMember(item)',
|
||||
display_field='full_name',
|
||||
secondary_field='email',
|
||||
placeholder=_('loyalty.shared.pins.pin_name'),
|
||||
min_chars=1,
|
||||
no_results_text=_('loyalty.shared.pins.no_staff_found'),
|
||||
loading_text=_('loyalty.common.loading')
|
||||
) }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.pin_staff_id') }}</label>
|
||||
<input type="text" x-model="pinForm.staff_id" required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="{{ _('loyalty.shared.pins.pin_staff_id') }}">
|
||||
<input type="text" x-model="pinForm.staff_id"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-800"
|
||||
placeholder="{{ _('loyalty.shared.pins.pin_staff_id') }}"
|
||||
:readonly="pinForm.staff_id && staffMembers.length > 0">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.pin_code') }}</label>
|
||||
@@ -182,21 +194,20 @@
|
||||
{% call modal('editPinModal', _('loyalty.shared.pins.edit_pin'), 'showEditModal', size='md', show_footer=false) %}
|
||||
<form @submit.prevent="updatePin()">
|
||||
<div class="space-y-4">
|
||||
<!-- Staff Member Autocomplete -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.pin_name') }}</label>
|
||||
<input type="text" x-model="pinForm.name" required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="{{ _('loyalty.shared.pins.pin_name') }}">
|
||||
<input type="text" :value="pinForm.name" readonly
|
||||
class="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-400 cursor-not-allowed">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.pin_staff_id') }}</label>
|
||||
<input type="text" x-model="pinForm.staff_id" required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="{{ _('loyalty.shared.pins.pin_staff_id') }}">
|
||||
<input type="text" :value="pinForm.staff_id" readonly
|
||||
class="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-400 cursor-not-allowed">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.pin_code') }}</label>
|
||||
<input type="password" x-model="pinForm.pin" minlength="4" maxlength="8"
|
||||
<input type="password" x-model="pinForm.pin" required minlength="4" maxlength="8"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="{{ _('loyalty.shared.pins.pin_edit_placeholder') }}">
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ _('loyalty.shared.pins.pin_edit_hint') }}</p>
|
||||
@@ -204,13 +215,8 @@
|
||||
{% if show_store_filter %}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.pin_store') }}</label>
|
||||
<select x-model="pinForm.store_id" required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">{{ _('loyalty.shared.pins.select_store') }}</option>
|
||||
<template x-for="loc in locations" :key="loc.store_id">
|
||||
<option :value="loc.store_id" x-text="loc.store_name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<input type="text" :value="locations.find(l => l.store_id == pinForm.store_id)?.store_name || '-'" readonly
|
||||
class="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-400 cursor-not-allowed">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -50,20 +50,7 @@
|
||||
<template x-for="tx in transactions" :key="tx.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" x-text="formatDateTime(tx.transaction_at)"></td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block"
|
||||
:style="'background-color: ' + (program?.card_color || '#4F46E5') + '20'">
|
||||
<span class="absolute inset-0 flex items-center justify-center text-xs font-semibold"
|
||||
:style="'color: ' + (program?.card_color || '#4F46E5')"
|
||||
x-text="tx.customer_name?.charAt(0).toUpperCase() || '?'"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold" x-text="tx.customer_name || 'Unknown'"></p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="tx.card_number || '-'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm" x-text="tx.customer_name || 'Unknown'"></td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="{
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
function storeLoyaltyProgram() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'loyalty-program',
|
||||
currentPage: 'program',
|
||||
|
||||
program: null,
|
||||
loading: false,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% from 'shared/macros/headers.html' import page_header_flex %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/modals.html' import modal_simple %}
|
||||
{% from 'shared/macros/inputs.html' import search_autocomplete %}
|
||||
|
||||
{% block title %}{{ _('loyalty.store.terminal.title') }}{% endblock %}
|
||||
|
||||
@@ -63,18 +64,22 @@
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<!-- Search Input -->
|
||||
<div class="relative mb-4">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="searchQuery"
|
||||
@keyup.enter="lookupCustomer()"
|
||||
placeholder="{{ _('loyalty.store.terminal.search_placeholder') }}"
|
||||
class="w-full pl-10 pr-4 py-3 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<!-- Search Input with Autocomplete -->
|
||||
<div class="mb-4">
|
||||
{{ search_autocomplete(
|
||||
search_var='searchQuery',
|
||||
results_var='searchResults',
|
||||
show_dropdown_var='showSearchDropdown',
|
||||
loading_var='searchingCustomers',
|
||||
search_action='debouncedSearchCustomers()',
|
||||
select_action='selectCustomer(item)',
|
||||
display_field='customer_name',
|
||||
secondary_field='customer_email',
|
||||
placeholder=_('loyalty.store.terminal.search_placeholder'),
|
||||
min_chars=2,
|
||||
no_results_text=_('loyalty.store.terminal.customer_not_found'),
|
||||
loading_text=_('loyalty.store.terminal.looking_up')
|
||||
) }}
|
||||
</div>
|
||||
<button
|
||||
@click="lookupCustomer()"
|
||||
|
||||
@@ -404,10 +404,10 @@ def get_platform_email_config(db: Session) -> dict:
|
||||
config["smtp_password"] = db_smtp_password if db_smtp_password else settings.smtp_password
|
||||
|
||||
db_smtp_use_tls = get_db_setting("smtp_use_tls")
|
||||
config["smtp_use_tls"] = db_smtp_use_tls.lower() in ("true", "1", "yes") if db_smtp_use_tls else settings.smtp_use_tls
|
||||
config["smtp_use_tls"] = bool(db_smtp_use_tls) if db_smtp_use_tls is not None else settings.smtp_use_tls
|
||||
|
||||
db_smtp_use_ssl = get_db_setting("smtp_use_ssl")
|
||||
config["smtp_use_ssl"] = db_smtp_use_ssl.lower() in ("true", "1", "yes") if db_smtp_use_ssl else settings.smtp_use_ssl
|
||||
config["smtp_use_ssl"] = bool(db_smtp_use_ssl) if db_smtp_use_ssl is not None else settings.smtp_use_ssl
|
||||
|
||||
# SendGrid
|
||||
db_sendgrid_key = get_db_setting("sendgrid_api_key")
|
||||
@@ -432,10 +432,10 @@ def get_platform_email_config(db: Session) -> dict:
|
||||
|
||||
# Behavior
|
||||
db_enabled = get_db_setting("email_enabled")
|
||||
config["enabled"] = db_enabled.lower() in ("true", "1", "yes") if db_enabled else settings.email_enabled
|
||||
config["enabled"] = bool(db_enabled) if db_enabled is not None else settings.email_enabled
|
||||
|
||||
db_debug = get_db_setting("email_debug")
|
||||
config["debug"] = db_debug.lower() in ("true", "1", "yes") if db_debug else settings.email_debug
|
||||
config["debug"] = bool(db_debug) if db_debug is not None else settings.email_debug
|
||||
|
||||
return config
|
||||
|
||||
@@ -1038,8 +1038,8 @@ class EmailService:
|
||||
subscription_service,
|
||||
)
|
||||
|
||||
tier = subscription_service.get_current_tier(self.db, store_id)
|
||||
self._store_tier_cache[store_id] = tier.value if tier else None
|
||||
sub = subscription_service.get_subscription_for_store(self.db, store_id)
|
||||
self._store_tier_cache[store_id] = sub.tier.code if sub and sub.tier else None
|
||||
return self._store_tier_cache[store_id]
|
||||
|
||||
def _should_add_powered_by_footer(self, store_id: int | None) -> bool:
|
||||
|
||||
@@ -37,10 +37,10 @@ from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.utils.money import cents_to_euros, euros_to_cents
|
||||
from models.database.base import TimestampMixin
|
||||
from models.database.base import SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class Order(Base, TimestampMixin):
|
||||
class Order(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""
|
||||
Unified order model for all sales channels.
|
||||
|
||||
|
||||
@@ -12,20 +12,45 @@
|
||||
"team": "Team"
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"members": "Mitglieder",
|
||||
"actions": "Aktionen",
|
||||
"active": "Aktiv",
|
||||
"add_member": "Mitglied hinzufügen",
|
||||
"all_stores": "Alle Filialen",
|
||||
"edit_member": "Mitglied bearbeiten",
|
||||
"editor": "Bearbeiter",
|
||||
"email": "E-Mail",
|
||||
"email_placeholder": "E-Mail-Adresse eingeben",
|
||||
"error_title": "Fehler beim Laden des Teams",
|
||||
"first_name": "Vorname",
|
||||
"invite_first_member": "Laden Sie Ihr erstes Teammitglied ein",
|
||||
"invite_member": "Mitglied einladen",
|
||||
"invitation_accepted": "Einladung angenommen",
|
||||
"invitation_sent": "Einladung gesendet",
|
||||
"last_name": "Nachname",
|
||||
"loading_team": "Team wird geladen...",
|
||||
"manage_members_description": "Teammitglieder über alle Filialen verwalten",
|
||||
"manager": "Manager",
|
||||
"member": "Mitglied",
|
||||
"member_stores": "Filialen des Mitglieds",
|
||||
"members": "Mitglieder",
|
||||
"no_members_description": "Laden Sie Teammitglieder ein um Ihre Filialen zu verwalten",
|
||||
"no_members_title": "Noch keine Teammitglieder",
|
||||
"no_role": "Keine Rolle",
|
||||
"owner": "Inhaber",
|
||||
"pending_invitations": "Ausstehende Einladungen",
|
||||
"permissions": "Berechtigungen",
|
||||
"remove_confirmation": "Sind Sie sicher, dass Sie entfernen möchten",
|
||||
"remove_from_all_stores": "Aus allen Filialen entfernen",
|
||||
"remove_member": "Mitglied entfernen",
|
||||
"role": "Rolle",
|
||||
"owner": "Inhaber",
|
||||
"manager": "Manager",
|
||||
"editor": "Bearbeiter",
|
||||
"viewer": "Betrachter",
|
||||
"permissions": "Berechtigungen",
|
||||
"pending_invitations": "Ausstehende Einladungen",
|
||||
"invitation_sent": "Einladung gesendet",
|
||||
"invitation_accepted": "Einladung angenommen"
|
||||
"select_stores": "Filialen auswählen",
|
||||
"send_invitation": "Einladung senden",
|
||||
"status": "Status",
|
||||
"store_roles": "Filialrollen",
|
||||
"stores_and_roles": "Filialen & Rollen",
|
||||
"title": "Team",
|
||||
"total_members": "Mitglieder gesamt",
|
||||
"viewer": "Betrachter"
|
||||
},
|
||||
"messages": {
|
||||
"business_info_saved": "Business info saved",
|
||||
|
||||
@@ -12,20 +12,45 @@
|
||||
"team": "Team"
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"members": "Members",
|
||||
"actions": "Actions",
|
||||
"active": "Active",
|
||||
"add_member": "Add Member",
|
||||
"all_stores": "All Stores",
|
||||
"edit_member": "Edit Member",
|
||||
"editor": "Editor",
|
||||
"email": "Email",
|
||||
"email_placeholder": "Enter email address",
|
||||
"error_title": "Error loading team",
|
||||
"first_name": "First Name",
|
||||
"invite_first_member": "Invite your first team member",
|
||||
"invite_member": "Invite Member",
|
||||
"invitation_accepted": "Invitation Accepted",
|
||||
"invitation_sent": "Invitation Sent",
|
||||
"last_name": "Last Name",
|
||||
"loading_team": "Loading team...",
|
||||
"manage_members_description": "Manage team members across all your stores",
|
||||
"manager": "Manager",
|
||||
"member": "Member",
|
||||
"member_stores": "Member's stores",
|
||||
"members": "Members",
|
||||
"no_members_description": "Invite team members to help manage your stores",
|
||||
"no_members_title": "No team members yet",
|
||||
"no_role": "No role",
|
||||
"owner": "Owner",
|
||||
"pending_invitations": "Pending Invitations",
|
||||
"permissions": "Permissions",
|
||||
"remove_confirmation": "Are you sure you want to remove",
|
||||
"remove_from_all_stores": "Remove from all stores",
|
||||
"remove_member": "Remove Member",
|
||||
"role": "Role",
|
||||
"owner": "Owner",
|
||||
"manager": "Manager",
|
||||
"editor": "Editor",
|
||||
"viewer": "Viewer",
|
||||
"permissions": "Permissions",
|
||||
"pending_invitations": "Pending Invitations",
|
||||
"invitation_sent": "Invitation Sent",
|
||||
"invitation_accepted": "Invitation Accepted"
|
||||
"select_stores": "Select Stores",
|
||||
"send_invitation": "Send Invitation",
|
||||
"status": "Status",
|
||||
"store_roles": "Store Roles",
|
||||
"stores_and_roles": "Stores & Roles",
|
||||
"title": "Team",
|
||||
"total_members": "Total Members",
|
||||
"viewer": "Viewer"
|
||||
},
|
||||
"messages": {
|
||||
"business_info_saved": "Business info saved",
|
||||
|
||||
@@ -12,20 +12,45 @@
|
||||
"team": "Équipe"
|
||||
},
|
||||
"team": {
|
||||
"title": "Équipe",
|
||||
"members": "Membres",
|
||||
"actions": "Actions",
|
||||
"active": "Actifs",
|
||||
"add_member": "Ajouter un membre",
|
||||
"all_stores": "Tous les magasins",
|
||||
"edit_member": "Modifier le membre",
|
||||
"editor": "Éditeur",
|
||||
"email": "E-mail",
|
||||
"email_placeholder": "Saisir l'adresse e-mail",
|
||||
"error_title": "Erreur lors du chargement",
|
||||
"first_name": "Prénom",
|
||||
"invite_first_member": "Invitez votre premier membre",
|
||||
"invite_member": "Inviter un membre",
|
||||
"invitation_accepted": "Invitation acceptée",
|
||||
"invitation_sent": "Invitation envoyée",
|
||||
"last_name": "Nom de famille",
|
||||
"loading_team": "Chargement de l'équipe...",
|
||||
"manage_members_description": "Gérer les membres de l'équipe sur tous vos magasins",
|
||||
"manager": "Gestionnaire",
|
||||
"member": "Membre",
|
||||
"member_stores": "Magasins du membre",
|
||||
"members": "Membres",
|
||||
"no_members_description": "Invitez des membres pour gérer vos magasins",
|
||||
"no_members_title": "Aucun membre encore",
|
||||
"no_role": "Aucun rôle",
|
||||
"owner": "Propriétaire",
|
||||
"pending_invitations": "Invitations en attente",
|
||||
"permissions": "Permissions",
|
||||
"remove_confirmation": "Êtes-vous sûr de vouloir supprimer",
|
||||
"remove_from_all_stores": "Supprimer de tous les magasins",
|
||||
"remove_member": "Retirer un membre",
|
||||
"role": "Rôle",
|
||||
"owner": "Propriétaire",
|
||||
"manager": "Gestionnaire",
|
||||
"editor": "Éditeur",
|
||||
"viewer": "Lecteur",
|
||||
"permissions": "Permissions",
|
||||
"pending_invitations": "Invitations en attente",
|
||||
"invitation_sent": "Invitation envoyée",
|
||||
"invitation_accepted": "Invitation acceptée"
|
||||
"select_stores": "Sélectionner les magasins",
|
||||
"send_invitation": "Envoyer l'invitation",
|
||||
"status": "Statut",
|
||||
"store_roles": "Rôles par magasin",
|
||||
"stores_and_roles": "Magasins et rôles",
|
||||
"title": "Équipe",
|
||||
"total_members": "Membres totaux",
|
||||
"viewer": "Lecteur"
|
||||
},
|
||||
"messages": {
|
||||
"business_info_saved": "Business info saved",
|
||||
|
||||
@@ -12,20 +12,45 @@
|
||||
"team": "Team"
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"members": "Memberen",
|
||||
"actions": "Aktiounen",
|
||||
"active": "Aktiv",
|
||||
"add_member": "Member derbäisetzen",
|
||||
"all_stores": "All Geschäfter",
|
||||
"edit_member": "Member änneren",
|
||||
"editor": "Editeur",
|
||||
"email": "E-Mail",
|
||||
"email_placeholder": "E-Mail-Adress aginn",
|
||||
"error_title": "Feeler beim Lueden vum Team",
|
||||
"first_name": "Virnumm",
|
||||
"invite_first_member": "Invitéiert Äert éischt Teammember",
|
||||
"invite_member": "Member invitéieren",
|
||||
"invitation_accepted": "Invitatioun ugeholl",
|
||||
"invitation_sent": "Invitatioun geschéckt",
|
||||
"last_name": "Nonumm",
|
||||
"loading_team": "Team gëtt gelueden...",
|
||||
"manage_members_description": "Teammemberen iwwer all Geschäfter verwalten",
|
||||
"manager": "Manager",
|
||||
"member": "Member",
|
||||
"member_stores": "Geschäfter vum Member",
|
||||
"members": "Memberen",
|
||||
"no_members_description": "Invitéiert Teammemberen fir Är Geschäfter ze verwalten",
|
||||
"no_members_title": "Nach keng Teammemberen",
|
||||
"no_role": "Keng Roll",
|
||||
"owner": "Proprietär",
|
||||
"pending_invitations": "Aussteesend Invitatiounen",
|
||||
"permissions": "Rechter",
|
||||
"remove_confirmation": "Sidd Dir sécher, datt Dir ewechhuele wëllt",
|
||||
"remove_from_all_stores": "Vun all Geschäfter ewechhuelen",
|
||||
"remove_member": "Member ewechhuelen",
|
||||
"role": "Roll",
|
||||
"owner": "Proprietär",
|
||||
"manager": "Manager",
|
||||
"editor": "Editeur",
|
||||
"viewer": "Betruechter",
|
||||
"permissions": "Rechter",
|
||||
"pending_invitations": "Aussteesend Invitatiounen",
|
||||
"invitation_sent": "Invitatioun geschéckt",
|
||||
"invitation_accepted": "Invitatioun ugeholl"
|
||||
"select_stores": "Geschäfter wielen",
|
||||
"send_invitation": "Invitatioun schécken",
|
||||
"status": "Status",
|
||||
"store_roles": "Geschäftsrollen",
|
||||
"stores_and_roles": "Geschäfter & Rollen",
|
||||
"title": "Team",
|
||||
"total_members": "Memberen total",
|
||||
"viewer": "Betruechter"
|
||||
},
|
||||
"messages": {
|
||||
"business_info_saved": "Business info saved",
|
||||
|
||||
@@ -10,10 +10,10 @@ from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
from models.database.base import SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class Merchant(Base, TimestampMixin):
|
||||
class Merchant(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""
|
||||
Represents a merchant (business entity) in the system.
|
||||
|
||||
@@ -74,7 +74,7 @@ class Merchant(Base, TimestampMixin):
|
||||
# ========================================================================
|
||||
# Relationships
|
||||
# ========================================================================
|
||||
owner = relationship("User", back_populates="owned_merchants")
|
||||
owner = relationship("User", foreign_keys="[Merchant.owner_user_id]", back_populates="owned_merchants")
|
||||
"""The user who owns this merchant."""
|
||||
|
||||
stores = relationship(
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy import (
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
@@ -24,13 +25,17 @@ from app.core.config import settings
|
||||
|
||||
# Import Base from the central database module instead of creating a new one
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
from models.database.base import SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class Store(Base, TimestampMixin):
|
||||
class Store(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""Represents a store in the system."""
|
||||
|
||||
__tablename__ = "stores" # Name of the table in the database
|
||||
__table_args__ = (
|
||||
Index("uq_stores_store_code_active", "store_code", unique=True, postgresql_where="deleted_at IS NULL"),
|
||||
Index("uq_stores_subdomain_active", "subdomain", unique=True, postgresql_where="deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
id = Column(
|
||||
Integer, primary_key=True, index=True
|
||||
@@ -42,11 +47,11 @@ class Store(Base, TimestampMixin):
|
||||
) # Foreign key to the parent merchant
|
||||
|
||||
store_code = Column(
|
||||
String, unique=True, index=True, nullable=False
|
||||
) # Unique, indexed, non-nullable store code column
|
||||
String, index=True, nullable=False
|
||||
) # Indexed, non-nullable store code column (unique among non-deleted)
|
||||
subdomain = Column(
|
||||
String(100), unique=True, nullable=False, index=True
|
||||
) # Unique, non-nullable subdomain column with indexing
|
||||
String(100), nullable=False, index=True
|
||||
) # Non-nullable subdomain column (unique among non-deleted)
|
||||
name = Column(
|
||||
String, nullable=False
|
||||
) # Non-nullable name column for the store (brand name)
|
||||
@@ -418,7 +423,7 @@ class Store(Base, TimestampMixin):
|
||||
}
|
||||
|
||||
|
||||
class StoreUser(Base, TimestampMixin):
|
||||
class StoreUser(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""
|
||||
Represents a user's team membership in a store.
|
||||
|
||||
|
||||
@@ -15,11 +15,11 @@ ROLE SYSTEM (Phase 1 — Consolidated 4-value enum):
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, Integer, String
|
||||
from sqlalchemy import Boolean, Column, DateTime, Index, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
from models.database.base import SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
@@ -31,14 +31,18 @@ class UserRole(str, enum.Enum):
|
||||
STORE_MEMBER = "store_member" # Team member on specific store(s)
|
||||
|
||||
|
||||
class User(Base, TimestampMixin):
|
||||
class User(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""Represents a platform user (admins, merchant owners, and store team)."""
|
||||
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (
|
||||
Index("uq_users_email_active", "email", unique=True, postgresql_where="deleted_at IS NULL"),
|
||||
Index("uq_users_username_active", "username", unique=True, postgresql_where="deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
username = Column(String, unique=True, index=True, nullable=False)
|
||||
email = Column(String, index=True, nullable=False)
|
||||
username = Column(String, index=True, nullable=False)
|
||||
first_name = Column(String)
|
||||
last_name = Column(String)
|
||||
hashed_password = Column(String, nullable=False)
|
||||
@@ -57,7 +61,7 @@ class User(Base, TimestampMixin):
|
||||
# Relationships
|
||||
# NOTE: marketplace_import_jobs relationship removed - owned by marketplace module
|
||||
# Use: MarketplaceImportJob.query.filter_by(user_id=user.id) instead
|
||||
owned_merchants = relationship("Merchant", back_populates="owner")
|
||||
owned_merchants = relationship("Merchant", foreign_keys="[Merchant.owner_user_id]", back_populates="owner")
|
||||
store_memberships = relationship(
|
||||
"StoreUser", foreign_keys="[StoreUser.user_id]", back_populates="user"
|
||||
)
|
||||
|
||||
@@ -124,6 +124,8 @@ def get_all_merchants(
|
||||
search: str | None = Query(None, description="Search by merchant name"),
|
||||
is_active: bool | None = Query(None),
|
||||
is_verified: bool | None = Query(None),
|
||||
include_deleted: bool = Query(False, description="Include soft-deleted merchants"),
|
||||
only_deleted: bool = Query(False, description="Show only soft-deleted merchants (trash view)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
@@ -135,6 +137,8 @@ def get_all_merchants(
|
||||
search=search,
|
||||
is_active=is_active,
|
||||
is_verified=is_verified,
|
||||
include_deleted=include_deleted,
|
||||
only_deleted=only_deleted,
|
||||
)
|
||||
|
||||
return MerchantListResponse(
|
||||
@@ -403,3 +407,24 @@ def delete_merchant(
|
||||
db.commit() # ✅ ARCH: Commit at API level for transaction control
|
||||
|
||||
return {"message": f"Merchant {merchant_id} deleted successfully"}
|
||||
|
||||
|
||||
@admin_merchants_router.put("/{merchant_id}/restore")
|
||||
def restore_merchant(
|
||||
merchant_id: int = Path(..., description="Merchant ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Restore a soft-deleted merchant (Admin only).
|
||||
|
||||
This only restores the merchant record itself.
|
||||
Stores and their children must be restored separately.
|
||||
"""
|
||||
from app.core.soft_delete import restore
|
||||
from app.modules.tenancy.models import Merchant
|
||||
|
||||
restored = restore(db, Merchant, merchant_id, restored_by_id=current_admin.id)
|
||||
db.commit()
|
||||
logger.info(f"Merchant {merchant_id} restored by admin {current_admin.username}")
|
||||
return {"message": f"Merchant '{restored.name}' restored successfully", "merchant_id": merchant_id}
|
||||
|
||||
@@ -87,6 +87,8 @@ def get_all_stores_admin(
|
||||
is_active: bool | None = Query(None),
|
||||
is_verified: bool | None = Query(None),
|
||||
merchant_id: int | None = Query(None, description="Filter by merchant ID"),
|
||||
include_deleted: bool = Query(False, description="Include soft-deleted stores"),
|
||||
only_deleted: bool = Query(False, description="Show only soft-deleted stores (trash view)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
@@ -99,6 +101,8 @@ def get_all_stores_admin(
|
||||
is_active=is_active,
|
||||
is_verified=is_verified,
|
||||
merchant_id=merchant_id,
|
||||
include_deleted=include_deleted,
|
||||
only_deleted=only_deleted,
|
||||
)
|
||||
return StoreListResponse(stores=stores, total=total, skip=skip, limit=limit)
|
||||
|
||||
@@ -309,3 +313,24 @@ def delete_store(
|
||||
message = admin_service.delete_store(db, store.id)
|
||||
db.commit()
|
||||
return {"message": message}
|
||||
|
||||
|
||||
@admin_stores_router.put("/{store_id}/restore")
|
||||
def restore_store(
|
||||
store_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Restore a soft-deleted store (Admin only).
|
||||
|
||||
This only restores the store record itself.
|
||||
Child records (products, customers, etc.) must be restored separately.
|
||||
"""
|
||||
from app.core.soft_delete import restore
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
restored = restore(db, Store, store_id, restored_by_id=current_admin.id)
|
||||
db.commit()
|
||||
logger.info(f"Store {store_id} restored by admin {current_admin.username}")
|
||||
return {"message": f"Store '{restored.name}' restored successfully", "store_id": store_id}
|
||||
|
||||
@@ -143,6 +143,8 @@ def list_admin_users(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
include_super_admins: bool = Query(True),
|
||||
include_deleted: bool = Query(False, description="Include soft-deleted users"),
|
||||
only_deleted: bool = Query(False, description="Show only soft-deleted users (trash view)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_super_admin),
|
||||
):
|
||||
@@ -156,6 +158,8 @@ def list_admin_users(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
include_super_admins=include_super_admins,
|
||||
include_deleted=include_deleted,
|
||||
only_deleted=only_deleted,
|
||||
)
|
||||
|
||||
admin_responses = [_build_admin_response(admin) for admin in admins]
|
||||
@@ -395,3 +399,26 @@ def delete_admin_user(
|
||||
"message": "Admin user deleted successfully",
|
||||
"user_id": user_id,
|
||||
}
|
||||
|
||||
|
||||
@admin_users_router.put("/{user_id}/restore")
|
||||
def restore_admin_user(
|
||||
user_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_super_admin_api),
|
||||
):
|
||||
"""
|
||||
Restore a soft-deleted admin user.
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
from app.core.soft_delete import restore
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
restored = restore(db, User, user_id, restored_by_id=current_admin.id)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"User '{restored.username}' restored successfully",
|
||||
"user_id": user_id,
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.modules.tenancy.schemas import (
|
||||
MerchantStoreUpdate,
|
||||
)
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
from app.modules.tenancy.schemas.team import MerchantTeamInvite
|
||||
from app.modules.tenancy.services.merchant_service import merchant_service
|
||||
from app.modules.tenancy.services.merchant_store_service import merchant_store_service
|
||||
|
||||
@@ -168,11 +169,130 @@ async def merchant_team_overview(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get team members across all stores owned by the merchant.
|
||||
Get team members across all merchant stores (member-centric view).
|
||||
|
||||
Returns a list of stores with their team members grouped by store.
|
||||
Returns deduplicated members with per-store role info.
|
||||
"""
|
||||
return merchant_store_service.get_merchant_team_overview(db, merchant.id)
|
||||
return merchant_store_service.get_merchant_team_members(db, merchant.id)
|
||||
|
||||
|
||||
@_account_router.get("/team/stores/{store_id}/roles")
|
||||
async def merchant_team_store_roles(
|
||||
store_id: int,
|
||||
current_user: UserContext = Depends(get_current_merchant_api),
|
||||
merchant=Depends(get_merchant_for_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get available roles for a specific store."""
|
||||
from app.modules.tenancy.services.store_team_service import store_team_service
|
||||
|
||||
merchant_store_service.validate_store_ownership(db, merchant.id, store_id)
|
||||
roles = store_team_service.get_store_roles(db, store_id)
|
||||
return {"roles": roles, "total": len(roles)}
|
||||
|
||||
|
||||
@_account_router.post("/team/invite")
|
||||
async def merchant_team_invite(
|
||||
data: MerchantTeamInvite,
|
||||
current_user: UserContext = Depends(get_current_merchant_api),
|
||||
merchant=Depends(get_merchant_for_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Invite a member to one or more merchant stores."""
|
||||
from app.modules.tenancy.schemas.team import (
|
||||
MerchantTeamInviteResponse,
|
||||
MerchantTeamInviteResult,
|
||||
)
|
||||
from app.modules.tenancy.services.store_team_service import store_team_service
|
||||
|
||||
# Get the User ORM object (service needs it as inviter)
|
||||
inviter = merchant_store_service.get_user(db, current_user.id)
|
||||
|
||||
results = []
|
||||
for store_id in data.store_ids:
|
||||
try:
|
||||
store = merchant_store_service.validate_store_ownership(
|
||||
db, merchant.id, store_id
|
||||
)
|
||||
store_team_service.invite_team_member(
|
||||
db,
|
||||
store=store,
|
||||
inviter=inviter,
|
||||
email=data.email,
|
||||
role_name=data.role_name,
|
||||
)
|
||||
results.append(MerchantTeamInviteResult(
|
||||
store_id=store.id,
|
||||
store_name=store.name,
|
||||
success=True,
|
||||
))
|
||||
except Exception as e:
|
||||
results.append(MerchantTeamInviteResult(
|
||||
store_id=store_id,
|
||||
store_name=getattr(e, "store_name", str(store_id)),
|
||||
success=False,
|
||||
error=str(e),
|
||||
))
|
||||
|
||||
db.commit()
|
||||
success_count = sum(1 for r in results if r.success)
|
||||
if success_count == len(results):
|
||||
message = f"Invitation sent to {data.email} for {success_count} store(s)"
|
||||
elif success_count > 0:
|
||||
message = f"Invitation partially sent ({success_count}/{len(results)} stores)"
|
||||
else:
|
||||
message = "Invitation failed for all stores"
|
||||
|
||||
return MerchantTeamInviteResponse(
|
||||
message=message,
|
||||
email=data.email,
|
||||
results=results,
|
||||
)
|
||||
|
||||
|
||||
@_account_router.put("/team/stores/{store_id}/members/{user_id}")
|
||||
async def merchant_team_update_role(
|
||||
store_id: int,
|
||||
user_id: int,
|
||||
role_name: str = Query(..., description="New role name"),
|
||||
current_user: UserContext = Depends(get_current_merchant_api),
|
||||
merchant=Depends(get_merchant_for_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update a member's role in a specific store."""
|
||||
from app.modules.tenancy.services.store_team_service import store_team_service
|
||||
|
||||
store = merchant_store_service.validate_store_ownership(db, merchant.id, store_id)
|
||||
store_team_service.update_member_role(
|
||||
db,
|
||||
store=store,
|
||||
user_id=user_id,
|
||||
new_role_name=role_name,
|
||||
actor_user_id=current_user.id,
|
||||
)
|
||||
db.commit()
|
||||
return {"message": "Role updated successfully"}
|
||||
|
||||
|
||||
@_account_router.delete("/team/stores/{store_id}/members/{user_id}", status_code=204)
|
||||
async def merchant_team_remove_member(
|
||||
store_id: int,
|
||||
user_id: int,
|
||||
current_user: UserContext = Depends(get_current_merchant_api),
|
||||
merchant=Depends(get_merchant_for_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Remove a member from a specific store."""
|
||||
from app.modules.tenancy.services.store_team_service import store_team_service
|
||||
|
||||
store = merchant_store_service.validate_store_ownership(db, merchant.id, store_id)
|
||||
store_team_service.remove_team_member(
|
||||
db,
|
||||
store=store,
|
||||
user_id=user_id,
|
||||
actor_user_id=current_user.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
@_account_router.get("/profile", response_model=MerchantPortalProfileResponse)
|
||||
|
||||
@@ -277,12 +277,13 @@ def update_team_member(
|
||||
"""
|
||||
store = request.state.store
|
||||
|
||||
store_team_service.update_member_role(
|
||||
store_team_service.update_member(
|
||||
db=db,
|
||||
store=store,
|
||||
user_id=user_id,
|
||||
new_role_id=update_data.role_id,
|
||||
role_id=update_data.role_id,
|
||||
is_active=update_data.is_active,
|
||||
actor_user_id=current_user.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ async def store_login_page(
|
||||
"request": request,
|
||||
"store_code": store_code,
|
||||
"platform_code": platform_code,
|
||||
"frontend_type": "store",
|
||||
**get_jinja2_globals(language),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -118,6 +118,12 @@ from app.modules.tenancy.schemas.team import (
|
||||
InvitationAccept,
|
||||
InvitationAcceptResponse,
|
||||
InvitationResponse,
|
||||
MerchantTeamInvite,
|
||||
MerchantTeamInviteResponse,
|
||||
MerchantTeamMemberResponse,
|
||||
MerchantTeamMemberStoreInfo,
|
||||
MerchantTeamOverviewResponse,
|
||||
MerchantTeamStoreInfo,
|
||||
PermissionCheckRequest,
|
||||
PermissionCheckResponse,
|
||||
RoleBase,
|
||||
|
||||
@@ -315,3 +315,89 @@ class TeamErrorResponse(BaseModel):
|
||||
error_code: str
|
||||
message: str
|
||||
details: dict | None = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Merchant Team Schemas (Hub View)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class MerchantTeamMemberStoreInfo(BaseModel):
|
||||
"""A member's role/status in one specific store."""
|
||||
|
||||
store_id: int
|
||||
store_name: str
|
||||
store_code: str
|
||||
role_name: str | None = None
|
||||
role_id: int | None = None
|
||||
is_active: bool = True
|
||||
is_pending: bool = False
|
||||
|
||||
|
||||
class MerchantTeamMemberResponse(BaseModel):
|
||||
"""A team member aggregated across all merchant stores."""
|
||||
|
||||
user_id: int
|
||||
email: EmailStr
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
full_name: str
|
||||
stores: list[MerchantTeamMemberStoreInfo] = Field(default_factory=list)
|
||||
is_owner: bool = False
|
||||
|
||||
|
||||
class MerchantTeamStoreInfo(BaseModel):
|
||||
"""Compact store info for the merchant team overview."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
code: str
|
||||
|
||||
|
||||
class MerchantTeamOverviewResponse(BaseModel):
|
||||
"""Merchant team overview with member-centric view."""
|
||||
|
||||
merchant_name: str
|
||||
stores: list[MerchantTeamStoreInfo]
|
||||
members: list[MerchantTeamMemberResponse]
|
||||
total_members: int
|
||||
total_active: int
|
||||
total_pending: int
|
||||
|
||||
|
||||
class MerchantTeamInvite(BaseModel):
|
||||
"""Schema for inviting a member to merchant stores."""
|
||||
|
||||
email: EmailStr
|
||||
first_name: str | None = Field(None, max_length=100)
|
||||
last_name: str | None = Field(None, max_length=100)
|
||||
store_ids: list[int] = Field(..., min_length=1, description="Store IDs to invite to")
|
||||
role_name: str = Field("staff", description="Role name for all selected stores")
|
||||
|
||||
@field_validator("role_name")
|
||||
@classmethod
|
||||
def validate_role_name(cls, v):
|
||||
"""Validate role name is in allowed presets."""
|
||||
allowed_roles = ["manager", "staff", "support", "viewer", "marketing"]
|
||||
if v.lower() not in allowed_roles:
|
||||
raise ValueError(
|
||||
f"Role name must be one of: {', '.join(allowed_roles)}"
|
||||
)
|
||||
return v.lower()
|
||||
|
||||
|
||||
class MerchantTeamInviteResult(BaseModel):
|
||||
"""Per-store invite result."""
|
||||
|
||||
store_id: int
|
||||
store_name: str
|
||||
success: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class MerchantTeamInviteResponse(BaseModel):
|
||||
"""Response for merchant team invite (multi-store)."""
|
||||
|
||||
message: str
|
||||
email: EmailStr
|
||||
results: list[MerchantTeamInviteResult]
|
||||
|
||||
@@ -443,6 +443,8 @@ class AdminPlatformService:
|
||||
include_super_admins: bool = True,
|
||||
is_active: bool | None = None,
|
||||
search: str | None = None,
|
||||
include_deleted: bool = False,
|
||||
only_deleted: bool = False,
|
||||
) -> tuple[list[User], int]:
|
||||
"""
|
||||
List all admin users with optional filtering.
|
||||
@@ -454,6 +456,8 @@ class AdminPlatformService:
|
||||
include_super_admins: Whether to include super admins
|
||||
is_active: Filter by active status
|
||||
search: Search term for username/email/name
|
||||
include_deleted: Include soft-deleted users
|
||||
only_deleted: Show only soft-deleted users
|
||||
|
||||
Returns:
|
||||
Tuple of (list of User objects, total count)
|
||||
@@ -462,6 +466,12 @@ class AdminPlatformService:
|
||||
User.role.in_(["super_admin", "platform_admin"])
|
||||
)
|
||||
|
||||
# Soft-delete visibility
|
||||
if include_deleted or only_deleted:
|
||||
query = query.execution_options(include_deleted=True)
|
||||
if only_deleted:
|
||||
query = query.filter(User.deleted_at.isnot(None))
|
||||
|
||||
if not include_super_admins:
|
||||
query = query.filter(User.role == "platform_admin")
|
||||
|
||||
|
||||
@@ -322,8 +322,10 @@ class AdminService:
|
||||
owned_count=len(user.owned_merchants),
|
||||
)
|
||||
|
||||
from app.core.soft_delete import soft_delete
|
||||
|
||||
username = user.username
|
||||
db.delete(user)
|
||||
soft_delete(db, user, deleted_by_id=current_admin_id)
|
||||
|
||||
logger.info(f"Admin {current_admin_id} deleted user {username}")
|
||||
return f"User {username} deleted successfully"
|
||||
@@ -477,12 +479,20 @@ class AdminService:
|
||||
is_active: bool | None = None,
|
||||
is_verified: bool | None = None,
|
||||
merchant_id: int | None = None,
|
||||
include_deleted: bool = False,
|
||||
only_deleted: bool = False,
|
||||
) -> tuple[list[Store], int]:
|
||||
"""Get paginated list of all stores with filtering."""
|
||||
try:
|
||||
# Eagerly load merchant relationship to avoid N+1 queries
|
||||
query = db.query(Store).options(joinedload(Store.merchant))
|
||||
|
||||
# Soft-delete visibility
|
||||
if include_deleted or only_deleted:
|
||||
query = query.execution_options(include_deleted=True)
|
||||
if only_deleted:
|
||||
query = query.filter(Store.deleted_at.isnot(None))
|
||||
|
||||
# Filter by merchant
|
||||
if merchant_id is not None:
|
||||
query = query.filter(Store.merchant_id == merchant_id)
|
||||
@@ -506,6 +516,10 @@ class AdminService:
|
||||
|
||||
# Get total count (without joinedload for performance)
|
||||
count_query = db.query(Store)
|
||||
if include_deleted or only_deleted:
|
||||
count_query = count_query.execution_options(include_deleted=True)
|
||||
if only_deleted:
|
||||
count_query = count_query.filter(Store.deleted_at.isnot(None))
|
||||
if merchant_id is not None:
|
||||
count_query = count_query.filter(Store.merchant_id == merchant_id)
|
||||
if search:
|
||||
@@ -596,17 +610,16 @@ class AdminService:
|
||||
store = self._get_store_by_id_or_raise(db, store_id)
|
||||
|
||||
try:
|
||||
from app.core.soft_delete import soft_delete_cascade
|
||||
|
||||
store_code = store.store_code
|
||||
|
||||
# TODO: Delete associated data in correct order
|
||||
# - Delete orders
|
||||
# - Delete customers
|
||||
# - Delete products
|
||||
# - Delete team members
|
||||
# - Delete roles
|
||||
# - Delete import jobs
|
||||
|
||||
db.delete(store)
|
||||
soft_delete_cascade(db, store, deleted_by_id=None, cascade_rels=[
|
||||
("products", []),
|
||||
("customers", []),
|
||||
("orders", []),
|
||||
("store_users", []),
|
||||
])
|
||||
|
||||
logger.warning(f"Store {store_code} and all associated data deleted")
|
||||
return f"Store {store_code} successfully deleted"
|
||||
|
||||
@@ -148,6 +148,8 @@ class MerchantService:
|
||||
search: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
is_verified: bool | None = None,
|
||||
include_deleted: bool = False,
|
||||
only_deleted: bool = False,
|
||||
) -> tuple[list[Merchant], int]:
|
||||
"""
|
||||
Get paginated list of merchants with optional filters.
|
||||
@@ -159,15 +161,25 @@ class MerchantService:
|
||||
search: Search term for merchant name
|
||||
is_active: Filter by active status
|
||||
is_verified: Filter by verified status
|
||||
include_deleted: Include soft-deleted merchants
|
||||
only_deleted: Show only soft-deleted merchants (trash view)
|
||||
|
||||
Returns:
|
||||
Tuple of (merchants list, total count)
|
||||
"""
|
||||
exec_opts = {}
|
||||
if include_deleted or only_deleted:
|
||||
exec_opts["include_deleted"] = True
|
||||
|
||||
query = select(Merchant).options(
|
||||
joinedload(Merchant.stores),
|
||||
joinedload(Merchant.owner),
|
||||
)
|
||||
|
||||
# Soft-delete filter
|
||||
if only_deleted:
|
||||
query = query.where(Merchant.deleted_at.isnot(None))
|
||||
|
||||
# Apply filters
|
||||
if search:
|
||||
query = query.where(Merchant.name.ilike(f"%{search}%"))
|
||||
@@ -178,13 +190,13 @@ class MerchantService:
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = db.execute(count_query).scalar()
|
||||
total = db.execute(count_query, execution_options=exec_opts).scalar()
|
||||
|
||||
# Apply pagination and order
|
||||
query = query.order_by(Merchant.name).offset(skip).limit(limit)
|
||||
|
||||
# Use unique() when using joinedload with collections to avoid duplicate rows
|
||||
merchants = list(db.execute(query).scalars().unique().all())
|
||||
merchants = list(db.execute(query, execution_options=exec_opts).scalars().unique().all())
|
||||
|
||||
return merchants, total
|
||||
|
||||
@@ -228,11 +240,19 @@ class MerchantService:
|
||||
Raises:
|
||||
MerchantNotFoundException: If merchant not found
|
||||
"""
|
||||
from app.core.soft_delete import soft_delete_cascade
|
||||
|
||||
merchant = self.get_merchant_by_id(db, merchant_id)
|
||||
|
||||
# Due to cascade="all, delete-orphan", associated stores will be deleted
|
||||
db.delete(merchant)
|
||||
db.flush()
|
||||
MERCHANT_CASCADE = [
|
||||
("stores", [
|
||||
("products", []),
|
||||
("customers", []),
|
||||
("orders", []),
|
||||
("store_users", []),
|
||||
]),
|
||||
]
|
||||
soft_delete_cascade(db, merchant, deleted_by_id=None, cascade_rels=MERCHANT_CASCADE)
|
||||
logger.info(f"Deleted merchant ID {merchant_id} and associated stores")
|
||||
|
||||
def toggle_verification(
|
||||
|
||||
@@ -424,13 +424,126 @@ class MerchantStoreService:
|
||||
result.append(store_team)
|
||||
|
||||
return {
|
||||
"merchant_name": merchant.business_name or merchant.brand_name,
|
||||
"merchant_name": merchant.name,
|
||||
"owner_email": merchant.owner.email if merchant.owner else None,
|
||||
"stores": result,
|
||||
"total_members": sum(s["member_count"] for s in result),
|
||||
}
|
||||
|
||||
|
||||
def get_user(self, db: Session, user_id: int):
|
||||
"""Get a User ORM object by ID."""
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
return db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
def validate_store_ownership(
|
||||
self, db: Session, merchant_id: int, store_id: int
|
||||
) -> Store:
|
||||
"""
|
||||
Validate that a store belongs to the merchant.
|
||||
|
||||
Returns the Store object if valid, raises exception otherwise.
|
||||
"""
|
||||
store = (
|
||||
db.query(Store)
|
||||
.filter(Store.id == store_id, Store.merchant_id == merchant_id)
|
||||
.first()
|
||||
)
|
||||
if not store:
|
||||
from app.modules.tenancy.exceptions import StoreNotFoundException
|
||||
|
||||
raise StoreNotFoundException(store_id, identifier_type="id")
|
||||
return store
|
||||
|
||||
def get_merchant_team_members(self, db: Session, merchant_id: int) -> dict:
|
||||
"""
|
||||
Get team members across all merchant stores in a member-centric view.
|
||||
|
||||
Deduplicates users across stores and aggregates per-store role info.
|
||||
"""
|
||||
from app.modules.tenancy.models.store import StoreUser
|
||||
|
||||
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
||||
if not merchant:
|
||||
raise MerchantNotFoundException(merchant_id)
|
||||
|
||||
stores = (
|
||||
db.query(Store)
|
||||
.filter(Store.merchant_id == merchant_id)
|
||||
.order_by(Store.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Build member-centric view: keyed by user_id
|
||||
members_map: dict[int, dict] = {}
|
||||
store_list = []
|
||||
|
||||
for store in stores:
|
||||
store_list.append({
|
||||
"id": store.id,
|
||||
"name": store.name,
|
||||
"code": store.store_code,
|
||||
})
|
||||
|
||||
store_users = (
|
||||
db.query(StoreUser)
|
||||
.filter(StoreUser.store_id == store.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
for su in store_users:
|
||||
user = su.user
|
||||
if not user:
|
||||
continue
|
||||
|
||||
uid = user.id
|
||||
is_pending = su.invitation_accepted_at is None and su.invitation_token is not None
|
||||
|
||||
if uid not in members_map:
|
||||
members_map[uid] = {
|
||||
"user_id": uid,
|
||||
"email": user.email,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"full_name": f"{user.first_name or ''} {user.last_name or ''}".strip() or user.email,
|
||||
"stores": [],
|
||||
"is_owner": uid == merchant.owner_user_id,
|
||||
}
|
||||
|
||||
members_map[uid]["stores"].append({
|
||||
"store_id": store.id,
|
||||
"store_name": store.name,
|
||||
"store_code": store.store_code,
|
||||
"role_name": su.role.name if su.role else None,
|
||||
"role_id": su.role_id,
|
||||
"is_active": su.is_active,
|
||||
"is_pending": is_pending,
|
||||
})
|
||||
|
||||
members = list(members_map.values())
|
||||
# Owner first, then alphabetical
|
||||
members.sort(key=lambda m: (not m["is_owner"], m["full_name"].lower()))
|
||||
|
||||
total_active = sum(
|
||||
1 for m in members
|
||||
if any(s["is_active"] and not s["is_pending"] for s in m["stores"])
|
||||
)
|
||||
total_pending = sum(
|
||||
1 for m in members
|
||||
if any(s["is_pending"] for s in m["stores"])
|
||||
)
|
||||
|
||||
return {
|
||||
"merchant_name": merchant.name,
|
||||
"stores": store_list,
|
||||
"members": members,
|
||||
"total_members": len(members),
|
||||
"total_active": total_active,
|
||||
"total_pending": total_pending,
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
merchant_store_service = MerchantStoreService()
|
||||
|
||||
|
||||
@@ -76,10 +76,22 @@ class StoreTeamService:
|
||||
Dict with invitation details
|
||||
"""
|
||||
try:
|
||||
# Check team size limit from subscription
|
||||
from app.modules.billing.services import subscription_service
|
||||
# Check team size limit from subscription (skip if no subscription)
|
||||
try:
|
||||
from app.modules.billing.services.usage_service import usage_service
|
||||
|
||||
subscription_service.check_team_limit(db, store.id)
|
||||
limit_check = usage_service.check_limit(db, store.id, "team_members")
|
||||
if limit_check.limit is not None and not limit_check.can_proceed:
|
||||
raise TierLimitExceededException(
|
||||
message=limit_check.message or "Team member limit reached",
|
||||
limit_type="team_members",
|
||||
current=limit_check.current,
|
||||
limit=limit_check.limit,
|
||||
)
|
||||
except TierLimitExceededException:
|
||||
raise
|
||||
except Exception as e: # noqa: EXC003
|
||||
logger.warning(f"Could not check team limit (proceeding): {e}")
|
||||
|
||||
# Check if user already exists
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
@@ -331,8 +343,9 @@ class StoreTeamService:
|
||||
if store_user.is_owner:
|
||||
raise CannotRemoveOwnerException(user_id, store.id)
|
||||
|
||||
# Soft delete - just deactivate
|
||||
store_user.is_active = False
|
||||
from app.core.soft_delete import soft_delete
|
||||
|
||||
soft_delete(db, store_user, deleted_by_id=actor_user_id)
|
||||
|
||||
logger.info(f"Removed user {user_id} from store {store.store_code}")
|
||||
|
||||
@@ -438,6 +451,60 @@ class StoreTeamService:
|
||||
logger.error(f"Error updating member role: {str(e)}")
|
||||
raise
|
||||
|
||||
def update_member(
|
||||
self,
|
||||
db: Session,
|
||||
store: Store,
|
||||
user_id: int,
|
||||
role_id: int | None = None,
|
||||
is_active: bool | None = None,
|
||||
actor_user_id: int | None = None,
|
||||
) -> StoreUser:
|
||||
"""
|
||||
Update a team member's role (by ID) and/or active status.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
store: Store
|
||||
user_id: User ID
|
||||
role_id: New role ID (must belong to this store)
|
||||
is_active: New active status
|
||||
actor_user_id: Actor performing the update
|
||||
|
||||
Returns:
|
||||
Updated StoreUser
|
||||
"""
|
||||
store_user = (
|
||||
db.query(StoreUser)
|
||||
.filter(StoreUser.store_id == store.id, StoreUser.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
if not store_user:
|
||||
raise UserNotFoundException(str(user_id))
|
||||
|
||||
if role_id is not None:
|
||||
role = (
|
||||
db.query(Role)
|
||||
.filter(Role.id == role_id, Role.store_id == store.id)
|
||||
.first()
|
||||
)
|
||||
if not role:
|
||||
raise InvalidRoleException(f"Role {role_id} not found in store {store.id}")
|
||||
self.update_member_role(
|
||||
db=db,
|
||||
store=store,
|
||||
user_id=user_id,
|
||||
new_role_name=role.name,
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
|
||||
if is_active is not None:
|
||||
store_user.is_active = is_active
|
||||
|
||||
db.flush()
|
||||
db.refresh(store_user)
|
||||
return store_user
|
||||
|
||||
def get_team_members(
|
||||
self,
|
||||
db: Session,
|
||||
|
||||
309
app/modules/tenancy/static/merchant/js/merchant-team.js
Normal file
309
app/modules/tenancy/static/merchant/js/merchant-team.js
Normal file
@@ -0,0 +1,309 @@
|
||||
// static/merchant/js/merchant-team.js
|
||||
/**
|
||||
* Merchant team management page logic
|
||||
* Manage team members across stores, invitations, and roles
|
||||
*/
|
||||
|
||||
const merchantTeamLog = window.LogConfig.createLogger('merchantTeam');
|
||||
|
||||
merchantTeamLog.info('Loading...');
|
||||
|
||||
function merchantTeam() {
|
||||
merchantTeamLog.info('merchantTeam() called');
|
||||
|
||||
return {
|
||||
// Inherit base layout state
|
||||
...data(),
|
||||
|
||||
// Set page identifier
|
||||
currentPage: 'team',
|
||||
|
||||
// Team data
|
||||
members: [],
|
||||
stores: [],
|
||||
stats: { total_members: 0, total_active: 0, total_pending: 0 },
|
||||
|
||||
// Loading states
|
||||
loading: false,
|
||||
error: null,
|
||||
saving: false,
|
||||
|
||||
// Filters
|
||||
storeFilter: '',
|
||||
|
||||
// Modal states
|
||||
showInviteModal: false,
|
||||
showEditModal: false,
|
||||
showRemoveModal: false,
|
||||
selectedMember: null,
|
||||
|
||||
// Invite form
|
||||
inviteForm: {
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
store_ids: [],
|
||||
role_name: 'staff',
|
||||
},
|
||||
|
||||
// Role options (preset)
|
||||
roleOptions: [
|
||||
{ value: 'manager', label: 'Manager' },
|
||||
{ value: 'staff', label: 'Staff' },
|
||||
{ value: 'support', label: 'Support' },
|
||||
{ value: 'viewer', label: 'Viewer' },
|
||||
{ value: 'marketing', label: 'Marketing' },
|
||||
],
|
||||
|
||||
async init() {
|
||||
// Load i18n translations
|
||||
await I18n.loadModule('tenancy');
|
||||
|
||||
merchantTeamLog.info('Team init() called');
|
||||
|
||||
// Guard against multiple initialization
|
||||
if (window._merchantTeamInitialized) {
|
||||
merchantTeamLog.warn('Already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
window._merchantTeamInitialized = true;
|
||||
|
||||
// Call parent init first
|
||||
const parentInit = data().init;
|
||||
if (parentInit) {
|
||||
await parentInit.call(this);
|
||||
}
|
||||
|
||||
// Load dynamic menu
|
||||
this.loadMenuConfig();
|
||||
|
||||
try {
|
||||
await this.loadTeamData();
|
||||
} catch (error) {
|
||||
merchantTeamLog.error('Init failed:', error);
|
||||
this.error = 'Failed to initialize team page';
|
||||
}
|
||||
|
||||
merchantTeamLog.info('Team initialization complete');
|
||||
},
|
||||
|
||||
/**
|
||||
* Load team data (members, stores, stats)
|
||||
*/
|
||||
async loadTeamData() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await apiClient.get('/merchants/account/team');
|
||||
|
||||
this.members = response.members || [];
|
||||
this.stores = response.stores || [];
|
||||
this.stats = {
|
||||
total_members: response.total_members || 0,
|
||||
total_active: response.total_active || 0,
|
||||
total_pending: response.total_pending || 0,
|
||||
};
|
||||
|
||||
merchantTeamLog.info('Loaded team data:', this.members.length, 'members,', this.stores.length, 'stores');
|
||||
} catch (error) {
|
||||
merchantTeamLog.error('Failed to load team data:', error);
|
||||
this.error = error.message || 'Failed to load team data';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Filter members by store
|
||||
*/
|
||||
get filteredMembers() {
|
||||
if (!this.storeFilter) {
|
||||
return this.members;
|
||||
}
|
||||
const storeId = parseInt(this.storeFilter);
|
||||
return this.members.filter(member =>
|
||||
member.stores && member.stores.some(s => s.store_id === storeId)
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Open invite modal with reset form
|
||||
*/
|
||||
openInviteModal() {
|
||||
this.inviteForm = {
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
store_ids: this.stores.map(s => s.id),
|
||||
role_name: 'staff',
|
||||
};
|
||||
this.showInviteModal = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle store in invite form store_ids
|
||||
*/
|
||||
toggleStoreSelection(storeId) {
|
||||
const idx = this.inviteForm.store_ids.indexOf(storeId);
|
||||
if (idx > -1) {
|
||||
this.inviteForm.store_ids.splice(idx, 1);
|
||||
} else {
|
||||
this.inviteForm.store_ids.push(storeId);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Send invitation
|
||||
*/
|
||||
async sendInvitation() {
|
||||
if (!this.inviteForm.email) {
|
||||
Utils.showToast(I18n.t('tenancy.messages.email_is_required'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.inviteForm.store_ids.length === 0) {
|
||||
Utils.showToast(I18n.t('tenancy.messages.select_at_least_one_store'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
await apiClient.post('/merchants/account/team/invite', this.inviteForm);
|
||||
|
||||
Utils.showToast(I18n.t('tenancy.messages.invitation_sent_successfully'), 'success');
|
||||
merchantTeamLog.info('Invitation sent to:', this.inviteForm.email);
|
||||
|
||||
this.showInviteModal = false;
|
||||
await this.loadTeamData();
|
||||
} catch (error) {
|
||||
merchantTeamLog.error('Failed to send invitation:', error);
|
||||
Utils.showToast(error.message || 'Failed to send invitation', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Open edit modal for a member
|
||||
*/
|
||||
openEditModal(member) {
|
||||
this.selectedMember = JSON.parse(JSON.stringify(member));
|
||||
this.showEditModal = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update member role for a specific store
|
||||
*/
|
||||
async updateMemberRole(storeId, userId, roleName) {
|
||||
this.saving = true;
|
||||
try {
|
||||
await apiClient.put(
|
||||
`/merchants/account/team/stores/${storeId}/members/${userId}?role_name=${encodeURIComponent(roleName)}`
|
||||
);
|
||||
|
||||
Utils.showToast(I18n.t('tenancy.messages.team_member_updated'), 'success');
|
||||
merchantTeamLog.info('Updated member role:', userId, 'store:', storeId, 'role:', roleName);
|
||||
|
||||
this.showEditModal = false;
|
||||
this.selectedMember = null;
|
||||
await this.loadTeamData();
|
||||
} catch (error) {
|
||||
merchantTeamLog.error('Failed to update member role:', error);
|
||||
Utils.showToast(error.message || 'Failed to update member role', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Open remove confirmation modal
|
||||
*/
|
||||
openRemoveModal(member) {
|
||||
this.selectedMember = JSON.parse(JSON.stringify(member));
|
||||
this.showRemoveModal = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove member from a specific store
|
||||
*/
|
||||
async removeMember(storeId, userId) {
|
||||
this.saving = true;
|
||||
try {
|
||||
await apiClient.delete(`/merchants/account/team/stores/${storeId}/members/${userId}`);
|
||||
|
||||
Utils.showToast(I18n.t('tenancy.messages.team_member_removed'), 'success');
|
||||
merchantTeamLog.info('Removed member:', userId, 'from store:', storeId);
|
||||
|
||||
this.showRemoveModal = false;
|
||||
this.selectedMember = null;
|
||||
await this.loadTeamData();
|
||||
} catch (error) {
|
||||
merchantTeamLog.error('Failed to remove member:', error);
|
||||
Utils.showToast(error.message || 'Failed to remove member', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove member from all stores
|
||||
*/
|
||||
async removeFromAllStores(member) {
|
||||
if (!member || !member.stores || member.stores.length === 0) return;
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
for (const store of member.stores) {
|
||||
await apiClient.delete(
|
||||
`/merchants/account/team/stores/${store.store_id}/members/${member.user_id}`
|
||||
);
|
||||
}
|
||||
|
||||
Utils.showToast(I18n.t('tenancy.messages.team_member_removed'), 'success');
|
||||
merchantTeamLog.info('Removed member from all stores:', member.user_id);
|
||||
|
||||
this.showRemoveModal = false;
|
||||
this.selectedMember = null;
|
||||
await this.loadTeamData();
|
||||
} catch (error) {
|
||||
merchantTeamLog.error('Failed to remove member from all stores:', error);
|
||||
Utils.showToast(error.message || 'Failed to remove member', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get initials for avatar display
|
||||
*/
|
||||
getInitials(member) {
|
||||
const first = member.first_name || member.email?.charAt(0) || '';
|
||||
const last = member.last_name || '';
|
||||
return (first.charAt(0) + last.charAt(0)).toUpperCase() || '?';
|
||||
},
|
||||
|
||||
/**
|
||||
* Get member status based on their store memberships
|
||||
*/
|
||||
getMemberStatus(member) {
|
||||
if (!member.stores || member.stores.length === 0) return 'inactive';
|
||||
if (member.stores.some(s => s.is_pending)) return 'pending';
|
||||
if (member.stores.some(s => s.is_active)) return 'active';
|
||||
return 'inactive';
|
||||
},
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const locale = window.STORE_CONFIG?.locale || 'en-GB';
|
||||
return new Date(dateStr).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -194,7 +194,7 @@ function storeTeam() {
|
||||
this.saving = true;
|
||||
try {
|
||||
await apiClient.put(
|
||||
`/store/${this.storeCode}/team/members/${this.selectedMember.user_id}`,
|
||||
`/store/team/members/${this.selectedMember.id}`,
|
||||
this.editForm
|
||||
);
|
||||
|
||||
@@ -228,7 +228,7 @@ function storeTeam() {
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
await apiClient.delete(`/store/team/members/${this.selectedMember.user_id}`);
|
||||
await apiClient.delete(`/store/team/members/${this.selectedMember.id}`);
|
||||
|
||||
Utils.showToast(I18n.t('tenancy.messages.team_member_removed'), 'success');
|
||||
storeTeamLog.info('Removed team member:', this.selectedMember.user_id);
|
||||
|
||||
@@ -177,6 +177,9 @@
|
||||
|
||||
<!-- Scripts - ORDER MATTERS! -->
|
||||
|
||||
<!-- 0. Frontend type -->
|
||||
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("admin") }}';</script>
|
||||
|
||||
<!-- 1. Log Configuration -->
|
||||
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
|
||||
|
||||
|
||||
@@ -178,6 +178,9 @@
|
||||
|
||||
<!-- Scripts - ORDER MATTERS! -->
|
||||
|
||||
<!-- 0. Frontend type -->
|
||||
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("merchant") }}';</script>
|
||||
|
||||
<!-- 1. Log Configuration -->
|
||||
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
|
||||
|
||||
|
||||
@@ -1,140 +1,379 @@
|
||||
{# app/modules/tenancy/templates/tenancy/merchant/team.html #}
|
||||
{% extends "merchant/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
|
||||
{% from 'shared/macros/modals.html' import modal, confirm_modal %}
|
||||
|
||||
{% block title %}{{ _("tenancy.team.title") }}{% endblock %}
|
||||
{% block title %}{{ _('tenancy.team.title') }}{% endblock %}
|
||||
{% block alpine_data %}merchantTeam(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="merchantTeam()">
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Header with Invite button -->
|
||||
{% call page_header_flex(title=_('tenancy.team.title'), subtitle=_('tenancy.team.manage_members_description')) %}
|
||||
<button @click="openInviteModal()"
|
||||
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('user-plus', 'w-4 h-4 mr-2')"></span>
|
||||
{{ _('tenancy.team.invite_member') }}
|
||||
</button>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Loading State -->
|
||||
{{ loading_state(_('tenancy.team.loading_team'), 'loading') }}
|
||||
|
||||
<!-- Error State -->
|
||||
{{ error_state(_('tenancy.team.error_title'), 'error', 'error && !loading') }}
|
||||
|
||||
<!-- Main Content (visible when not loading) -->
|
||||
<div x-show="!loading" x-cloak>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<!-- Total Members -->
|
||||
<div class="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs border border-gray-200 dark:border-gray-700">
|
||||
<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('users', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ _("tenancy.team.title") }}</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ _("tenancy.team.members") }}
|
||||
<span x-show="data" class="font-medium" x-text="`(${data?.total_members || 0})`"></span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('tenancy.team.total_members') }}</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_members"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p class="text-sm text-red-800 dark:text-red-200" x-text="error"></p>
|
||||
<!-- Active -->
|
||||
<div class="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs border border-gray-200 dark:border-gray-700">
|
||||
<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>
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{{ _("common.loading") }}
|
||||
</div>
|
||||
|
||||
<!-- Store Teams -->
|
||||
<div x-show="!loading && data" x-cloak class="space-y-6">
|
||||
<template x-for="store in data?.stores || []" :key="store.store_id">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<!-- Store Header -->
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span x-html="$icon('shopping-bag', 'w-5 h-5 text-gray-400')"></span>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="store.store_name"></h3>
|
||||
<p class="text-xs text-gray-400 font-mono" x-text="store.store_code"></p>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="store.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
|
||||
x-text="store.is_active ? '{{ _("common.active") }}' : '{{ _("common.inactive") }}'">
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400"
|
||||
x-text="`${store.member_count} {{ _("tenancy.team.members").toLowerCase() }}`"></span>
|
||||
<a :href="`/store/${store.store_code}/team`"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-900/30 rounded-md hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-colors">
|
||||
<span x-html="$icon('external-link', 'w-3.5 h-3.5 mr-1')"></span>
|
||||
{{ _("common.view") }}
|
||||
</a>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('tenancy.team.active') }}</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_active"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members List -->
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<!-- Owner Row -->
|
||||
<div class="px-6 py-3 flex items-center gap-4 bg-gray-50/50 dark:bg-gray-700/30">
|
||||
<div class="w-8 h-8 rounded-full bg-indigo-100 dark:bg-indigo-900 flex items-center justify-center flex-shrink-0">
|
||||
<span x-html="$icon('shield-check', 'w-4 h-4 text-indigo-600 dark:text-indigo-400')"></span>
|
||||
<!-- Pending -->
|
||||
<div class="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs border border-gray-200 dark:border-gray-700">
|
||||
<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('clock', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('tenancy.team.pending_invitations') }}</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_pending"></p>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="data?.owner_email || '{{ _("tenancy.team.owner") }}'"></p>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 rounded-full">{{ _("tenancy.team.owner") }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Team Members -->
|
||||
<template x-for="member in store.members" :key="member.id">
|
||||
<div class="px-6 py-3 flex items-center gap-4">
|
||||
<div class="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
|
||||
<span x-html="$icon('user', 'w-4 h-4 text-gray-500 dark:text-gray-400')"></span>
|
||||
<!-- Store Filter -->
|
||||
<div class="mb-4" x-show="stores.length > 1">
|
||||
<select x-model="storeFilter"
|
||||
class="px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:ring focus:ring-purple-300 dark:focus:ring-purple-600">
|
||||
<option value="">{{ _('tenancy.team.all_stores') }}</option>
|
||||
<template x-for="store in stores" :key="store.id">
|
||||
<option :value="store.id" x-text="store.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
|
||||
<!-- Members Table -->
|
||||
<div x-show="filteredMembers.length > 0">
|
||||
{% call table_wrapper() %}
|
||||
{{ table_header([_('tenancy.team.member'), _('tenancy.team.stores_and_roles'), _('tenancy.team.status'), _('tenancy.team.actions')]) }}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="member in filteredMembers" :key="member.user_id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<!-- Member: Avatar + Name + Email -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="relative w-8 h-8 mr-3 rounded-full flex-shrink-0">
|
||||
<div class="flex items-center justify-center w-full h-full rounded-full"
|
||||
:class="getMemberStatus(member) === 'active' ? 'bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-300' : 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'">
|
||||
<span class="text-xs font-semibold" x-text="getInitials(member)"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-gray-800 dark:text-gray-200">
|
||||
<span x-text="member.first_name || ''"></span>
|
||||
<span x-text="member.last_name || ''"></span>
|
||||
<span x-show="!member.first_name && !member.last_name" x-text="member.email"></span>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="member.email"
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="member.email"
|
||||
x-show="member.first_name || member.last_name"></p>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="member.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'"
|
||||
x-text="member.is_active ? (member.role_name || '{{ _("tenancy.team.members") }}') : '{{ _("common.pending") }}'">
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Stores & Roles -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<template x-for="store in member.stores" :key="store.store_id">
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||
<span class="font-medium" x-text="store.store_name"></span>:
|
||||
<span x-text="store.role_name || '{{ _('tenancy.team.no_role') }}'"></span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<template x-if="member.is_owner">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
<span x-html="$icon('shield-check', 'w-3 h-3 mr-1')"></span>
|
||||
{{ _('tenancy.team.owner') }}
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="!member.is_owner && getMemberStatus(member) === 'pending'">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
{{ _('common.pending') }}
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="!member.is_owner && getMemberStatus(member) === 'active'">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
{{ _('common.active') }}
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="!member.is_owner && getMemberStatus(member) === 'inactive'">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
|
||||
{{ _('common.inactive') }}
|
||||
</span>
|
||||
</template>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<template x-if="member.is_owner">
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs text-purple-600 dark:text-purple-400">
|
||||
<span x-html="$icon('shield-check', 'w-4 h-4')"></span>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="!member.is_owner">
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="openEditModal(member)"
|
||||
class="p-1.5 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
:title="$t('tenancy.team.edit_member')">
|
||||
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button @click="openRemoveModal(member)"
|
||||
class="p-1.5 text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
:title="$t('tenancy.team.remove_member')">
|
||||
<span x-html="$icon('trash', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<template x-if="store.members.length === 0">
|
||||
<div class="px-6 py-6 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
{{ _("tenancy.team.title") }} - {{ _("common.none") }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty State: No Stores -->
|
||||
<template x-if="data && data.stores.length === 0">
|
||||
<div class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div x-show="!loading && filteredMembers.length === 0" x-cloak
|
||||
class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<span x-html="$icon('user-group', 'w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto')"></span>
|
||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">{{ _("common.not_available") }}</p>
|
||||
<h3 class="mt-4 text-sm font-medium text-gray-900 dark:text-gray-200">{{ _('tenancy.team.no_members_title') }}</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ _('tenancy.team.no_members_description') }}</p>
|
||||
<button @click="openInviteModal()"
|
||||
class="mt-4 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
|
||||
<span x-html="$icon('user-plus', 'w-4 h-4 mr-2')"></span>
|
||||
{{ _('tenancy.team.invite_first_member') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ==================== INVITE MODAL ==================== -->
|
||||
{% call modal('inviteModal', _('tenancy.team.invite_member'), 'showInviteModal', size='md', show_footer=false) %}
|
||||
<form @submit.prevent="sendInvitation()" class="space-y-4">
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{{ _('tenancy.team.email') }} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="email" x-model="inviteForm.email" required
|
||||
class="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-lg focus:border-purple-400 focus:outline-none focus:ring focus:ring-purple-300 dark:focus:ring-purple-600"
|
||||
placeholder="{{ _('tenancy.team.email_placeholder') }}">
|
||||
</div>
|
||||
|
||||
<!-- First Name / Last Name -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{{ _('tenancy.team.first_name') }}
|
||||
</label>
|
||||
<input type="text" x-model="inviteForm.first_name"
|
||||
class="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-lg focus:border-purple-400 focus:outline-none focus:ring focus:ring-purple-300 dark:focus:ring-purple-600"
|
||||
placeholder="{{ _('tenancy.team.first_name') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{{ _('tenancy.team.last_name') }}
|
||||
</label>
|
||||
<input type="text" x-model="inviteForm.last_name"
|
||||
class="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-lg focus:border-purple-400 focus:outline-none focus:ring focus:ring-purple-300 dark:focus:ring-purple-600"
|
||||
placeholder="{{ _('tenancy.team.last_name') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Store Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ _('tenancy.team.select_stores') }}
|
||||
</label>
|
||||
<div class="space-y-2 max-h-40 overflow-y-auto border border-gray-200 dark:border-gray-600 rounded-lg p-3">
|
||||
<template x-for="store in stores" :key="store.id">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox"
|
||||
:value="store.id"
|
||||
:checked="inviteForm.store_ids.includes(store.id)"
|
||||
@change="toggleStoreSelection(store.id)"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500 dark:border-gray-600 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="store.name"></span>
|
||||
<span class="text-xs text-gray-400 font-mono" x-text="store.code"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{{ _('tenancy.team.role') }}
|
||||
</label>
|
||||
<select x-model="inviteForm.role_name"
|
||||
class="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-lg focus:border-purple-400 focus:outline-none focus:ring focus:ring-purple-300 dark:focus:ring-purple-600">
|
||||
<template x-for="role in roleOptions" :key="role.value">
|
||||
<option :value="role.value" x-text="role.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button type="button" @click="showInviteModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 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 transition-colors">
|
||||
{{ _('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" :disabled="saving"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white 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 transition-colors">
|
||||
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="!saving" x-html="$icon('paper-airplane', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="saving ? '{{ _('common.sending') }}...' : '{{ _('tenancy.team.send_invitation') }}'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
|
||||
<!-- ==================== EDIT MODAL ==================== -->
|
||||
{% call modal('editModal', _('tenancy.team.edit_member'), 'showEditModal', size='md', show_footer=false) %}
|
||||
<div x-show="selectedMember" class="space-y-4">
|
||||
<!-- Member info (read-only) -->
|
||||
<div class="flex items-center gap-3 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300"
|
||||
x-text="selectedMember ? getInitials(selectedMember) : ''"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
<span x-text="selectedMember?.first_name || ''"></span>
|
||||
<span x-text="selectedMember?.last_name || ''"></span>
|
||||
<span x-show="!selectedMember?.first_name && !selectedMember?.last_name"
|
||||
x-text="selectedMember?.email"></span>
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedMember?.email"
|
||||
x-show="selectedMember?.first_name || selectedMember?.last_name"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-store role management -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('tenancy.team.store_roles') }}</h4>
|
||||
<template x-for="store in selectedMember?.stores || []" :key="store.store_id">
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="store.store_name"></p>
|
||||
<p class="text-xs text-gray-400 font-mono" x-text="store.store_code"></p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<select x-model="store.role_name"
|
||||
class="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">
|
||||
<template x-for="role in roleOptions" :key="role.value">
|
||||
<option :value="role.value" :selected="role.value === store.role_name" x-text="role.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
<button @click="updateMemberRole(store.store_id, selectedMember.user_id, store.role_name)"
|
||||
:disabled="saving"
|
||||
class="px-3 py-1 text-xs font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
<span x-show="saving" x-html="$icon('spinner', 'w-3 h-3')"></span>
|
||||
<span x-show="!saving">{{ _('common.update') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<div class="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button @click="showEditModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 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 transition-colors">
|
||||
{{ _('common.close') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- ==================== REMOVE MODAL ==================== -->
|
||||
{% call modal('removeModal', _('tenancy.team.remove_member'), 'showRemoveModal', size='sm', show_footer=false) %}
|
||||
<div x-show="selectedMember" class="space-y-4">
|
||||
<!-- Warning -->
|
||||
<div class="flex items-start gap-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5')"></span>
|
||||
<p class="text-sm text-red-800 dark:text-red-200">
|
||||
{{ _('tenancy.team.remove_confirmation') }}
|
||||
<strong x-text="(selectedMember?.first_name || '') + ' ' + (selectedMember?.last_name || selectedMember?.email || '')"></strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Store list -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('tenancy.team.member_stores') }}:</p>
|
||||
<template x-for="store in selectedMember?.stores || []" :key="store.store_id">
|
||||
<div class="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-html="$icon('shopping-bag', 'w-4 h-4 text-gray-400')"></span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="store.store_name"></span>
|
||||
<span class="text-xs text-gray-400" x-text="'(' + (store.role_name || '{{ _('tenancy.team.no_role') }}') + ')'"></span>
|
||||
</div>
|
||||
<button @click="removeMember(store.store_id, selectedMember.user_id)"
|
||||
:disabled="saving"
|
||||
class="px-2 py-1 text-xs font-medium text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors disabled:opacity-50">
|
||||
{{ _('common.remove') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button @click="showRemoveModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 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 transition-colors">
|
||||
{{ _('common.cancel') }}
|
||||
</button>
|
||||
<button @click="removeFromAllStores(selectedMember)"
|
||||
:disabled="saving"
|
||||
x-show="selectedMember?.stores?.length > 1"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-lg hover:bg-red-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="!saving" x-html="$icon('trash', 'w-4 h-4 mr-2')"></span>
|
||||
{{ _('tenancy.team.remove_from_all_stores') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function merchantTeam() {
|
||||
return {
|
||||
loading: true,
|
||||
error: null,
|
||||
data: null,
|
||||
|
||||
async init() {
|
||||
try {
|
||||
this.data = await apiClient.get('/merchants/tenancy/account/team');
|
||||
} catch (e) {
|
||||
this.error = e.message || 'Failed to load team data';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% block extra_scripts %}
|
||||
<script defer src="{{ url_for('tenancy_static', path='merchant/js/merchant-team.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -208,6 +208,9 @@
|
||||
|
||||
<!-- Scripts - ORDER MATTERS! -->
|
||||
|
||||
<!-- 0. Frontend type -->
|
||||
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("store") }}';</script>
|
||||
|
||||
<!-- 1. Log Configuration -->
|
||||
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="member in members" :key="member.user_id">
|
||||
<template x-for="member in members" :key="member.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<!-- Member Info -->
|
||||
<td class="px-4 py-3">
|
||||
@@ -240,7 +240,7 @@
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
>
|
||||
<template x-for="role in roles" :key="role.id">
|
||||
<option :value="role.id" x-text="role.name"></option>
|
||||
<option :value="role.id" :selected="role.id === editForm.role_id" x-text="role.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
# app/modules/tenancy/tests/integration/test_store_team_members_api.py
|
||||
"""
|
||||
Integration tests for store team member CRUD API endpoints.
|
||||
|
||||
Tests the member management endpoints at:
|
||||
/api/v1/store/team/members
|
||||
/api/v1/store/team/invite
|
||||
|
||||
Authentication: Overrides get_current_store_from_cookie_or_header to return
|
||||
a UserContext with the correct token_store_id. The test user is the merchant
|
||||
owner, so all permission checks pass (owner bypass).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.deps import get_current_store_from_cookie_or_header
|
||||
from app.modules.tenancy.models import Merchant, Role, Store, StoreUser, User
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
from main import app
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
BASE = "/api/v1/store/team"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def member_owner(db):
|
||||
"""Create a store owner user for member tests."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
user = User(
|
||||
email=f"memberowner_{uid}@test.com",
|
||||
username=f"memberowner_{uid}",
|
||||
hashed_password=auth.hash_password("memberpass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
first_name="Owner",
|
||||
last_name="User",
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def member_merchant(db, member_owner):
|
||||
"""Create a merchant owned by member_owner."""
|
||||
merchant = Merchant(
|
||||
name="Member Test Merchant",
|
||||
owner_user_id=member_owner.id,
|
||||
contact_email=member_owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def member_store(db, member_merchant):
|
||||
"""Create a store for member tests."""
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
store = Store(
|
||||
merchant_id=member_merchant.id,
|
||||
store_code=f"MEMTEST_{uid.upper()}",
|
||||
subdomain=f"memtest{uid}",
|
||||
name=f"Member Test Store {uid}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def member_store_user(db, member_store, member_owner):
|
||||
"""Create a StoreUser association for the owner."""
|
||||
store_user = StoreUser(
|
||||
store_id=member_store.id,
|
||||
user_id=member_owner.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(store_user)
|
||||
db.commit()
|
||||
db.refresh(store_user)
|
||||
return store_user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def member_auth(member_owner, member_store, member_store_user):
|
||||
"""Override auth dependency to simulate authenticated store owner.
|
||||
|
||||
Overrides get_current_store_from_cookie_or_header so that both
|
||||
require_store_owner and require_store_permission(...) inner functions
|
||||
receive the correct UserContext. The owner bypass ensures all
|
||||
permission checks pass.
|
||||
"""
|
||||
user_context = UserContext(
|
||||
id=member_owner.id,
|
||||
email=member_owner.email,
|
||||
username=member_owner.username,
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
token_store_id=member_store.id,
|
||||
)
|
||||
|
||||
def _override():
|
||||
return user_context
|
||||
|
||||
app.dependency_overrides[get_current_store_from_cookie_or_header] = _override
|
||||
yield {"Authorization": "Bearer fake-token"}
|
||||
app.dependency_overrides.pop(get_current_store_from_cookie_or_header, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def staff_role(db, member_store):
|
||||
"""Create a 'staff' role for the store."""
|
||||
role = Role(
|
||||
store_id=member_store.id,
|
||||
name="staff",
|
||||
permissions=["orders.view", "products.view"],
|
||||
)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager_role(db, member_store):
|
||||
"""Create a 'manager' role for the store."""
|
||||
role = Role(
|
||||
store_id=member_store.id,
|
||||
name="manager",
|
||||
permissions=["orders.view", "orders.edit", "products.view", "products.edit", "team.view"],
|
||||
)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def team_member_user(db):
|
||||
"""Create another user to serve as a team member."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
user = User(
|
||||
email=f"teammember_{uid}@test.com",
|
||||
username=f"teammember_{uid}",
|
||||
hashed_password=auth.hash_password("memberpass123"),
|
||||
role="store_member",
|
||||
is_active=True,
|
||||
first_name="Team",
|
||||
last_name="Member",
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def team_member(db, member_store, team_member_user, staff_role):
|
||||
"""Create a StoreUser for team_member_user with staff role."""
|
||||
store_user = StoreUser(
|
||||
store_id=member_store.id,
|
||||
user_id=team_member_user.id,
|
||||
role_id=staff_role.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(store_user)
|
||||
db.commit()
|
||||
db.refresh(store_user)
|
||||
return store_user
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GET /team/members
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.tenancy
|
||||
class TestListMembers:
|
||||
"""Tests for GET /api/v1/store/team/members."""
|
||||
|
||||
def test_list_members_returns_owner_and_member(
|
||||
self, client, member_auth, team_member, member_owner, team_member_user
|
||||
):
|
||||
"""GET /members returns both owner and team member."""
|
||||
response = client.get(f"{BASE}/members", headers=member_auth)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
member_ids = {m["id"] for m in data["members"]}
|
||||
assert member_owner.id in member_ids
|
||||
assert team_member_user.id in member_ids
|
||||
|
||||
def test_list_members_response_shape(self, client, member_auth, team_member):
|
||||
"""Each member in the response has expected fields."""
|
||||
response = client.get(f"{BASE}/members", headers=member_auth)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "members" in data
|
||||
assert "total" in data
|
||||
assert "active_count" in data
|
||||
assert "pending_invitations" in data
|
||||
member = data["members"][0]
|
||||
assert "id" in member
|
||||
assert "email" in member
|
||||
assert "username" in member
|
||||
assert "first_name" in member
|
||||
assert "last_name" in member
|
||||
assert "full_name" in member
|
||||
assert "role_name" in member
|
||||
assert "role_id" in member
|
||||
assert "permissions" in member
|
||||
assert "is_active" in member
|
||||
assert "is_owner" in member
|
||||
assert "invitation_pending" in member
|
||||
|
||||
def test_list_members_stats(
|
||||
self, client, member_auth, team_member, member_owner
|
||||
):
|
||||
"""GET /members returns correct statistics."""
|
||||
response = client.get(f"{BASE}/members", headers=member_auth)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] >= 2
|
||||
assert data["active_count"] >= 2
|
||||
assert data["pending_invitations"] >= 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GET /team/members/{user_id}
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.tenancy
|
||||
class TestGetMember:
|
||||
"""Tests for GET /api/v1/store/team/members/{user_id}."""
|
||||
|
||||
def test_get_member_success(
|
||||
self, client, member_auth, team_member, team_member_user
|
||||
):
|
||||
"""GET /members/{user_id} returns the specific member."""
|
||||
response = client.get(
|
||||
f"{BASE}/members/{team_member_user.id}", headers=member_auth
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == team_member_user.id
|
||||
assert data["email"] == team_member_user.email
|
||||
assert data["role_name"] == "staff"
|
||||
|
||||
def test_get_nonexistent_member(self, client, member_auth):
|
||||
"""GET /members/{user_id} returns 404 for non-existent user."""
|
||||
response = client.get(f"{BASE}/members/99999", headers=member_auth)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PUT /team/members/{user_id}
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.tenancy
|
||||
class TestUpdateMember:
|
||||
"""Tests for PUT /api/v1/store/team/members/{user_id}."""
|
||||
|
||||
def test_update_member_role_success(
|
||||
self, client, member_auth, team_member, team_member_user, manager_role
|
||||
):
|
||||
"""PUT /members/{user_id} updates the member's role."""
|
||||
response = client.put(
|
||||
f"{BASE}/members/{team_member_user.id}",
|
||||
headers=member_auth,
|
||||
json={"role_id": manager_role.id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["role_name"] == "manager"
|
||||
assert data["role_id"] == manager_role.id
|
||||
|
||||
def test_update_member_active_status(
|
||||
self, client, member_auth, team_member, team_member_user
|
||||
):
|
||||
"""PUT /members/{user_id} can deactivate a member."""
|
||||
response = client.put(
|
||||
f"{BASE}/members/{team_member_user.id}",
|
||||
headers=member_auth,
|
||||
json={"is_active": False},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["is_active"] is False
|
||||
|
||||
def test_update_owner_rejected(
|
||||
self, client, member_auth, member_owner, staff_role
|
||||
):
|
||||
"""PUT /members/{user_id} rejects changing owner's role."""
|
||||
response = client.put(
|
||||
f"{BASE}/members/{member_owner.id}",
|
||||
headers=member_auth,
|
||||
json={"role_id": staff_role.id},
|
||||
)
|
||||
assert response.status_code in (400, 422)
|
||||
|
||||
def test_update_nonexistent_member(self, client, member_auth, staff_role):
|
||||
"""PUT /members/{user_id} returns 404 for non-existent user."""
|
||||
response = client.put(
|
||||
f"{BASE}/members/99999",
|
||||
headers=member_auth,
|
||||
json={"role_id": staff_role.id},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_update_with_invalid_role_id(
|
||||
self, client, member_auth, team_member, team_member_user
|
||||
):
|
||||
"""PUT /members/{user_id} returns 422 for non-existent role."""
|
||||
response = client.put(
|
||||
f"{BASE}/members/{team_member_user.id}",
|
||||
headers=member_auth,
|
||||
json={"role_id": 99999},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DELETE /team/members/{user_id}
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.tenancy
|
||||
class TestRemoveMember:
|
||||
"""Tests for DELETE /api/v1/store/team/members/{user_id}."""
|
||||
|
||||
def test_remove_member_success(
|
||||
self, client, member_auth, team_member, team_member_user, db
|
||||
):
|
||||
"""DELETE /members/{user_id} removes a team member."""
|
||||
response = client.delete(
|
||||
f"{BASE}/members/{team_member_user.id}", headers=member_auth
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user_id"] == team_member_user.id
|
||||
|
||||
# Verify member is soft-deleted (deleted_at set, record hidden from normal queries)
|
||||
db.expire_all()
|
||||
store_user = (
|
||||
db.query(StoreUser)
|
||||
.execution_options(include_deleted=True)
|
||||
.filter(StoreUser.user_id == team_member_user.id)
|
||||
.first()
|
||||
)
|
||||
assert store_user is not None
|
||||
assert store_user.deleted_at is not None
|
||||
|
||||
def test_remove_owner_rejected(self, client, member_auth, member_owner):
|
||||
"""DELETE /members/{user_id} rejects removing the owner."""
|
||||
response = client.delete(
|
||||
f"{BASE}/members/{member_owner.id}", headers=member_auth
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_remove_nonexistent_member(self, client, member_auth):
|
||||
"""DELETE /members/{user_id} returns 404 for non-existent user."""
|
||||
response = client.delete(
|
||||
f"{BASE}/members/99999", headers=member_auth
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# POST /team/invite
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.tenancy
|
||||
class TestInviteMember:
|
||||
"""Tests for POST /api/v1/store/team/invite."""
|
||||
|
||||
def test_invite_member_success(self, client, member_auth, staff_role):
|
||||
"""POST /invite creates an invitation for a new email."""
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
response = client.post(
|
||||
f"{BASE}/invite",
|
||||
headers=member_auth,
|
||||
json={
|
||||
"email": f"newinvite_{uid}@test.com",
|
||||
"role_name": "staff",
|
||||
"first_name": "New",
|
||||
"last_name": "Invitee",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == f"newinvite_{uid}@test.com"
|
||||
assert data["invitation_sent"] is True
|
||||
assert data["role"] == "staff"
|
||||
|
||||
def test_invite_duplicate_email(
|
||||
self, client, member_auth, team_member, team_member_user, staff_role
|
||||
):
|
||||
"""POST /invite with existing member email returns error or reactivation."""
|
||||
response = client.post(
|
||||
f"{BASE}/invite",
|
||||
headers=member_auth,
|
||||
json={
|
||||
"email": team_member_user.email,
|
||||
"role_name": "staff",
|
||||
},
|
||||
)
|
||||
# May succeed as reactivation or fail as duplicate
|
||||
assert response.status_code in (200, 400, 409, 422)
|
||||
@@ -243,17 +243,29 @@ class TestStoreTeamServiceRemove:
|
||||
"""Test suite for removing team members."""
|
||||
|
||||
def test_remove_team_member_success(self, db, team_store, team_member):
|
||||
"""Test removing a team member."""
|
||||
"""Test removing a team member (soft delete)."""
|
||||
result = store_team_service.remove_team_member(
|
||||
db=db,
|
||||
store=team_store,
|
||||
user_id=team_member.user_id,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(team_member)
|
||||
|
||||
# Verify soft-deleted (hidden from normal queries, visible with include_deleted)
|
||||
from app.modules.tenancy.models import StoreUser
|
||||
|
||||
hidden = db.query(StoreUser).filter(StoreUser.id == team_member.id).first()
|
||||
assert hidden is None # Filtered out by soft-delete
|
||||
|
||||
visible = (
|
||||
db.query(StoreUser)
|
||||
.execution_options(include_deleted=True)
|
||||
.filter(StoreUser.id == team_member.id)
|
||||
.first()
|
||||
)
|
||||
assert visible is not None
|
||||
assert visible.deleted_at is not None
|
||||
assert result is True
|
||||
assert team_member.is_active is False
|
||||
|
||||
def test_remove_owner_raises_error(self, db, team_store, store_owner):
|
||||
"""Test removing owner raises exception."""
|
||||
|
||||
@@ -100,6 +100,9 @@
|
||||
|
||||
<!-- Core Scripts - ORDER MATTERS! -->
|
||||
|
||||
<!-- 0. Frontend type (server-injected, used by log-config and dev-toolbar) -->
|
||||
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("admin") }}';</script>
|
||||
|
||||
<!-- 1. FIRST: Log Configuration -->
|
||||
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
|
||||
<!-- Core Scripts - ORDER MATTERS! -->
|
||||
|
||||
<!-- 0. Frontend type (server-injected, used by log-config and dev-toolbar) -->
|
||||
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("merchant") }}';</script>
|
||||
|
||||
<!-- 1. FIRST: Log Configuration -->
|
||||
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@
|
||||
|
||||
<!-- Core Scripts - ORDER MATTERS! -->
|
||||
|
||||
<!-- 0. Frontend type (server-injected, used by log-config and dev-toolbar) -->
|
||||
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("store") }}';</script>
|
||||
|
||||
<!-- 1. FIRST: Log Configuration -->
|
||||
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
|
||||
|
||||
|
||||
@@ -349,6 +349,9 @@
|
||||
|
||||
{# JavaScript Loading Order (CRITICAL - must be in this order) #}
|
||||
|
||||
{# 0. Frontend type (server-injected, used by log-config and dev-toolbar) #}
|
||||
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("storefront") }}';</script>
|
||||
|
||||
{# 1. Log Configuration (must load first) #}
|
||||
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
|
||||
|
||||
|
||||
@@ -79,12 +79,16 @@ def testing_session_local(engine):
|
||||
commits. This allows fixtures to remain usable after database operations
|
||||
without needing to refresh or re-query them.
|
||||
"""
|
||||
return sessionmaker(
|
||||
from app.core.database import register_soft_delete_filter
|
||||
|
||||
session_factory = sessionmaker(
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
bind=engine,
|
||||
expire_on_commit=False, # Prevents lazy-load issues after commits
|
||||
)
|
||||
register_soft_delete_filter(session_factory)
|
||||
return session_factory
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
|
||||
119
docs/backend/soft-delete.md
Normal file
119
docs/backend/soft-delete.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Soft Delete
|
||||
|
||||
## Overview
|
||||
|
||||
Business-critical records use soft delete instead of hard delete. When a record is "deleted", it gets a `deleted_at` timestamp instead of being removed from the database. This preserves data for investigation, auditing, and potential restoration.
|
||||
|
||||
## How It Works
|
||||
|
||||
### SoftDeleteMixin
|
||||
|
||||
Models opt into soft delete by inheriting `SoftDeleteMixin` (from `models/database/base.py`):
|
||||
|
||||
```python
|
||||
from models.database.base import SoftDeleteMixin, TimestampMixin
|
||||
|
||||
class MyModel(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "my_table"
|
||||
# ...
|
||||
```
|
||||
|
||||
This adds two columns:
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `deleted_at` | DateTime (nullable, indexed) | When the record was deleted. NULL = alive. |
|
||||
| `deleted_by_id` | Integer (FK to users.id, nullable) | Who performed the deletion. |
|
||||
|
||||
### Automatic Query Filtering
|
||||
|
||||
A `do_orm_execute` event on the session automatically appends `WHERE deleted_at IS NULL` to all SELECT queries for models with `SoftDeleteMixin`. This means:
|
||||
|
||||
- **Normal queries never see deleted records** — no code changes needed
|
||||
- **Relationship lazy loads are also filtered** — e.g., `store.products` won't include deleted products
|
||||
|
||||
### Bypassing the Filter
|
||||
|
||||
To see deleted records (admin views, restore operations):
|
||||
|
||||
```python
|
||||
# Legacy query style
|
||||
db.query(User).execution_options(include_deleted=True).all()
|
||||
|
||||
# Core select style
|
||||
from sqlalchemy import select
|
||||
db.execute(
|
||||
select(User).filter(User.id == 42),
|
||||
execution_options={"include_deleted": True}
|
||||
).scalar_one_or_none()
|
||||
```
|
||||
|
||||
## Models Using Soft Delete
|
||||
|
||||
| Model | Table | Module |
|
||||
|-------|-------|--------|
|
||||
| User | users | tenancy |
|
||||
| Merchant | merchants | tenancy |
|
||||
| Store | stores | tenancy |
|
||||
| StoreUser | store_users | tenancy |
|
||||
| Customer | customers | customers |
|
||||
| Order | orders | orders |
|
||||
| Product | products | catalog |
|
||||
| LoyaltyProgram | loyalty_programs | loyalty |
|
||||
| LoyaltyCard | loyalty_cards | loyalty |
|
||||
|
||||
## Utility Functions
|
||||
|
||||
Import from `app.core.soft_delete`:
|
||||
|
||||
### `soft_delete(db, entity, deleted_by_id)`
|
||||
|
||||
Marks a single record as deleted.
|
||||
|
||||
### `restore(db, model_class, entity_id, restored_by_id)`
|
||||
|
||||
Restores a soft-deleted record. Queries with `include_deleted=True` internally.
|
||||
|
||||
### `soft_delete_cascade(db, entity, deleted_by_id, cascade_rels)`
|
||||
|
||||
Soft-deletes a record and recursively soft-deletes its children:
|
||||
|
||||
```python
|
||||
soft_delete_cascade(db, merchant, deleted_by_id=admin.id, cascade_rels=[
|
||||
("stores", [
|
||||
("products", []),
|
||||
("customers", []),
|
||||
("orders", []),
|
||||
("store_users", []),
|
||||
]),
|
||||
])
|
||||
```
|
||||
|
||||
## Partial Unique Indexes
|
||||
|
||||
Tables with unique constraints (e.g., `users.email`, `stores.store_code`) use **partial unique indexes** that only enforce uniqueness among non-deleted rows:
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX uq_users_email_active ON users (email) WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
This allows a soft-deleted user's email to be reused by a new registration.
|
||||
|
||||
## Adding Soft Delete to a New Model
|
||||
|
||||
1. Add `SoftDeleteMixin` to the model class
|
||||
2. Create an alembic migration adding `deleted_at` and `deleted_by_id` columns
|
||||
3. If the model has unique constraints, convert them to partial unique indexes
|
||||
4. If the model has relationships to users (ForeignKey to users.id), add `foreign_keys=` to those relationships to resolve ambiguity with `deleted_by_id`
|
||||
5. Register the test session factory with `register_soft_delete_filter()` if not already done
|
||||
|
||||
## What Stays as Hard Delete
|
||||
|
||||
Operational and config data that doesn't need investigation trail:
|
||||
|
||||
- Roles, themes, email settings, invoice settings
|
||||
- Cart items, application logs, notifications
|
||||
- Password/email verification tokens
|
||||
- Domains (store and merchant)
|
||||
- Content pages, media files
|
||||
- Import jobs, marketplace products
|
||||
143
docs/proposals/post-soft-delete-followups.md
Normal file
143
docs/proposals/post-soft-delete-followups.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Post Soft-Delete Follow-up Tasks
|
||||
|
||||
**Date:** 2026-03-28
|
||||
**Context:** During the soft-delete implementation session, several gaps were identified in the platform. This proposal outlines 6 follow-up tasks in priority order.
|
||||
|
||||
---
|
||||
|
||||
## 1. Admin Email Verification Gap (Quick Fix)
|
||||
|
||||
**Problem:** Admin users (super_admin, platform_admin) are created with `is_email_verified=False` (model default). Login checks `is_email_verified` and blocks unverified users. But there's no admin-facing email verification flow — no verification email is sent on admin creation, and `/resend-verification` is merchant-scoped.
|
||||
|
||||
**Impact:** Newly created admin accounts can't log in until somehow email-verified.
|
||||
|
||||
**Proposed Fix:** Auto-set `is_email_verified=True` when creating admin users via `admin_platform_service.create_super_admin()` and `create_platform_admin()`. Admins are created by super admins, so trust is implicit.
|
||||
|
||||
**Alternative:** Send a verification email on admin creation using the existing `EmailVerificationToken` model and `/verify-email` page endpoint.
|
||||
|
||||
**Files:**
|
||||
- `app/modules/tenancy/services/admin_platform_service.py` — `create_super_admin()`, `create_platform_admin()`
|
||||
|
||||
**Effort:** Small (< 30 min)
|
||||
|
||||
---
|
||||
|
||||
## 2. Customer Soft-Delete Endpoint (Compliance)
|
||||
|
||||
**Problem:** Customers have no delete endpoint at all — not soft delete, not hard delete. Only customer addresses can be deleted. This is a gap for GDPR/data-subject-deletion compliance.
|
||||
|
||||
**Proposed Fix:** Add soft-delete endpoints:
|
||||
- `DELETE /api/v1/store/customers/{customer_id}` — store owner/staff can soft-delete
|
||||
- `DELETE /api/v1/admin/customers/{customer_id}` — admin can soft-delete
|
||||
|
||||
Customer already has `SoftDeleteMixin`. Consider cascading to orders, addresses, and loyalty cards.
|
||||
|
||||
**Files:**
|
||||
- `app/modules/customers/routes/api/store.py` — new DELETE endpoint
|
||||
- `app/modules/customers/services/customer_service.py` — new `delete_customer()` method
|
||||
|
||||
**Effort:** Medium (1-2 hours)
|
||||
|
||||
---
|
||||
|
||||
## 3. Cascade Restore Utility
|
||||
|
||||
**Problem:** `restore()` only restores a single record. Restoring a merchant doesn't auto-restore its stores/products/customers/orders. Admin has to restore each entity one by one.
|
||||
|
||||
**Proposed Fix:** Add `restore_cascade()` to `app/core/soft_delete.py` mirroring `soft_delete_cascade()`. Walk the same relationship tree. Add optional `cascade=true` query param to existing restore endpoints:
|
||||
- `PUT /api/v1/admin/merchants/{id}/restore?cascade=true`
|
||||
- `PUT /api/v1/admin/stores/{id}/restore?cascade=true`
|
||||
|
||||
**Files:**
|
||||
- `app/core/soft_delete.py` — new `restore_cascade()` function
|
||||
- `app/modules/tenancy/routes/api/admin_stores.py` — update restore endpoint
|
||||
- `app/modules/tenancy/routes/api/admin_merchants.py` — update restore endpoint
|
||||
|
||||
**Effort:** Small-Medium (1 hour)
|
||||
|
||||
---
|
||||
|
||||
## 4. Admin Trash UI
|
||||
|
||||
**Problem:** The soft-delete API supports `?only_deleted=true` on admin list endpoints (stores, merchants, users) but there's no UI to browse or restore deleted records.
|
||||
|
||||
**Proposed Fix:** Add a "Trash" toggle/tab to admin list pages:
|
||||
- `admin/stores.html` — toggle between active stores and trash
|
||||
- `admin/merchants.html` — same
|
||||
- `admin/admin-users.html` — same (super admin only)
|
||||
|
||||
Each deleted row shows `deleted_at`, `deleted_by`, and a "Restore" button calling `PUT /api/v1/admin/{entity}/{id}/restore`.
|
||||
|
||||
**Implementation:** The Alpine.js components need a `showDeleted` toggle state that:
|
||||
- Adds `?only_deleted=true` to the list API call
|
||||
- Shows a different table header (with deleted_at column)
|
||||
- Replaces edit/delete actions with a Restore button
|
||||
|
||||
**Files:**
|
||||
- `app/modules/tenancy/templates/tenancy/admin/stores.html`
|
||||
- `app/modules/tenancy/templates/tenancy/admin/merchants.html`
|
||||
- `app/modules/tenancy/templates/tenancy/admin/admin-users.html`
|
||||
- Corresponding JS files in `app/modules/tenancy/static/admin/js/`
|
||||
|
||||
**Effort:** Medium (2-3 hours)
|
||||
|
||||
---
|
||||
|
||||
## 5. Admin Team Management Page
|
||||
|
||||
**Problem:** There is no admin-level page for managing store teams. The admin can see merchant users at `/admin/merchant-users`, but this is a user-centric view — not team-centric. Admin cannot:
|
||||
- View team members per store
|
||||
- Invite/remove team members on behalf of a store
|
||||
- See team composition across the platform
|
||||
|
||||
Store owners manage their teams at `/store/{code}/team`. Merchants manage across stores at `/merchants/account/team`. But admin has no equivalent.
|
||||
|
||||
**Proposed Fix:** Add `/admin/stores/{store_code}/team` page that reuses the existing store team API endpoints (`/api/v1/store/team/*`) with admin auth context. The admin store detail page should link to it.
|
||||
|
||||
**Components needed:**
|
||||
- Page route in `app/modules/tenancy/routes/pages/admin.py`
|
||||
- Template at `app/modules/tenancy/templates/tenancy/admin/store-team.html`
|
||||
- JS component (can largely reuse `store/js/team.js` patterns)
|
||||
- Menu item or link from store detail page
|
||||
|
||||
**Consideration:** Admin already has `/admin/store-roles` for role CRUD. The team page completes the picture.
|
||||
|
||||
**Effort:** Medium-Large (3-4 hours)
|
||||
|
||||
---
|
||||
|
||||
## 6. Merchant Team Roles Page
|
||||
|
||||
**Problem:** Store frontend has a full roles management page (`/store/{code}/team/roles`) with CRUD for custom roles and granular permissions. Merchant portal has no equivalent — merchants can only assign preset roles (manager, staff, support, viewer, marketing) during invite/edit, not create custom roles.
|
||||
|
||||
**Proposed Fix:** Add `/merchants/account/team/roles` page. Since roles are per-store in the data model, the page should:
|
||||
1. Let merchant pick a store from a dropdown
|
||||
2. Show roles for that store (reusing `GET /account/team/stores/{store_id}/roles`)
|
||||
3. Allow CRUD on custom roles (delegating to store team service)
|
||||
|
||||
**Files:**
|
||||
- New page route in `app/modules/tenancy/routes/pages/merchant.py`
|
||||
- New template at `app/modules/tenancy/templates/tenancy/merchant/team-roles.html`
|
||||
- New JS at `app/modules/tenancy/static/merchant/js/merchant-roles.js`
|
||||
- New API endpoints in `app/modules/tenancy/routes/api/merchant.py`
|
||||
- Menu item in `app/modules/tenancy/definition.py` (merchant menu)
|
||||
- i18n keys in 4 locale files
|
||||
|
||||
**Reference:** Store roles page at `templates/tenancy/store/roles.html` and `static/store/js/roles.js`
|
||||
|
||||
**Effort:** Large (4-5 hours)
|
||||
|
||||
---
|
||||
|
||||
## Priority & Sequencing
|
||||
|
||||
| # | Task | Priority | Effort | Dependency |
|
||||
|---|------|----------|--------|------------|
|
||||
| 1 | Admin email verification | Critical | Small | None |
|
||||
| 2 | Customer soft-delete | High (compliance) | Medium | None |
|
||||
| 3 | Cascade restore | Medium | Small | None |
|
||||
| 4 | Admin trash UI | Medium | Medium | None |
|
||||
| 5 | Admin team management | Medium | Medium-Large | None |
|
||||
| 6 | Merchant roles page | Low | Large | None |
|
||||
|
||||
Tasks 1-3 can be done in a single session. Tasks 4-6 are independent and can be tackled in any order.
|
||||
@@ -96,6 +96,7 @@ nav:
|
||||
- Store-in-Token Architecture: backend/store-in-token-architecture.md
|
||||
- Admin Integration Guide: backend/admin-integration-guide.md
|
||||
- Admin Feature Integration: backend/admin-feature-integration.md
|
||||
- Soft Delete: backend/soft-delete.md
|
||||
|
||||
# --- Frontend ---
|
||||
- Frontend:
|
||||
@@ -332,6 +333,7 @@ nav:
|
||||
- RBAC Cleanup Two-Phase Plan: proposals/rbac-cleanup-two-phase-plan.md
|
||||
- Store Login Platform Detection: proposals/store-login-platform-detection.md
|
||||
- Test API Deps Auth Dependencies: proposals/test-api-deps-auth-dependencies.md
|
||||
- Post Soft-Delete Follow-ups: proposals/post-soft-delete-followups.md
|
||||
|
||||
# --- Archive ---
|
||||
- Archive:
|
||||
|
||||
@@ -5,6 +5,7 @@ Database models package - Base classes and mixins only.
|
||||
This package provides the base infrastructure for SQLAlchemy models:
|
||||
- Base: SQLAlchemy declarative base
|
||||
- TimestampMixin: Mixin for created_at/updated_at timestamps
|
||||
- SoftDeleteMixin: Mixin for soft-deletable models (deleted_at/deleted_by_id)
|
||||
|
||||
IMPORTANT: Domain models have been migrated to their respective modules:
|
||||
- Tenancy models: app.modules.tenancy.models
|
||||
@@ -22,9 +23,10 @@ IMPORTANT: Domain models have been migrated to their respective modules:
|
||||
Import models from their canonical module locations instead of this package.
|
||||
"""
|
||||
|
||||
from .base import Base, TimestampMixin
|
||||
from .base import Base, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"SoftDeleteMixin",
|
||||
"TimestampMixin",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Column, DateTime
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
@@ -15,3 +15,20 @@ class TimestampMixin:
|
||||
onupdate=datetime.now(UTC),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class SoftDeleteMixin:
|
||||
"""Mixin for soft-deletable models.
|
||||
|
||||
Adds deleted_at and deleted_by_id columns. Records with deleted_at set
|
||||
are automatically excluded from queries via the do_orm_execute event
|
||||
in app.core.database. Use execution_options={"include_deleted": True}
|
||||
to bypass the filter.
|
||||
"""
|
||||
|
||||
deleted_at = Column(DateTime, nullable=True, index=True)
|
||||
deleted_by_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
@@ -848,6 +848,7 @@ class ArchitectureValidator:
|
||||
is_admin = "/admin/" in file_path_str or "\\admin\\" in file_path_str
|
||||
is_store = "/store/" in file_path_str or "\\store\\" in file_path_str
|
||||
is_shop = "/shop/" in file_path_str or "\\shop\\" in file_path_str
|
||||
is_merchant = "/merchant/" in file_path_str or "\\merchant\\" in file_path_str
|
||||
|
||||
if is_base_or_partial:
|
||||
print("⏭️ Skipping base/partial template")
|
||||
@@ -881,8 +882,8 @@ class ArchitectureValidator:
|
||||
# TPL-008: Check for call table_header() pattern (should be table_header_custom)
|
||||
self._check_table_header_call_pattern(file_path, content, lines)
|
||||
|
||||
# TPL-009: Check for invalid block names (admin and store use same blocks)
|
||||
if is_admin or is_store:
|
||||
# TPL-009: Check for invalid block names (admin, store, and merchant use same blocks)
|
||||
if is_admin or is_store or is_merchant:
|
||||
self._check_valid_block_names(file_path, content, lines)
|
||||
|
||||
if is_base_or_partial:
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"saving": "Speichern...",
|
||||
"processing": "Verarbeiten...",
|
||||
"searching": "Suchen...",
|
||||
"sending": "Wird gesendet",
|
||||
"refresh": "Aktualisieren",
|
||||
"retry": "Erneut versuchen",
|
||||
"view": "Ansehen",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"saving": "Saving...",
|
||||
"processing": "Processing...",
|
||||
"searching": "Searching...",
|
||||
"sending": "Sending",
|
||||
"refresh": "Refresh",
|
||||
"retry": "Retry",
|
||||
"view": "View",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"saving": "Enregistrement...",
|
||||
"processing": "Traitement...",
|
||||
"searching": "Recherche...",
|
||||
"sending": "Envoi en cours",
|
||||
"refresh": "Actualiser",
|
||||
"retry": "Réessayer",
|
||||
"view": "Voir",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"saving": "Späicheren...",
|
||||
"processing": "Veraarbechten...",
|
||||
"searching": "Sichen...",
|
||||
"sending": "Gëtt geschéckt",
|
||||
"refresh": "Aktualiséieren",
|
||||
"retry": "Nach eng Kéier probéieren",
|
||||
"view": "Kucken",
|
||||
|
||||
@@ -127,8 +127,11 @@ class APIClient {
|
||||
|
||||
apiLog.info(`Response: ${response.status} ${response.statusText} (${duration}ms)`);
|
||||
|
||||
// Parse response
|
||||
// Parse response (handle 204 No Content gracefully)
|
||||
let data;
|
||||
if (response.status === 204) {
|
||||
data = null;
|
||||
} else {
|
||||
try {
|
||||
data = await response.json();
|
||||
apiLog.debug('Response data received:', {
|
||||
@@ -140,6 +143,7 @@ class APIClient {
|
||||
apiLog.error('Failed to parse JSON response:', parseError);
|
||||
throw new Error('Invalid JSON response from server');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 401 Unauthorized - Just clear tokens, DON'T redirect
|
||||
if (response.status === 401) {
|
||||
|
||||
@@ -286,11 +286,11 @@
|
||||
}
|
||||
|
||||
function detectFrontend() {
|
||||
// Prefer server-injected value (set in base templates)
|
||||
if (window.FRONTEND_TYPE) return window.FRONTEND_TYPE;
|
||||
|
||||
// Fallback for pages without base template (e.g., API docs)
|
||||
var path = window.location.pathname;
|
||||
if (path.startsWith('/store/') || path === '/store') return 'store';
|
||||
if (path.startsWith('/admin/') || path === '/admin') return 'admin';
|
||||
if (path.indexOf('/merchants/') !== -1) return 'merchant';
|
||||
if (path.indexOf('/storefront/') !== -1) return 'storefront';
|
||||
if (path.startsWith('/api/')) return 'api';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
@@ -43,14 +43,11 @@ const LOG_LEVELS = {
|
||||
|
||||
/**
|
||||
* Detect which frontend we're in based on URL path
|
||||
* @returns {string} 'admin' | 'store' | 'shop' | 'unknown'
|
||||
* @returns {string} 'admin' | 'store' | 'merchant' | 'storefront' | 'unknown'
|
||||
*/
|
||||
function detectFrontend() {
|
||||
const path = window.location.pathname;
|
||||
|
||||
if (path.startsWith('/admin')) return 'admin';
|
||||
if (path.startsWith('/store')) return 'store';
|
||||
if (path.startsWith('/shop')) return 'shop';
|
||||
// Prefer server-injected value (set in base templates before this script loads)
|
||||
if (window.FRONTEND_TYPE) return window.FRONTEND_TYPE;
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
@@ -94,9 +91,13 @@ const DEFAULT_LOG_LEVELS = {
|
||||
development: LOG_LEVELS.DEBUG,
|
||||
production: LOG_LEVELS.INFO // Stores might need more logging
|
||||
},
|
||||
shop: {
|
||||
merchant: {
|
||||
development: LOG_LEVELS.DEBUG,
|
||||
production: LOG_LEVELS.ERROR // Shop frontend: minimal logging in production
|
||||
production: LOG_LEVELS.INFO // Merchant portal: same as store
|
||||
},
|
||||
storefront: {
|
||||
development: LOG_LEVELS.DEBUG,
|
||||
production: LOG_LEVELS.ERROR // Storefront: minimal logging in production
|
||||
},
|
||||
unknown: {
|
||||
development: LOG_LEVELS.DEBUG,
|
||||
@@ -275,10 +276,10 @@ const storeLoggers = {
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// PRE-CONFIGURED LOGGERS FOR SHOP FRONTEND
|
||||
// PRE-CONFIGURED LOGGERS FOR STOREFRONT
|
||||
// ============================================================================
|
||||
|
||||
const shopLoggers = {
|
||||
const storefrontLoggers = {
|
||||
// Product browsing
|
||||
catalog: createLogger('CATALOG', ACTIVE_LOG_LEVEL),
|
||||
product: createLogger('PRODUCT', ACTIVE_LOG_LEVEL),
|
||||
@@ -309,8 +310,10 @@ function getLoggers() {
|
||||
return adminLoggers;
|
||||
case 'store':
|
||||
return storeLoggers;
|
||||
case 'shop':
|
||||
return shopLoggers;
|
||||
case 'merchant':
|
||||
return storeLoggers; // Merchant portal reuses store logger set
|
||||
case 'storefront':
|
||||
return storefrontLoggers;
|
||||
default:
|
||||
return {}; // Empty object, use createLogger instead
|
||||
}
|
||||
|
||||
296
tests/unit/core/test_soft_delete.py
Normal file
296
tests/unit/core/test_soft_delete.py
Normal file
@@ -0,0 +1,296 @@
|
||||
# tests/unit/core/test_soft_delete.py
|
||||
"""
|
||||
Unit tests for soft-delete infrastructure.
|
||||
|
||||
Tests the SoftDeleteMixin, automatic query filtering, and utility functions.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.soft_delete import restore, soft_delete, soft_delete_cascade
|
||||
from app.modules.tenancy.models import Merchant, Store, StoreUser, User
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_owner(db):
|
||||
"""Create a user for soft-delete tests."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
user = User(
|
||||
email=f"sdowner_{uid}@test.com",
|
||||
username=f"sdowner_{uid}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_merchant(db, sd_owner):
|
||||
"""Create a merchant for soft-delete tests."""
|
||||
merchant = Merchant(
|
||||
name="SD Test Merchant",
|
||||
owner_user_id=sd_owner.id,
|
||||
contact_email=sd_owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_store(db, sd_merchant):
|
||||
"""Create a store for soft-delete tests."""
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
store = Store(
|
||||
merchant_id=sd_merchant.id,
|
||||
store_code=f"SDTEST_{uid.upper()}",
|
||||
subdomain=f"sdtest{uid}",
|
||||
name=f"SD Test Store {uid}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_member_user(db):
|
||||
"""Create another user to be a store member."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
user = User(
|
||||
email=f"sdmember_{uid}@test.com",
|
||||
username=f"sdmember_{uid}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="store_member",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_store_user(db, sd_store, sd_member_user):
|
||||
"""Create a StoreUser membership."""
|
||||
store_user = StoreUser(
|
||||
store_id=sd_store.id,
|
||||
user_id=sd_member_user.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(store_user)
|
||||
db.commit()
|
||||
db.refresh(store_user)
|
||||
return store_user
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SoftDeleteMixin basic behavior
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSoftDeleteBasic:
|
||||
"""Test soft_delete() utility function."""
|
||||
|
||||
def test_soft_delete_sets_fields(self, db, sd_owner, sd_store):
|
||||
"""soft_delete() sets deleted_at and deleted_by_id."""
|
||||
soft_delete(db, sd_store, deleted_by_id=sd_owner.id)
|
||||
db.commit()
|
||||
|
||||
# Query with include_deleted to see the record
|
||||
store = (
|
||||
db.query(Store)
|
||||
.execution_options(include_deleted=True)
|
||||
.filter(Store.id == sd_store.id)
|
||||
.first()
|
||||
)
|
||||
assert store is not None
|
||||
assert store.deleted_at is not None
|
||||
assert store.deleted_by_id == sd_owner.id
|
||||
|
||||
def test_soft_deleted_excluded_from_queries(self, db, sd_owner, sd_store):
|
||||
"""Soft-deleted records are automatically excluded from normal queries."""
|
||||
store_id = sd_store.id
|
||||
soft_delete(db, sd_store, deleted_by_id=sd_owner.id)
|
||||
db.commit()
|
||||
|
||||
result = db.query(Store).filter(Store.id == store_id).first()
|
||||
assert result is None
|
||||
|
||||
def test_soft_deleted_visible_with_include_deleted(self, db, sd_owner, sd_store):
|
||||
"""Soft-deleted records are visible with include_deleted=True."""
|
||||
store_id = sd_store.id
|
||||
soft_delete(db, sd_store, deleted_by_id=sd_owner.id)
|
||||
db.commit()
|
||||
|
||||
result = (
|
||||
db.query(Store)
|
||||
.execution_options(include_deleted=True)
|
||||
.filter(Store.id == store_id)
|
||||
.first()
|
||||
)
|
||||
assert result is not None
|
||||
assert result.id == store_id
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Restore
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRestore:
|
||||
"""Test restore() utility function."""
|
||||
|
||||
def test_restore_clears_deleted_fields(self, db, sd_owner, sd_store):
|
||||
"""restore() clears deleted_at and deleted_by_id."""
|
||||
soft_delete(db, sd_store, deleted_by_id=sd_owner.id)
|
||||
db.commit()
|
||||
|
||||
restored = restore(db, Store, sd_store.id, restored_by_id=sd_owner.id)
|
||||
db.commit()
|
||||
|
||||
assert restored.deleted_at is None
|
||||
assert restored.deleted_by_id is None
|
||||
|
||||
def test_restore_makes_record_visible(self, db, sd_owner, sd_store):
|
||||
"""After restore, record is visible in normal queries."""
|
||||
store_id = sd_store.id
|
||||
soft_delete(db, sd_store, deleted_by_id=sd_owner.id)
|
||||
db.commit()
|
||||
|
||||
restore(db, Store, store_id, restored_by_id=sd_owner.id)
|
||||
db.commit()
|
||||
|
||||
result = db.query(Store).filter(Store.id == store_id).first()
|
||||
assert result is not None
|
||||
|
||||
def test_restore_not_deleted_raises(self, db, sd_store):
|
||||
"""restore() raises ValueError if record is not deleted."""
|
||||
with pytest.raises(ValueError, match="is not deleted"):
|
||||
restore(db, Store, sd_store.id, restored_by_id=1)
|
||||
|
||||
def test_restore_not_found_raises(self, db):
|
||||
"""restore() raises ValueError if record doesn't exist."""
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
restore(db, Store, 99999, restored_by_id=1)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Cascade soft delete
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSoftDeleteCascade:
|
||||
"""Test soft_delete_cascade() utility function."""
|
||||
|
||||
def test_cascade_deletes_parent_and_children(
|
||||
self, db, sd_owner, sd_store, sd_store_user
|
||||
):
|
||||
"""soft_delete_cascade() deletes parent and its children."""
|
||||
count = soft_delete_cascade(
|
||||
db,
|
||||
sd_store,
|
||||
deleted_by_id=sd_owner.id,
|
||||
cascade_rels=[("store_users", [])],
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert count == 2 # store + store_user
|
||||
|
||||
# Both should be hidden from normal queries
|
||||
assert db.query(Store).filter(Store.id == sd_store.id).first() is None
|
||||
assert (
|
||||
db.query(StoreUser).filter(StoreUser.id == sd_store_user.id).first()
|
||||
is None
|
||||
)
|
||||
|
||||
# Both visible with include_deleted
|
||||
store = (
|
||||
db.query(Store)
|
||||
.execution_options(include_deleted=True)
|
||||
.filter(Store.id == sd_store.id)
|
||||
.first()
|
||||
)
|
||||
assert store is not None
|
||||
assert store.deleted_at is not None
|
||||
|
||||
su = (
|
||||
db.query(StoreUser)
|
||||
.execution_options(include_deleted=True)
|
||||
.filter(StoreUser.id == sd_store_user.id)
|
||||
.first()
|
||||
)
|
||||
assert su is not None
|
||||
assert su.deleted_at is not None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Partial unique indexes
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPartialUniqueIndexes:
|
||||
"""Test that unique constraints allow reuse after soft delete."""
|
||||
|
||||
def test_user_email_reusable_after_soft_delete(self, db):
|
||||
"""Soft-deleted user's email can be used by a new user."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
email = f"reuse_{uuid.uuid4().hex[:8]}@test.com"
|
||||
username = f"reuse_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
user1 = User(
|
||||
email=email,
|
||||
username=username,
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="store_member",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user1)
|
||||
db.commit()
|
||||
|
||||
# Soft-delete user1
|
||||
soft_delete(db, user1, deleted_by_id=None)
|
||||
db.commit()
|
||||
|
||||
# Create user2 with same email — should succeed
|
||||
user2 = User(
|
||||
email=email,
|
||||
username=f"reuse2_{uuid.uuid4().hex[:8]}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="store_member",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user2)
|
||||
db.commit()
|
||||
db.refresh(user2)
|
||||
|
||||
assert user2.id is not None
|
||||
assert user2.email == email
|
||||
Reference in New Issue
Block a user