Coverage for src/orders/services.py: 98%
104 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-08-04 12:59 +0300
« prev ^ index » next coverage.py v7.9.2, created at 2025-08-04 12:59 +0300
1from django.db import transaction
3from rest_framework.exceptions import ValidationError
5from datetime import datetime
6import re
7import uuid
9from src.orders.models import Order
10from src.shopping_bags.models import ShoppingBag
11from src.common.services import UserIdentificationService
12from src.orders.constants import CardErrorMessages, CardRegexPatterns
15class PaymentValidationService:
16 """
17 Service class for validating payment (credit card) data.
18 Provides static methods to validate card number, holder name, CVV, and expiry date.
19 Ensures all payment data is correct before processing an order.
20 """
22 # Regex patterns for different card types
23 CARD_PATTERNS = {
24 'VISA': CardRegexPatterns.VISA,
25 'MASTERCARD_LEGACY': CardRegexPatterns.MASTERCARD_LEGACY,
26 'MASTERCARD_NEW': CardRegexPatterns.MASTERCARD_NEW,
27 }
28 CVV_PATTERN = CardRegexPatterns.CVV
29 EXPIRY_DATE_PATTERN = CardRegexPatterns.EXPIRY_DATE
30 CARD_HOLDER_PATTERN = CardRegexPatterns.CARD_HOLDER
32 @classmethod
33 def validate_card_number(cls, card_number):
34 # Validates the card number using regex patterns for supported card types
35 if not card_number:
36 raise ValidationError(
37 {
38 'card_number': CardErrorMessages.INVALID_CARD_NUMBER,
39 }
40 )
42 for pattern in cls.CARD_PATTERNS.values():
43 if re.match(pattern, card_number):
44 return True
46 raise ValidationError(
47 {
48 'card_number': CardErrorMessages.INVALID_CARD_NUMBER,
49 }
50 )
52 @classmethod
53 def validate_card_holder_name(cls, name):
54 # Validates the card holder's name (letters, spaces, hyphens, etc.)
55 if not name:
56 raise ValidationError(
57 {
58 'card_holder_name': CardErrorMessages.INVALID_CARD_HOLDER_NAME,
59 }
60 )
62 if not re.match(cls.CARD_HOLDER_PATTERN, name):
63 raise ValidationError(
64 {
65 'card_holder_name': CardErrorMessages.INVALID_CARD_HOLDER_NAME
66 }
67 )
69 return True
71 @classmethod
72 def validate_cvv(cls, cvv):
73 # Validates the CVV (security code) for correct length and digits
74 if not cvv:
75 raise ValidationError(
76 {
77 'cvv': CardErrorMessages.INVALID_CVV_CODE,
78 }
79 )
81 if not re.match(cls.CVV_PATTERN, cvv):
82 raise ValidationError(
83 {
84 'cvv': CardErrorMessages.INVALID_CVV_CODE,
85 }
86 )
88 return True
90 @classmethod
91 def validate_expiry_date(cls, expiry_date):
92 # Validates the expiry date (MM/YY format) and checks if the card is expired
93 if not expiry_date:
94 raise ValidationError(
95 {
96 'expiry_date': CardErrorMessages.INVALID_EXPIRY_DATE,
97 }
98 )
100 if not re.match(cls.EXPIRY_DATE_PATTERN, expiry_date):
101 raise ValidationError(
102 {
103 'expiry_date': CardErrorMessages.INVALID_EXPIRY_DATE,
104 }
105 )
107 month, year = expiry_date.split('/')
108 current_date = datetime.now()
109 current_year = current_date.year % 100 # Get last two digits of year
110 current_month = current_date.month
111 exp_year = int(year)
112 exp_month = int(month)
114 if exp_year < current_year or (
115 exp_year == current_year and exp_month < current_month
116 ):
117 raise ValidationError(
118 {
119 'expiry_date': CardErrorMessages.CARD_HAS_EXPIRED,
120 }
121 )
123 return True
125 @classmethod
126 def validate_payment_data(cls, payment_data):
127 # Validates all payment fields together
128 cls.validate_card_number(payment_data.get('card_number'))
129 cls.validate_card_holder_name(payment_data.get('card_holder_name'))
130 cls.validate_cvv(payment_data.get('cvv'))
131 cls.validate_expiry_date(payment_data.get('expiry_date'))
133 return True
136class OrderService:
137 """
138 Service class for business logic related to orders.
139 Handles order creation, grouping, retrieval, and total calculation.
140 """
142 @staticmethod
143 def get_user_identifier(request):
144 # Uses a shared service to extract user identification info from the request
145 return UserIdentificationService.get_user_identifier(request)
147 @staticmethod
148 @transaction.atomic
149 def process_order_from_shopping_bag(user):
150 shopping_bag_items = ShoppingBag.objects.filter(
151 user=user
152 ).select_related('inventory')
154 if not shopping_bag_items.exists():
155 raise ValidationError(
156 {
157 'shopping_bag': 'Shopping bag is empty',
158 }
159 )
161 order_group = uuid.uuid4()
162 orders = []
164 for bag_item in shopping_bag_items:
165 order = Order.objects.create(
166 user=user,
167 inventory=bag_item.inventory,
168 quantity=bag_item.quantity,
169 order_group=order_group,
170 )
171 orders.append(order)
173 shopping_bag_items.delete()
175 return orders
177 @staticmethod
178 def get_user_orders(user):
179 # Retrieves all orders for a user, with related product and user info
180 return (
181 Order.objects.filter(
182 user=user,
183 )
184 .select_related(
185 'inventory',
186 'user',
187 )
188 .order_by(
189 '-created_at',
190 )
191 )
193 @staticmethod
194 def get_user_orders_grouped(user):
195 """
196 Groups orders by order_group, but only includes one entry per unique product (not per inventory/size).
197 The order group total is still calculated as the sum of all order items in the group.
198 Do not modify the order objects (do not set quantity to None).
199 """
200 orders = OrderService.get_user_orders(user)
201 grouped_orders = {}
203 for order in orders:
204 order_group_str = str(order.order_group)
205 if order_group_str not in grouped_orders:
206 grouped_orders[order_group_str] = {}
207 # Use (product_content_type, product_object_id) as the unique key
209 inventory = order.inventory
211 product = getattr(inventory, 'product', None)
212 if not product:
213 continue
215 product_key = (product._meta.model_name, product.id)
216 if product_key not in grouped_orders[order_group_str]:
217 grouped_orders[order_group_str][product_key] = order
219 # Convert dicts to lists for serializer compatibility
220 for group in grouped_orders:
221 grouped_orders[group] = list(grouped_orders[group].values())
223 return grouped_orders
225 @staticmethod
226 def calculate_order_group_total(order_group_id, user):
227 # Calculates the total price for all orders in a group (single checkout)
228 orders = Order.objects.filter(
229 user=user,
230 order_group=order_group_id,
231 ).select_related('inventory')
233 total = 0.0
235 for order in orders:
236 if order.inventory and hasattr(order.inventory, 'price'):
237 total += float(order.inventory.price) * order.quantity
239 return round(total, 2)