""" Soon Guan Delicacies - Views ✅ 优化版本 - 安全性、性能和代码质量改进 """ import json import csv import logging from decimal import Decimal from datetime import timedelta from collections import defaultdict from django.shortcuts import render, get_object_or_404, redirect from django.http import JsonResponse, FileResponse, Http404, HttpResponse from django.contrib import messages from django.contrib.auth import login, authenticate, logout from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User from django.views.decorators.http import require_POST, require_GET from django.db import transaction from django.db.models import Sum, Count, F, Q, Case, When, Value, IntegerField from django.urls import reverse from django.utils import timezone from django.core.paginator import Paginator from .models import ( Product, Category, Announcement, Order, OrderItem, OrderRound, Invoice, ChatMessage, UserProfile, DiscountCode, EmailVerification ) from .forms import AddToCartForm, CheckoutForm, UserRegisterForm from .cart import Cart logger = logging.getLogger(__name__) # ========================= # Constants # ========================= MAX_ORDERS_PER_PAGE = 100 MAX_CHAT_CONVERSATIONS = 50 CHAT_COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days # ========================= # Order Round Helpers # ========================= def get_current_order_round(): """ Get the currently active order round. Returns: OrderRound or None: Active round if today is within date window """ try: return OrderRound.current() except Exception as e: logger.exception(f"Error getting current order round: {e}") return None def ordering_is_open(): """ Check if ordering is currently open. Returns: bool: True if there's an active order round """ return get_current_order_round() is not None # ========================= # Store Front Views # ========================= def product_list(request): """ Display product list with filtering and search. Query Parameters: - category: Category ID or 'all' - q: Search query - partial: Return partial HTML for AJAX requests """ categories = Category.objects.all() announcement = Announcement.objects.filter(is_active=True).first() category_id = (request.GET.get('category') or 'all').strip() q = (request.GET.get('q') or '').strip() # Base queryset with select_related products = Product.objects.filter(is_active=True).select_related('category') # Category filter - 验证输入 if category_id and category_id != 'all': try: cat_id = int(category_id) if cat_id > 0: products = products.filter(category_id=cat_id) except (TypeError, ValueError): pass # Search filter - 限制搜索长度 if q: q = q[:100] # ✅ 限制搜索词长度 products = products.filter( Q(name__icontains=q) | Q(description__icontains=q) ) # Sort by availability and stock status_rank = Case( When(stock_quantity__gt=0, then=Value(0)), When(stock_quantity__lte=0, then=Value(1)), default=Value(2), output_field=IntegerField(), ) products = products.annotate(_status_rank=status_rank).order_by('_status_rank', 'category', 'name') cart = Cart(request) # Check if partial response needed (AJAX) partial = (request.GET.get('partial') == '1') or ( request.headers.get('X-Requested-With') == 'XMLHttpRequest' ) # ✅ 获取当前订单周期 order_round = get_current_order_round() is_ordering_open = order_round is not None # ✅ 准备模板变量 announcement_text = None announcement_icon = "📢" if announcement: announcement_text = announcement.content announcement_icon = announcement.icon or "📢" return render(request, "soonguan_store/product_list.html", { "products": products, "categories": categories, # ✅ 修复: 传递正确的变量名给模板 "announcement": announcement, "announcement_text": announcement_text, "announcement_icon": announcement_icon, "cart_count": len(cart), "partial": partial, # ✅ 修复: 传递 order_status 给模板 "ordering_open": is_ordering_open, "order_status": "open" if is_ordering_open else "closed", "order_round": order_round, }) def product_detail(request, pk): """Display product detail page.""" product = get_object_or_404(Product, pk=pk, is_active=True) cart = Cart(request) return render(request, "soonguan_store/product_detail.html", { "product": product, "cart_count": len(cart), }) # ========================= # Shopping Cart Views # ========================= @require_POST def cart_add(request, product_id): """ Add product to shopping cart. POST Parameters: - quantity: Number of items to add - from_detail: Optional, indicates source page """ product = get_object_or_404(Product, pk=product_id, is_active=True) form = AddToCartForm(request.POST) cart = Cart(request) # Check if ordering is open if not ordering_is_open(): msg = 'Ordering is currently closed. Please come back during the ordering window.' if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return JsonResponse({'success': False, 'error': msg}, status=403) messages.error(request, msg) return redirect('product_list') if form.is_valid(): qty = form.cleaned_data["quantity"] # ✅ 修复: 检查购物车已有数量 + 新增数量是否超过库存 existing_qty = cart.get_quantity(product.id) total_qty = existing_qty + qty if product.stock_quantity < total_qty: available = max(0, product.stock_quantity - existing_qty) if available <= 0: msg = f'Not enough stock. You already have {existing_qty} in your cart.' else: msg = f'Not enough stock. Only {available} more available (you have {existing_qty} in cart).' if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return JsonResponse({'success': False, 'error': msg}, status=400) messages.error(request, msg) return redirect('product_list') cart.add(product.id, qty=qty, override=False) # AJAX response if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return JsonResponse({ 'success': True, 'cart_count': len(cart), 'message': f'Added {product.name} x {qty} to cart' }) messages.success(request, f'Added {product.name} to cart!') # Redirect based on source if 'from_detail' in request.POST: return redirect('product_detail', pk=product_id) return redirect('product_list') def cart_detail(request): """Display shopping cart.""" cart = Cart(request) items = cart.items() total = sum((i["line_total"] for i in items), Decimal("0.00")) return render(request, "soonguan_store/cart_detail.html", { "cart_items": items, "cart_total": total, "cart_count": len(cart), }) @require_POST def cart_update(request, product_id): """ Update product quantity in cart. POST Parameters: - quantity: New quantity """ cart = Cart(request) form = AddToCartForm(request.POST) if form.is_valid(): qty = form.cleaned_data["quantity"] # ✅ 修复: 检查库存 try: product = Product.objects.get(pk=product_id, is_active=True) if product.stock_quantity < qty: qty = product.stock_quantity # 限制为最大可用库存 if qty <= 0: cart.remove(product_id) if request.headers.get("X-Requested-With") == "XMLHttpRequest": return JsonResponse({ "success": False, "error": "Product is out of stock", "removed": True }, status=400) messages.error(request, "Product is out of stock") return redirect("cart_detail") except Product.DoesNotExist: cart.remove(product_id) if request.headers.get("X-Requested-With") == "XMLHttpRequest": return JsonResponse({ "success": False, "error": "Product not found", "removed": True }, status=404) return redirect("cart_detail") cart.add(product_id, qty=qty, override=True) # Calculate totals items = cart.items() cart_total = sum((i["line_total"] for i in items), Decimal("0.00")) # Find line total for this product pid = int(product_id) line_total = Decimal("0.00") for item in items: if item["product"].id == pid: line_total = item["line_total"] break # AJAX response if request.headers.get("X-Requested-With") == "XMLHttpRequest": return JsonResponse({ "success": True, "product_id": pid, "qty": qty, "line_total": str(line_total), "cart_total": str(cart_total), "cart_count": len(cart), }) return redirect("cart_detail") @require_POST def cart_remove(request, product_id): """Remove product from cart.""" cart = Cart(request) cart.remove(product_id) messages.success(request, 'Item removed from cart') return redirect("cart_detail") # ========================= # Checkout & Orders # ========================= @transaction.atomic def checkout(request): """ Process checkout and create order. ✅ 支持折扣码功能 """ cart = Cart(request) # Check cart not empty if len(cart) == 0: messages.warning(request, 'Your cart is empty') return redirect("cart_detail") # Check ordering is open if not ordering_is_open(): messages.error(request, 'Ordering is currently closed') return redirect("cart_detail") # 计算购物车总额 cart_items = cart.items() cart_total = sum((i["line_total"] for i in cart_items), Decimal("0.00")) # ✅ 处理折扣码 applied_discount = None discount_amount = Decimal("0.00") final_total = cart_total assigned_discount = None # 检查用户是否有专属折扣码 (gracefully handle if table doesn't exist yet) try: if request.user.is_authenticated: now = timezone.now() assigned_discount = DiscountCode.objects.filter( assigned_user=request.user, is_active=True, valid_from__lte=now, valid_until__gte=now ).first() # 从 URL 参数或 POST 获取折扣码 discount_code_str = request.GET.get('discount') or request.POST.get('discount_code', '') discount_code_str = discount_code_str.strip().upper() if discount_code_str: try: discount = DiscountCode.objects.get(code__iexact=discount_code_str) user = request.user if request.user.is_authenticated else None is_valid, error_msg = discount.is_valid(user=user, order_amount=cart_total) if is_valid: applied_discount = discount discount_amount = discount.calculate_discount(cart_total) final_total = cart_total - discount_amount else: messages.warning(request, error_msg) except DiscountCode.DoesNotExist: messages.warning(request, 'Invalid discount code') except Exception as e: # DiscountCode table might not exist yet - skip discount functionality logger.warning(f"Discount code lookup failed (table may not exist): {e}") if request.method == "POST": form = CheckoutForm(request.POST) if form.is_valid(): # ✅ 修复: 在创建订单前检查库存 stock_errors = [] for item in cart_items: product = item["product"] # 重新获取最新库存 try: current_product = Product.objects.get(pk=product.id, is_active=True) if current_product.stock_quantity < item["qty"]: if current_product.stock_quantity <= 0: stock_errors.append(f'{product.name} is now out of stock') else: stock_errors.append( f'{product.name} only has {current_product.stock_quantity} left (you requested {item["qty"]})' ) except Product.DoesNotExist: stock_errors.append(f'{product.name} is no longer available') if stock_errors: for error in stock_errors: messages.error(request, error) messages.warning(request, 'Please update your cart and try again.') return redirect('cart_detail') # Get current order round current_round = get_current_order_round() # Create order (with or without discount support) order_kwargs = { 'user': request.user if (request.user.is_authenticated and not request.user.is_staff) else None, 'customer_name': form.cleaned_data["customer_name"], 'phone': form.cleaned_data["phone"], 'email': form.cleaned_data.get("email") or "", 'address': form.cleaned_data.get("address") or "", 'notes': form.cleaned_data.get("notes") or "", 'order_round': current_round, } # Add discount fields if they exist in the model try: if applied_discount: order_kwargs['discount_code'] = applied_discount order_kwargs['discount_amount'] = discount_amount except Exception: pass # Discount fields may not exist yet order = Order.objects.create(**order_kwargs) # ✅ 批量创建订单项目 order_items = [] for item in cart_items: product = item["product"] order_items.append(OrderItem( order=order, product=product, product_name=product.name, unit_price=item["unit_price"], quantity=item["qty"], )) OrderItem.objects.bulk_create(order_items) # ✅ 修复: 扣减库存 for item in cart_items: try: Product.objects.filter(pk=item["product"].id).update( stock_quantity=F('stock_quantity') - item["qty"] ) except Exception as e: logger.warning(f"Failed to deduct stock for product {item['product'].id}: {e}") # Calculate total order.recalc_total() # ✅ 记录折扣码使用 (if discount was applied) try: if applied_discount: user = request.user if request.user.is_authenticated else None applied_discount.use(user=user, order=order) except Exception as e: logger.warning(f"Failed to record discount usage: {e}") # Clear cart cart.clear() logger.info(f"Order {order.order_no} created successfully") messages.success(request, f'Order {order.order_no} created successfully!') return redirect('order_success', order_no=order.order_no) else: # Pre-fill form for logged-in users initial = {} if request.user.is_authenticated and hasattr(request.user, 'profile'): profile = request.user.profile initial = { 'customer_name': f"{request.user.first_name} {request.user.last_name}".strip() or request.user.username, 'phone': profile.phone, 'email': request.user.email or profile.email, 'address': profile.address, } form = CheckoutForm(initial=initial) return render(request, "soonguan_store/checkout.html", { "form": form, "cart_items": cart_items, "cart_total": cart_total, "cart_count": len(cart), "applied_discount": applied_discount, "discount_amount": discount_amount, "final_total": final_total, "assigned_discount": assigned_discount, }) def order_success(request, order_no): """Display order success page.""" order = get_object_or_404(Order, order_no=order_no) # ✅ 改进安全检查 if request.user.is_authenticated: # Staff 可以查看所有订单 if request.user.is_staff: pass # 普通用户只能查看自己的订单 elif order.user != request.user: raise Http404("Order not found") # 未登录用户可以查看刚创建的订单(通过 session 验证更安全,这里保持简单) return render(request, "soonguan_store/order_success.html", { "order": order, }) # ========================= # User Authentication # ========================= def send_verification_email(user, verification, request): """ 发送验证邮件 """ from django.core.mail import send_mail from django.template.loader import render_to_string from django.conf import settings # 构建验证链接 verify_url = request.build_absolute_uri( reverse('verify_email', kwargs={'token': str(verification.token)}) ) subject = 'Verify your email - Soon Guan Delicacies' # HTML 邮件内容 html_message = render_to_string('soonguan_store/verification_email.html', { 'user': user, 'verify_url': verify_url, 'expires_hours': 24, }) # 纯文本内容 plain_message = f""" Hi {user.first_name or user.username}, Thank you for registering at Soon Guan Delicacies! Please verify your email address by clicking the link below: {verify_url} This link will expire in 24 hours. If you didn't create an account, please ignore this email. Best regards, Soon Guan Delicacies Team """ try: send_mail( subject=subject, message=plain_message, from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=[user.email], html_message=html_message, fail_silently=False, ) return True except Exception as e: logger.exception(f"Failed to send verification email to {user.email}: {e}") return False def register_view(request): """ ✅ 改进的用户注册 - 邮件发送失败时提供降级方案 """ if request.user.is_authenticated: return redirect('product_list') if request.method == 'POST': form = UserRegisterForm(request.POST) if form.is_valid(): try: with transaction.atomic(): # 创建用户但设为未激活 user = form.save(commit=False) user.is_active = False user.save() # 创建验证记录 verification = EmailVerification.objects.create(user=user) # ✅ 尝试发送验证邮件 email_sent = send_verification_email(user, verification, request) if email_sent: # 邮件发送成功 messages.success( request, f'Account created! Please check your email ({user.email}) to verify your account.' ) return render(request, 'soonguan_store/register_success.html', { 'email': user.email }) else: # ✅ 邮件发送失败 - 提供降级方案 # 直接激活用户(临时方案) user.is_active = True user.save(update_fields=['is_active']) verification.delete() # 自动登录 login(request, user) messages.warning( request, '⚠️ Account created successfully! Email verification is temporarily unavailable, ' 'so your account has been activated automatically.' ) return redirect('product_list') except Exception as e: logger.exception(f"Registration error: {e}") messages.error( request, 'Registration failed due to a system error. Please try again or contact support.' ) else: form = UserRegisterForm() return render(request, 'soonguan_store/register.html', {'form': form}) def verify_email(request, token): """ 验证邮箱 """ try: verification = EmailVerification.objects.get(token=token) except EmailVerification.DoesNotExist: messages.error(request, 'Invalid verification link.') return render(request, 'soonguan_store/verify_email.html', { 'success': False, 'error': 'invalid_link' }) # 检查是否已验证 if verification.is_verified: messages.info(request, 'Your email has already been verified. Please login.') return redirect('login') # 检查是否过期 if verification.is_expired(): messages.error(request, 'Verification link has expired. Please request a new one.') return render(request, 'soonguan_store/verify_email.html', { 'success': False, 'error': 'expired', 'email': verification.user.email }) # 验证成功 verification.verify() # 激活用户 user = verification.user user.is_active = True user.save(update_fields=['is_active']) # 自动登录 login(request, user) messages.success(request, f'Email verified successfully! Welcome, {user.first_name or user.username}!') return render(request, 'soonguan_store/verify_email.html', { 'success': True, 'user': user }) def resend_verification(request): """ 重新发送验证邮件 """ if request.method == 'POST': email = request.POST.get('email', '').strip().lower() if not email: messages.error(request, 'Please enter your email address.') return redirect('login') try: user = User.objects.get(email__iexact=email) # 检查用户是否已激活 if user.is_active: messages.info(request, 'Your account is already verified. Please login.') return redirect('login') # 获取或创建验证记录 verification, created = EmailVerification.objects.get_or_create(user=user) if not created: # 重新生成令牌 verification.regenerate_token() # 发送邮件 if send_verification_email(user, verification, request): messages.success(request, f'Verification email sent to {email}. Please check your inbox.') else: messages.error(request, 'Failed to send verification email. Please try again later.') except User.DoesNotExist: # 为安全起见,不透露用户是否存在 messages.success(request, f'If an account exists with {email}, a verification email has been sent.') return redirect('login') def login_view(request): """ ✅ 改进的登录视图 - 支持邮箱和用户名登录 """ if request.user.is_authenticated: return redirect('product_list') if request.method == 'POST': identifier = request.POST.get('identifier', '').strip() password = request.POST.get('password', '') if not identifier or not password: messages.error(request, 'Please provide both email/username and password') return render(request, 'soonguan_store/login.html') # ✅ 尝试用邮箱或用户名登录 user = None # 先尝试用用户名登录 user = authenticate(request, username=identifier, password=password) # 如果失败,尝试用邮箱登录 if not user and '@' in identifier: try: username = User.objects.get(email__iexact=identifier).username user = authenticate(request, username=username, password=password) except User.DoesNotExist: pass if user is not None: # ✅ 检查账户是否已激活 if not user.is_active: messages.error( request, '⚠️ Your account is not activated yet. Please check your email for the verification link.' ) return render(request, 'soonguan_store/login.html') login(request, user) messages.success(request, f'Welcome back, {user.first_name or user.username}!') # Redirect to next parameter or dashboard for staff next_url = request.GET.get('next') if next_url: return redirect(next_url) elif user.is_staff: return redirect('myob_dashboard') else: return redirect('product_list') else: messages.error(request, '❌ Invalid email/username or password') return render(request, 'soonguan_store/login.html') def logout_view(request): """User logout.""" logout(request) messages.success(request, 'You have been logged out') return redirect('product_list') # ========================= # Chat Functions # ========================= def chat_get_or_create_session(request): """ Get or create chat session ID. Returns: str: Session ID from cookie or newly generated """ import uuid as uuid_module session_id = request.COOKIES.get('chat_session_id') if not session_id: session_id = f"chat_{uuid_module.uuid4().hex[:16]}" return session_id @require_POST def chat_send_message(request): """ Send a chat message from customer. POST Parameters: - message: Message content Returns: JSON: {success, message_id, created_at} """ try: data = json.loads(request.body) message_text = (data.get('message') or '').strip() # ✅ 验证消息长度 if not message_text: return JsonResponse({'success': False, 'error': 'Message cannot be empty'}, status=400) if len(message_text) > 2000: return JsonResponse({'success': False, 'error': 'Message too long'}, status=400) # Get or create session session_id = chat_get_or_create_session(request) # Create message chat_msg = ChatMessage.objects.create( session_id=session_id, sender_type='customer', message=message_text ) response = JsonResponse({ 'success': True, 'message_id': chat_msg.id, 'created_at': chat_msg.created_at.isoformat() }) # Set session cookie response.set_cookie( 'chat_session_id', session_id, max_age=CHAT_COOKIE_MAX_AGE, httponly=True, samesite='Lax' ) return response except json.JSONDecodeError: return JsonResponse({'success': False, 'error': 'Invalid JSON'}, status=400) except Exception as e: logger.exception(f"Error sending chat message: {e}") return JsonResponse({'success': False, 'error': 'Server error'}, status=500) def chat_get_messages(request): """ Get chat messages for current session. Query Parameters: - last_id: Get messages after this ID - limit: Max messages to return (default 50) Returns: JSON: {success, messages: [{id, sender_type, message, created_at}]} """ try: session_id = chat_get_or_create_session(request) # ✅ 安全地解析参数 try: last_id = int(request.GET.get('last_id', 0)) except ValueError: last_id = 0 try: limit = min(int(request.GET.get('limit', 50)), 100) except ValueError: limit = 50 # Get messages chat_messages = ChatMessage.objects.filter( session_id=session_id, id__gt=last_id ).order_by('created_at')[:limit] messages_data = [{ 'id': msg.id, 'sender_type': msg.sender_type, 'message': msg.message, 'created_at': msg.created_at.isoformat() } for msg in chat_messages] response = JsonResponse({ 'success': True, 'messages': messages_data }) # Set session cookie response.set_cookie( 'chat_session_id', session_id, max_age=CHAT_COOKIE_MAX_AGE, httponly=True, samesite='Lax' ) return response except Exception as e: logger.exception(f"Error getting chat messages: {e}") return JsonResponse({'success': False, 'error': 'Server error'}, status=500) # ========================= # Admin Chat Views # ========================= @login_required(login_url='login') def chat_admin_page(request): """Display admin chat interface.""" if not request.user.is_staff: return redirect('product_list') return render(request, 'soonguan_store/chat_admin.html') @login_required(login_url='login') def chat_admin_conversations_api(request): """ Get list of chat conversations for admin. Returns: JSON: {success, conversations: [{session_id, last_message, last_time, unread_count}]} """ if not request.user.is_staff: return JsonResponse({'success': False, 'error': 'Unauthorized'}, status=403) try: # ✅ 优化查询 - 使用子查询 from django.db.models import Max, Subquery, OuterRef # Get distinct sessions with latest message sessions = ChatMessage.objects.values('session_id').annotate( last_time=Max('created_at'), message_count=Count('id') ).order_by('-last_time')[:MAX_CHAT_CONVERSATIONS] conversations = [] for session in sessions: session_id = session['session_id'] # Get last message last_msg = ChatMessage.objects.filter( session_id=session_id ).order_by('-created_at').first() # ✅ 只计算未读的客户消息 unread = ChatMessage.objects.filter( session_id=session_id, sender_type='customer', is_read=False ).count() conversations.append({ 'session_id': session_id, 'last_message': last_msg.message[:100] if last_msg else '', # ✅ 限制长度 'last_time': session['last_time'].isoformat(), 'message_count': session['message_count'], 'unread_count': unread, }) return JsonResponse({ 'success': True, 'conversations': conversations }) except Exception as e: logger.exception(f"Error getting chat conversations: {e}") return JsonResponse({'success': False, 'error': 'Server error'}, status=500) @login_required(login_url='login') @require_GET def chat_admin_get_messages(request, session_id): """ ✅ 管理员获取指定会话的消息 GET Parameters: - last_id: 获取此 ID 之后的消息 (默认 0) - limit: 最大返回数量 (默认 50, 最大 200) - mark_read: 是否标记为已读 (默认 1) Returns: JSON: { success: bool, messages: [{id, sender_type, message, created_at, is_read}] } """ if not request.user.is_staff: return JsonResponse({'success': False, 'error': 'Unauthorized'}, status=403) try: # 安全解析参数 try: last_id = int(request.GET.get('last_id', 0)) except ValueError: last_id = 0 try: limit = min(int(request.GET.get('limit', 50)), 200) except ValueError: limit = 50 mark_read = request.GET.get('mark_read', '1') == '1' # 获取消息 messages_qs = ChatMessage.objects.filter( session_id=session_id, id__gt=last_id ).order_by('created_at')[:limit] messages_data = [{ 'id': msg.id, 'sender_type': msg.sender_type, 'message': msg.message, 'created_at': msg.created_at.isoformat(), 'is_read': msg.is_read } for msg in messages_qs] # 标记为已读 if mark_read: ChatMessage.objects.filter( session_id=session_id, sender_type='customer', is_read=False ).update(is_read=True) return JsonResponse({ 'success': True, 'messages': messages_data }) except Exception as e: logger.exception(f"Error getting admin chat messages: {e}") return JsonResponse({'success': False, 'error': 'Server error'}, status=500) @require_POST @login_required(login_url='login') def chat_admin_send(request, session_id): """ Send message as admin to specific session. POST Parameters: - message: Message content Returns: JSON: {success, message_id} """ if not request.user.is_staff: return JsonResponse({'success': False, 'error': 'Unauthorized'}, status=403) try: data = json.loads(request.body) message_text = (data.get('message') or '').strip() if not message_text: return JsonResponse({'success': False, 'error': 'Message cannot be empty'}, status=400) if len(message_text) > 2000: return JsonResponse({'success': False, 'error': 'Message too long'}, status=400) # Create admin message chat_msg = ChatMessage.objects.create( session_id=session_id, sender_type='admin', message=message_text ) # ✅ 标记该会话的客户消息为已读 ChatMessage.objects.filter( session_id=session_id, sender_type='customer', is_read=False ).update(is_read=True) return JsonResponse({ 'success': True, 'message_id': chat_msg.id, 'created_at': chat_msg.created_at.isoformat() }) except json.JSONDecodeError: return JsonResponse({'success': False, 'error': 'Invalid JSON'}, status=400) except Exception as e: logger.exception(f"Error sending admin message: {e}") return JsonResponse({'success': False, 'error': 'Server error'}, status=500) # ========================= # MYOB Dashboard # ========================= @login_required(login_url='login') # ✅ 添加登录验证装饰器 def myob_dashboard(request): """ ✅ 改进的 MYOB dashboard 主页面 需要登录且是 staff 用户才能访问 """ # ✅ 检查是否是 staff 用户 if not request.user.is_staff: messages.error(request, "⚠️ Only administrators can access this page") return redirect('product_list') return render(request, 'soonguan_store/myob_dashboard.html') @login_required(login_url='login') @require_GET def api_dashboard_stats(request): """ Get dashboard statistics. Query Parameters: - days: Number of days to include (optional) - round_ids: Comma-separated order round IDs (optional) Returns: JSON: {success, stats: {revenue, orders, customers, products, trends}} """ if not request.user.is_staff: return JsonResponse({'success': False, 'error': 'Unauthorized'}, status=403) try: # Parse filters days_param = request.GET.get('days') round_ids_param = request.GET.get('round_ids', '').strip() # Base queryset orders = Order.objects.all() # Apply round filter if round_ids_param: round_ids = [int(rid) for rid in round_ids_param.split(',') if rid.strip().isdigit()] if round_ids: orders = orders.filter(order_round_id__in=round_ids) # Apply date filter start_date = None days = None if days_param: try: days = int(days_param) if days > 0: end_date = timezone.now() start_date = end_date - timedelta(days=days) orders = orders.filter(created_at__gte=start_date) except ValueError: pass elif not round_ids_param: # Default to 7 days if no filter days = 7 end_date = timezone.now() start_date = end_date - timedelta(days=days) orders = orders.filter(created_at__gte=start_date) # Calculate statistics total_revenue = orders.aggregate(total=Sum('total_amount'))['total'] or Decimal('0.00') total_orders = orders.count() total_customers = orders.values('customer_name').distinct().count() # Product statistics total_products = Product.objects.filter(is_active=True).count() low_stock = Product.objects.filter( is_active=True, stock_quantity__lte=10 ).count() # Calculate trends (compare with previous period) revenue_trend = 0 orders_trend = 0 if days and start_date: prev_start = start_date - timedelta(days=days) prev_orders = Order.objects.filter( created_at__gte=prev_start, created_at__lt=start_date ) if round_ids_param: round_ids = [int(rid) for rid in round_ids_param.split(',') if rid.strip().isdigit()] if round_ids: prev_orders = prev_orders.filter(order_round_id__in=round_ids) prev_revenue = prev_orders.aggregate(total=Sum('total_amount'))['total'] or Decimal('0.00') prev_count = prev_orders.count() if prev_revenue > 0: revenue_trend = float((total_revenue - prev_revenue) / prev_revenue * 100) if prev_count > 0: orders_trend = float((total_orders - prev_count) / prev_count * 100) return JsonResponse({ 'success': True, 'stats': { 'total_revenue': float(total_revenue), 'total_orders': total_orders, 'total_customers': total_customers, 'total_products': total_products, 'low_stock_items': low_stock, 'revenue_trend': round(revenue_trend, 1), 'orders_trend': round(orders_trend, 1), } }) except Exception as e: logger.exception(f"Error getting dashboard stats: {e}") return JsonResponse({'success': False, 'error': 'Server error'}, status=500) @login_required(login_url='login') @require_GET def api_orders_list(request): """ Get orders list for MYOB dashboard. Query Parameters: - days: Filter by days (optional) - round_id: Filter by order round (optional) - status: Filter by status (optional) - page: Page number (optional) Returns: JSON: {success, orders: [{order_no, customer_name, status, total, ...}]} """ if not request.user.is_staff: return JsonResponse({'success': False, 'error': 'Unauthorized'}, status=403) try: # Parse filters days_param = request.GET.get('days') round_id = request.GET.get('round_id') status = request.GET.get('status') # Base queryset with optimized prefetch orders = Order.objects.select_related('order_round').prefetch_related('items') # Apply filters if days_param: try: days = int(days_param) if days > 0: cutoff = timezone.now() - timedelta(days=days) orders = orders.filter(created_at__gte=cutoff) except ValueError: pass if round_id and round_id != 'all': try: orders = orders.filter(order_round_id=int(round_id)) except ValueError: pass if status and status != 'all': orders = orders.filter(status=status) # Order by newest first orders = orders.order_by('-created_at') # ✅ 使用分页 page = request.GET.get('page', 1) paginator = Paginator(orders, MAX_ORDERS_PER_PAGE) orders_page = paginator.get_page(page) # Format data orders_data = [] for order in orders_page: orders_data.append({ 'id': order.id, 'order_no': order.order_no, 'customer_name': order.customer_name, 'phone': order.phone, 'email': order.email, 'status': order.status, 'status_display': order.get_status_display(), 'total_amount': float(order.total_amount), 'created_at': order.created_at.strftime('%Y-%m-%d %H:%M'), 'order_round': order.order_round.name if order.order_round else 'N/A', 'items_count': order.items.count(), }) return JsonResponse({ 'success': True, 'orders': orders_data, 'pagination': { 'page': orders_page.number, 'total_pages': paginator.num_pages, 'total_count': paginator.count, } }) except Exception as e: logger.exception(f"Error getting orders list: {e}") return JsonResponse({'success': False, 'error': 'Server error'}, status=500) @login_required(login_url='login') @require_POST def api_order_update_status(request, order_id): """ Update order status. POST Parameters: - status: New status (PENDING, CONFIRMED, CANCELLED, COMPLETED) Returns: JSON: {success, order} """ if not request.user.is_staff: return JsonResponse({'success': False, 'error': 'Unauthorized'}, status=403) try: order = get_object_or_404(Order, id=order_id) data = json.loads(request.body) new_status = data.get('status') # Validate status valid_statuses = ['PENDING', 'CONFIRMED', 'CANCELLED', 'COMPLETED'] if new_status not in valid_statuses: return JsonResponse({'success': False, 'error': 'Invalid status'}, status=400) order.status = new_status order.save() logger.info(f"Order {order.order_no} status updated to {new_status} by {request.user.username}") return JsonResponse({ 'success': True, 'order': { 'id': order.id, 'order_no': order.order_no, 'status': order.status, 'status_display': order.get_status_display(), } }) except json.JSONDecodeError: return JsonResponse({'success': False, 'error': 'Invalid JSON'}, status=400) except Exception as e: logger.exception(f"Error updating order status: {e}") return JsonResponse({'success': False, 'error': 'Server error'}, status=500) @login_required(login_url='login') @require_GET def api_order_summary(request): """ Get order summary (preparation list) for a specific order round. Query Parameters: - round_id: Order round ID ('current' for current round) Returns: JSON: { success, summary: [{product_name, units, total_pieces, orders_count}], round_info: {name, start_date, end_date} } """ if not request.user.is_staff: return JsonResponse({'success': False, 'error': 'Unauthorized'}, status=403) try: round_id = request.GET.get('round_id', 'current') # Get order round if round_id == 'current': order_round = get_current_order_round() if not order_round: return JsonResponse({ 'success': False, 'error': 'No active order round' }, status=404) else: order_round = get_object_or_404(OrderRound, id=round_id) # Get all orders for this round orders = Order.objects.filter(order_round=order_round).exclude(status='CANCELLED') # ✅ 使用聚合优化查询 product_summary = defaultdict(lambda: { 'units': 0, 'orders_count': set(), }) for order in orders.prefetch_related('items__product'): for item in order.items.all(): product_name = item.product_name or (item.product.name if item.product else 'Unknown') product_summary[product_name]['units'] += item.quantity product_summary[product_name]['orders_count'].add(order.id) # Format summary summary = [] for product_name, data in sorted(product_summary.items()): summary.append({ 'product_name': product_name, 'units': data['units'], 'orders_count': len(data['orders_count']), }) total_revenue = orders.aggregate(total=Sum('total_amount'))['total'] or Decimal('0.00') return JsonResponse({ 'success': True, 'summary': summary, 'round_info': { 'name': order_round.name, 'start_date': order_round.start_date.strftime('%Y-%m-%d'), 'end_date': order_round.end_date.strftime('%Y-%m-%d'), }, 'total_orders': orders.count(), 'total_items': sum(s['units'] for s in summary), 'total_revenue': float(total_revenue), }) except Exception as e: logger.exception(f"Error getting order summary: {e}") return JsonResponse({'success': False, 'error': 'Server error'}, status=500) @login_required(login_url='login') @require_GET def api_export_report(request): """ Export orders data as CSV. Query Parameters: - type: Report type ('sales', 'orders', 'customers') - round_id: Filter by round (optional) - days: Filter by days (optional) Returns: CSV file download """ if not request.user.is_staff: return JsonResponse({'success': False, 'error': 'Unauthorized'}, status=403) try: export_type = request.GET.get('type', 'orders') round_id = request.GET.get('round_id') days_param = request.GET.get('days') # Build queryset orders = Order.objects.all() if round_id and round_id != 'all': try: orders = orders.filter(order_round_id=int(round_id)) except ValueError: pass if days_param: try: days = int(days_param) if days > 0: cutoff = timezone.now() - timedelta(days=days) orders = orders.filter(created_at__gte=cutoff) except ValueError: pass # Create CSV response response = HttpResponse(content_type='text/csv; charset=utf-8') response['Content-Disposition'] = f'attachment; filename="export_{export_type}_{timezone.now().strftime("%Y%m%d")}.csv"' # ✅ 添加 BOM 以支持 Excel 正确显示中文 response.write('\ufeff') writer = csv.writer(response) if export_type == 'orders': # Orders export writer.writerow(['Order No', 'Date', 'Customer', 'Phone', 'Email', 'Status', 'Total', 'Items']) for order in orders.select_related('order_round').prefetch_related('items'): writer.writerow([ order.order_no, order.created_at.strftime('%Y-%m-%d %H:%M'), order.customer_name, order.phone, order.email, order.get_status_display(), float(order.total_amount), order.items.count(), ]) elif export_type == 'customers': # Customer export writer.writerow(['Customer', 'Phone', 'Email', 'Orders', 'Total Spent', 'Last Order']) # Aggregate by customer customers = defaultdict(lambda: { 'phone': '', 'email': '', 'orders': 0, 'total': Decimal('0.00'), 'last_order': None }) for order in orders: key = order.customer_name customers[key]['phone'] = order.phone customers[key]['email'] = order.email customers[key]['orders'] += 1 customers[key]['total'] += order.total_amount if not customers[key]['last_order'] or order.created_at > customers[key]['last_order']: customers[key]['last_order'] = order.created_at for name, data in sorted(customers.items()): writer.writerow([ name, data['phone'], data['email'], data['orders'], float(data['total']), data['last_order'].strftime('%Y-%m-%d') if data['last_order'] else '' ]) return response except Exception as e: logger.exception(f"Error exporting report: {e}") return JsonResponse({'success': False, 'error': 'Server error'}, status=500) # ========================= # MYOB Dashboard APIs (Additional) # ========================= @login_required(login_url='login') @require_GET def api_order_rounds(request): """ ✅ 获取订单周期列表 Returns: JSON: {success, data: [{id, label, is_active, start_date, end_date}]} """ if not request.user.is_staff: return JsonResponse({'success': False, 'error': 'Unauthorized'}, status=403) try: rounds = OrderRound.objects.all().order_by('-start_date')[:50] return JsonResponse({ 'success': True, 'data': [{ 'id': r.id, 'label': r.name, 'is_active': r.is_active, 'start_date': r.start_date.isoformat(), 'end_date': r.end_date.isoformat(), } for r in rounds] }) except Exception as e: logger.exception(f"Error getting order rounds: {e}") return JsonResponse({'success': False, 'error': 'Server error'}, status=500) @login_required(login_url='login') @require_GET def api_inventory(request): """ ✅ 获取库存列表 Returns: JSON: {success, data: [{name, category, stock, cost, price, profit, profit_margin, sold, is_low_stock}]} """ if not request.user.is_staff: return JsonResponse({'success': False, 'error': 'Unauthorized'}, status=403) try: products = Product.objects.select_related('category').filter(is_active=True) # 获取销售数据 sales_data = OrderItem.objects.filter( order__status__in=['CONFIRMED', 'COMPLETED'] ).values('product_id').annotate( total_sold=Sum('quantity') ) sales_dict = {item['product_id']: item['total_sold'] for item in sales_data} inventory_data = [] for product in products: price = float(product.price or 0) cost = price * 0.6 # 假设成本为60% profit = price - cost margin = round((profit / price * 100) if price > 0 else 0, 1) stock = product.stock_quantity low_stock_threshold = 10 inventory_data.append({ 'id': product.id, 'name': product.name, 'category': product.category.name if product.category else 'Uncategorized', 'stock': stock, 'cost': cost, 'price': price, 'profit': profit, 'profit_margin': margin, 'sold': sales_dict.get(product.id, 0), 'is_low_stock': stock < low_stock_threshold, }) return JsonResponse({ 'success': True, 'data': inventory_data }) except Exception as e: logger.exception(f"Error getting inventory: {e}") return JsonResponse({'success': False, 'error': 'Server error'}, status=500) @login_required(login_url='login') @require_GET def api_customers(request): """ ✅ 获取客户列表 Returns: JSON: {success, summary, data} """ if not request.user.is_staff: return JsonResponse({'success': False, 'error': 'Unauthorized'}, status=403) try: from django.db.models import Max customer_data = Order.objects.exclude( status='CANCELLED' ).values('phone', 'customer_name', 'email').annotate( order_count=Count('id'), total_spent=Sum('total_amount'), last_order_date=Max('created_at') ).order_by('-total_spent')[:100] customers = [] repeat_count = 0 total_spent_sum = 0 for c in customer_data: orders = c['order_count'] spent = float(c['total_spent'] or 0) if orders > 1: repeat_count += 1 status = 'VIP' if orders >= 5 else 'Repeat' else: status = 'New' total_spent_sum += spent customers.append({ 'name': c['customer_name'], 'phone': c['phone'], 'email': c['email'] or '', 'orders': orders, 'total_spent': spent, 'last_order': c['last_order_date'].strftime('%Y-%m-%d') if c['last_order_date'] else '', 'status': status, }) total_customers = len(customers) avg_ltv = total_spent_sum / total_customers if total_customers > 0 else 0 return JsonResponse({ 'success': True, 'summary': { 'total_customers': total_customers, 'repeat_customers': repeat_count, 'avg_lifetime_value': avg_ltv, }, 'data': customers }) except Exception as e: logger.exception(f"Error getting customers: {e}") return JsonResponse({'success': False, 'error': 'Server error'}, status=500) @login_required(login_url='login') @require_GET def api_sales_trend(request): """ ✅ 获取销售趋势数据 (最近7天) Returns: JSON: {success, data: [{date, revenue, orders}]} """ if not request.user.is_staff: return JsonResponse({'success': False, 'error': 'Unauthorized'}, status=403) try: end_date = timezone.now().date() start_date = end_date - timedelta(days=6) daily_orders = Order.objects.filter( created_at__date__gte=start_date, created_at__date__lte=end_date ).exclude(status='CANCELLED') data_dict = defaultdict(lambda: {'revenue': Decimal('0'), 'orders': 0}) for order in daily_orders: date_str = order.created_at.strftime('%Y-%m-%d') data_dict[date_str]['revenue'] += order.total_amount data_dict[date_str]['orders'] += 1 result = [] current_date = start_date while current_date <= end_date: date_str = current_date.strftime('%Y-%m-%d') if date_str in data_dict: result.append({ 'date': current_date.strftime('%m/%d'), 'revenue': float(data_dict[date_str]['revenue']), 'orders': data_dict[date_str]['orders'] }) else: result.append({ 'date': current_date.strftime('%m/%d'), 'revenue': 0, 'orders': 0 }) current_date += timedelta(days=1) return JsonResponse({ 'success': True, 'data': result }) except Exception as e: logger.exception(f"Error getting sales trend: {e}") return JsonResponse({'success': False, 'error': 'Server error'}, status=500) @login_required(login_url='login') @require_GET def api_category_distribution(request): """ ✅ 获取分类销售分布 Returns: JSON: {success, data: [{name, value}]} """ if not request.user.is_staff: return JsonResponse({'success': False, 'error': 'Unauthorized'}, status=403) try: category_data = OrderItem.objects.filter( order__status__in=['CONFIRMED', 'COMPLETED'] ).select_related('product__category').values( 'product__category__name' ).annotate( total=Sum(F('unit_price') * F('quantity')) ).order_by('-total')[:7] result = [] for item in category_data: name = item['product__category__name'] or 'Uncategorized' result.append({ 'name': name, 'value': float(item['total'] or 0) }) return JsonResponse({ 'success': True, 'data': result }) except Exception as e: logger.exception(f"Error getting category distribution: {e}") return JsonResponse({'success': False, 'error': 'Server error'}, status=500) @login_required(login_url='login') @require_GET def api_top_products(request): """ ✅ 获取热销产品 Top 10 Returns: JSON: {success, data: [{name, sold}]} """ if not request.user.is_staff: return JsonResponse({'success': False, 'error': 'Unauthorized'}, status=403) try: top_products = OrderItem.objects.filter( order__status__in=['CONFIRMED', 'COMPLETED'] ).values('product_name').annotate( sold=Sum('quantity') ).order_by('-sold')[:10] result = [{ 'name': item['product_name'] or 'Unknown', 'sold': item['sold'] } for item in top_products] return JsonResponse({ 'success': True, 'data': result }) except Exception as e: logger.exception(f"Error getting top products: {e}") return JsonResponse({'success': False, 'error': 'Server error'}, status=500) @login_required(login_url='login') @require_POST def api_send_invoice(request, order_id): """ ✅ 发送发票邮件 (用于 MYOB Dashboard) Returns: JSON: {success, invoice_no, sent_to} """ if not request.user.is_staff: return JsonResponse({'success': False, 'error': 'Unauthorized'}, status=403) try: from .invoice_service import create_and_send_invoice order = get_object_or_404(Order, id=order_id) if order.status not in ['CONFIRMED', 'COMPLETED']: return JsonResponse({ 'success': False, 'error': 'Only confirmed/completed orders can send invoice' }, status=400) if not order.email: return JsonResponse({ 'success': False, 'error': 'Order has no email address' }, status=400) invoice = create_and_send_invoice(order, request=request) if invoice: return JsonResponse({ 'success': True, 'invoice_no': invoice.invoice_no, 'sent_to': order.email, }) else: return JsonResponse({ 'success': False, 'error': 'Failed to send invoice' }, status=500) except Exception as e: logger.exception(f"Error sending invoice: {e}") return JsonResponse({'success': False, 'error': 'Server error'}, status=500) # ========================= # Invoice Functions # ========================= @login_required(login_url='login') @require_POST def api_order_send_invoice(request, order_id): """ Generate and send invoice for an order. Returns: JSON: {success, invoice_no} """ if not request.user.is_staff: return JsonResponse({'success': False, 'error': 'Unauthorized'}, status=403) try: from .invoice_service import create_and_send_invoice order = get_object_or_404(Order, id=order_id) if not order.email: return JsonResponse({ 'success': False, 'error': 'Order has no email address' }, status=400) # Create and send invoice invoice = create_and_send_invoice(order, request=request) if not invoice: return JsonResponse({ 'success': False, 'error': 'Failed to create invoice' }, status=500) logger.info(f"Invoice {invoice.invoice_no} sent for order {order.order_no}") return JsonResponse({ 'success': True, 'invoice_no': invoice.invoice_no, 'sent_at': invoice.sent_at.isoformat() if invoice.sent_at else None }) except Exception as e: logger.exception(f"Error sending invoice: {e}") return JsonResponse({'success': False, 'error': 'Server error'}, status=500) def invoice_view(request, token): """ View invoice by token (public access). Args: token: UUID token for invoice access """ try: invoice = get_object_or_404(Invoice, view_token=token) # Security: Check if PDF exists if not invoice.pdf_file: raise Http404("Invoice PDF not found") # Return PDF file return FileResponse( invoice.pdf_file.open('rb'), content_type='application/pdf', as_attachment=False, filename=f"Invoice_{invoice.invoice_no}.pdf" ) except Http404: raise except Exception as e: logger.exception(f"Error viewing invoice: {e}") raise Http404("Error loading invoice") def invoice_pdf_download(request, token): """ Download invoice PDF by token. Args: token: UUID token for invoice access """ try: invoice = get_object_or_404(Invoice, view_token=token) if not invoice.pdf_file: raise Http404("Invoice PDF not found") return FileResponse( invoice.pdf_file.open('rb'), content_type='application/pdf', as_attachment=True, filename=f"Invoice_{invoice.invoice_no}.pdf" ) except Http404: raise except Exception as e: logger.exception(f"Error downloading invoice: {e}") raise Http404("Error downloading invoice") # ========================= # ✅ Discount Code API # ========================= @require_POST def api_validate_discount(request): """ 验证折扣码 API POST /api/discount/validate/ Body: {"code": "DISCOUNT10", "order_amount": 100.00} Returns: JSON: {success, message, discount_amount, final_total, discount_type, discount_value} """ try: data = json.loads(request.body) code = (data.get('code') or '').strip().upper() order_amount = Decimal(str(data.get('order_amount', 0))) if not code: return JsonResponse({ 'success': False, 'error': 'Please enter a discount code' }, status=400) # 查找折扣码 try: discount = DiscountCode.objects.get(code__iexact=code) except DiscountCode.DoesNotExist: return JsonResponse({ 'success': False, 'error': 'Invalid discount code' }, status=404) # 验证折扣码 user = request.user if request.user.is_authenticated else None is_valid, error_msg = discount.is_valid(user=user, order_amount=order_amount) if not is_valid: return JsonResponse({ 'success': False, 'error': error_msg }, status=400) # 计算折扣 discount_amount = discount.calculate_discount(order_amount) final_total = order_amount - discount_amount # 构建成功消息 if discount.discount_type == 'percentage': msg = f"Code applied! {discount.discount_value}% off" if discount.max_discount_amount: msg += f" (max ${discount.max_discount_amount})" else: msg = f"Code applied! ${discount.discount_value} off" return JsonResponse({ 'success': True, 'message': msg, 'discount_amount': float(discount_amount), 'final_total': float(final_total), 'discount_type': discount.discount_type, 'discount_value': float(discount.discount_value) }) except json.JSONDecodeError: return JsonResponse({ 'success': False, 'error': 'Invalid request data' }, status=400) except Exception as e: logger.exception(f"Error validating discount: {e}") return JsonResponse({ 'success': False, 'error': 'Server error' }, status=500)