feat: add SQL query tool, platform debug, loyalty settings, and multi-module improvements
Some checks failed
Some checks failed
- Add admin SQL query tool with saved queries, schema explorer presets, and collapsible category sections (dev_tools module) - Add platform debug tool for admin diagnostics - Add loyalty settings page with owner-only access control - Fix loyalty settings owner check (use currentUser instead of window.__userData) - Replace HTTPException with AuthorizationException in loyalty routes - Expand loyalty module with PIN service, Apple Wallet, program management - Improve store login with platform detection and multi-platform support - Update billing feature gates and subscription services - Add store platform sync improvements and remove is_primary column - Add unit tests for loyalty (PIN, points, stamps, program services) - Update i18n translations across dev_tools locales Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -234,12 +234,11 @@ class AppleWalletService:
|
||||
"pass.json": json.dumps(pass_data).encode("utf-8"),
|
||||
}
|
||||
|
||||
# Add placeholder images (in production, these would be actual images)
|
||||
# For now, we'll skip images and use the pass.json only
|
||||
# pass_files["icon.png"] = self._get_icon_bytes(program)
|
||||
# pass_files["icon@2x.png"] = self._get_icon_bytes(program, scale=2)
|
||||
# pass_files["logo.png"] = self._get_logo_bytes(program)
|
||||
# pass_files["logo@2x.png"] = self._get_logo_bytes(program, scale=2)
|
||||
# Add pass images (icon and logo)
|
||||
pass_files["icon.png"] = self._get_icon_bytes(program)
|
||||
pass_files["icon@2x.png"] = self._get_icon_bytes(program, scale=2)
|
||||
pass_files["logo.png"] = self._get_logo_bytes(program)
|
||||
pass_files["logo@2x.png"] = self._get_logo_bytes(program, scale=2)
|
||||
|
||||
# Create manifest
|
||||
manifest = {}
|
||||
@@ -377,6 +376,105 @@ class AppleWalletService:
|
||||
|
||||
return pass_data
|
||||
|
||||
def _get_icon_bytes(self, program: LoyaltyProgram, scale: int = 1) -> bytes:
|
||||
"""
|
||||
Generate icon image for Apple Wallet pass.
|
||||
|
||||
Apple icon dimensions: 29x29 (@1x), 58x58 (@2x).
|
||||
Uses program logo if available, otherwise generates a colored square
|
||||
with the program initial.
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
size = 29 * scale
|
||||
|
||||
if program.logo_url:
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(program.logo_url, timeout=10, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
img = Image.open(io.BytesIO(resp.content))
|
||||
img = img.convert("RGBA")
|
||||
img = img.resize((size, size), Image.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
except Exception:
|
||||
logger.warning("Failed to fetch logo for icon, using fallback")
|
||||
|
||||
# Fallback: colored square with initial
|
||||
hex_color = (program.card_color or "#4F46E5").lstrip("#")
|
||||
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
|
||||
|
||||
img = Image.new("RGBA", (size, size), (r, g, b, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
initial = (program.display_name or "L")[0].upper()
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", size // 2)
|
||||
except OSError:
|
||||
font = ImageFont.load_default()
|
||||
bbox = draw.textbbox((0, 0), initial, font=font)
|
||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
draw.text(((size - tw) / 2, (size - th) / 2 - bbox[1]), initial, fill=(255, 255, 255, 255), font=font)
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
def _get_logo_bytes(self, program: LoyaltyProgram, scale: int = 1) -> bytes:
|
||||
"""
|
||||
Generate logo image for Apple Wallet pass.
|
||||
|
||||
Apple logo dimensions: 160x50 (@1x), 320x100 (@2x).
|
||||
Uses program logo if available, otherwise generates a colored rectangle
|
||||
with the program initial.
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
width, height = 160 * scale, 50 * scale
|
||||
|
||||
if program.logo_url:
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(program.logo_url, timeout=10, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
img = Image.open(io.BytesIO(resp.content))
|
||||
img = img.convert("RGBA")
|
||||
# Fit within dimensions preserving aspect ratio
|
||||
img.thumbnail((width, height), Image.LANCZOS)
|
||||
# Center on transparent canvas
|
||||
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
x = (width - img.width) // 2
|
||||
y = (height - img.height) // 2
|
||||
canvas.paste(img, (x, y))
|
||||
buf = io.BytesIO()
|
||||
canvas.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
except Exception:
|
||||
logger.warning("Failed to fetch logo for pass logo, using fallback")
|
||||
|
||||
# Fallback: colored rectangle with initial
|
||||
hex_color = (program.card_color or "#4F46E5").lstrip("#")
|
||||
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
|
||||
|
||||
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
initial = (program.display_name or "L")[0].upper()
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", height // 2)
|
||||
except OSError:
|
||||
font = ImageFont.load_default()
|
||||
bbox = draw.textbbox((0, 0), initial, font=font)
|
||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
# Draw initial centered
|
||||
draw.text(((width - tw) / 2, (height - th) / 2 - bbox[1]), initial, fill=(r, g, b, 255), font=font)
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
def _hex_to_rgb(self, hex_color: str) -> str:
|
||||
"""Convert hex color to RGB format for Apple Wallet."""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
|
||||
@@ -164,7 +164,34 @@ class LoyaltyFeatureProvider:
|
||||
db: Session,
|
||||
store_id: int,
|
||||
) -> list[FeatureUsage]:
|
||||
return []
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.modules.loyalty.models import LoyaltyCard, StaffPin
|
||||
|
||||
active_cards = (
|
||||
db.query(func.count(LoyaltyCard.id))
|
||||
.filter(
|
||||
LoyaltyCard.enrolled_at_store_id == store_id,
|
||||
LoyaltyCard.is_active == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
active_pins = (
|
||||
db.query(func.count(StaffPin.id))
|
||||
.filter(
|
||||
StaffPin.store_id == store_id,
|
||||
StaffPin.is_active == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return [
|
||||
FeatureUsage(feature_code="loyalty_cards", current_usage=active_cards),
|
||||
FeatureUsage(feature_code="loyalty_staff_pins", current_usage=active_pins),
|
||||
]
|
||||
|
||||
def get_merchant_usage(
|
||||
self,
|
||||
@@ -172,7 +199,58 @@ class LoyaltyFeatureProvider:
|
||||
merchant_id: int,
|
||||
platform_id: int,
|
||||
) -> list[FeatureUsage]:
|
||||
return []
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.modules.loyalty.models import (
|
||||
AppleDeviceRegistration,
|
||||
LoyaltyCard,
|
||||
StaffPin,
|
||||
)
|
||||
|
||||
active_cards = (
|
||||
db.query(func.count(LoyaltyCard.id))
|
||||
.filter(
|
||||
LoyaltyCard.merchant_id == merchant_id,
|
||||
LoyaltyCard.is_active == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
active_pins = (
|
||||
db.query(func.count(StaffPin.id))
|
||||
.filter(
|
||||
StaffPin.merchant_id == merchant_id,
|
||||
StaffPin.is_active == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
apple_registrations = (
|
||||
db.query(func.count(AppleDeviceRegistration.id))
|
||||
.join(LoyaltyCard)
|
||||
.filter(LoyaltyCard.merchant_id == merchant_id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
google_wallets = (
|
||||
db.query(func.count(LoyaltyCard.id))
|
||||
.filter(
|
||||
LoyaltyCard.merchant_id == merchant_id,
|
||||
LoyaltyCard.google_object_id.isnot(None),
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return [
|
||||
FeatureUsage(feature_code="loyalty_cards", current_usage=active_cards),
|
||||
FeatureUsage(feature_code="loyalty_staff_pins", current_usage=active_pins),
|
||||
FeatureUsage(feature_code="loyalty_apple_wallet", current_usage=apple_registrations),
|
||||
FeatureUsage(feature_code="loyalty_google_wallet", current_usage=google_wallets),
|
||||
]
|
||||
|
||||
|
||||
# Singleton instance for module registration
|
||||
|
||||
@@ -29,7 +29,7 @@ class LoyaltyOnboardingProvider:
|
||||
title_key="loyalty.onboarding.create_program.title",
|
||||
description_key="loyalty.onboarding.create_program.description",
|
||||
icon="gift",
|
||||
route_template="/store/{store_code}/loyalty/programs",
|
||||
route_template="/store/{store_code}/loyalty/settings",
|
||||
order=300,
|
||||
category="loyalty",
|
||||
),
|
||||
@@ -49,13 +49,13 @@ class LoyaltyOnboardingProvider:
|
||||
if not store:
|
||||
return False
|
||||
|
||||
count = (
|
||||
exists = (
|
||||
db.query(LoyaltyProgram)
|
||||
.filter(LoyaltyProgram.merchant_id == store.merchant_id)
|
||||
.limit(1)
|
||||
.count()
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
return count > 0
|
||||
return exists
|
||||
|
||||
|
||||
loyalty_onboarding_provider = LoyaltyOnboardingProvider()
|
||||
|
||||
@@ -291,9 +291,8 @@ class PinService:
|
||||
|
||||
return pin
|
||||
|
||||
# No match found - record failed attempt on all unlocked PINs
|
||||
# This is a simplified approach; in production you might want to
|
||||
# track which PIN was attempted based on additional context
|
||||
# No match found - record failed attempt on the first unlocked PIN only
|
||||
# This limits blast radius to 1 lockout instead of N
|
||||
locked_pin = None
|
||||
remaining = None
|
||||
|
||||
@@ -306,7 +305,8 @@ class PinService:
|
||||
if is_now_locked:
|
||||
locked_pin = pin
|
||||
else:
|
||||
remaining = pin.remaining_attempts
|
||||
remaining = max(0, config.pin_max_failed_attempts - pin.failed_attempts)
|
||||
break # Only record on the first unlocked PIN
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -663,6 +663,78 @@ class ProgramService:
|
||||
avg_stamps = total_stamps_issued / total_cards if total_cards > 0 else 0
|
||||
avg_points = total_points_issued / total_cards if total_cards > 0 else 0
|
||||
|
||||
# New this month (cards created since month start)
|
||||
new_this_month = (
|
||||
db.query(func.count(LoyaltyCard.id))
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyCard.created_at >= month_start,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Points activity this month
|
||||
points_this_month = (
|
||||
db.query(func.sum(LoyaltyTransaction.points_delta))
|
||||
.join(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyTransaction.points_delta > 0,
|
||||
LoyaltyTransaction.transaction_at >= month_start,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
points_redeemed_this_month = (
|
||||
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
|
||||
.join(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyTransaction.points_delta < 0,
|
||||
LoyaltyTransaction.transaction_at >= month_start,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# 30-day transaction metrics
|
||||
transactions_30d = (
|
||||
db.query(func.count(LoyaltyTransaction.id))
|
||||
.join(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyTransaction.transaction_at >= thirty_days_ago,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
points_issued_30d = (
|
||||
db.query(func.sum(LoyaltyTransaction.points_delta))
|
||||
.join(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyTransaction.points_delta > 0,
|
||||
LoyaltyTransaction.transaction_at >= thirty_days_ago,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
points_redeemed_30d = (
|
||||
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
|
||||
.join(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyTransaction.points_delta < 0,
|
||||
LoyaltyTransaction.transaction_at >= thirty_days_ago,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Estimated liability (unredeemed value)
|
||||
current_stamps = (
|
||||
db.query(func.sum(LoyaltyCard.stamp_count))
|
||||
@@ -677,6 +749,7 @@ class ProgramService:
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
total_points_balance = current_points
|
||||
# Rough estimate: assume 100 points = €1
|
||||
points_value_cents = current_points // 100 * 100
|
||||
|
||||
@@ -684,18 +757,28 @@ class ProgramService:
|
||||
(current_stamps * stamp_value // program.stamps_target) + points_value_cents
|
||||
)
|
||||
|
||||
avg_points_per_member = round(current_points / active_cards, 2) if active_cards > 0 else 0
|
||||
|
||||
return {
|
||||
"total_cards": total_cards,
|
||||
"active_cards": active_cards,
|
||||
"new_this_month": new_this_month,
|
||||
"total_stamps_issued": total_stamps_issued,
|
||||
"total_stamps_redeemed": total_stamps_redeemed,
|
||||
"stamps_this_month": stamps_this_month,
|
||||
"redemptions_this_month": redemptions_this_month,
|
||||
"total_points_issued": total_points_issued,
|
||||
"total_points_redeemed": total_points_redeemed,
|
||||
"total_points_balance": total_points_balance,
|
||||
"points_this_month": points_this_month,
|
||||
"points_redeemed_this_month": points_redeemed_this_month,
|
||||
"cards_with_activity_30d": cards_with_activity_30d,
|
||||
"average_stamps_per_card": round(avg_stamps, 2),
|
||||
"average_points_per_card": round(avg_points, 2),
|
||||
"avg_points_per_member": avg_points_per_member,
|
||||
"transactions_30d": transactions_30d,
|
||||
"points_issued_30d": points_issued_30d,
|
||||
"points_redeemed_30d": points_redeemed_30d,
|
||||
"estimated_liability_cents": estimated_liability,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user