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

1from django.db import transaction 

2 

3from rest_framework.exceptions import ValidationError 

4 

5from datetime import datetime 

6import re 

7import uuid 

8 

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 

13 

14 

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 """ 

21 

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 

31 

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 ) 

41 

42 for pattern in cls.CARD_PATTERNS.values(): 

43 if re.match(pattern, card_number): 

44 return True 

45 

46 raise ValidationError( 

47 { 

48 'card_number': CardErrorMessages.INVALID_CARD_NUMBER, 

49 } 

50 ) 

51 

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 ) 

61 

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 ) 

68 

69 return True 

70 

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 ) 

80 

81 if not re.match(cls.CVV_PATTERN, cvv): 

82 raise ValidationError( 

83 { 

84 'cvv': CardErrorMessages.INVALID_CVV_CODE, 

85 } 

86 ) 

87 

88 return True 

89 

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 ) 

99 

100 if not re.match(cls.EXPIRY_DATE_PATTERN, expiry_date): 

101 raise ValidationError( 

102 { 

103 'expiry_date': CardErrorMessages.INVALID_EXPIRY_DATE, 

104 } 

105 ) 

106 

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) 

113 

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 ) 

122 

123 return True 

124 

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')) 

132 

133 return True 

134 

135 

136class OrderService: 

137 """ 

138 Service class for business logic related to orders. 

139 Handles order creation, grouping, retrieval, and total calculation. 

140 """ 

141 

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) 

146 

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') 

153 

154 if not shopping_bag_items.exists(): 

155 raise ValidationError( 

156 { 

157 'shopping_bag': 'Shopping bag is empty', 

158 } 

159 ) 

160 

161 order_group = uuid.uuid4() 

162 orders = [] 

163 

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) 

172 

173 shopping_bag_items.delete() 

174 

175 return orders 

176 

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 ) 

192 

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 = {} 

202 

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 

208 

209 inventory = order.inventory 

210 

211 product = getattr(inventory, 'product', None) 

212 if not product: 

213 continue 

214 

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 

218 

219 # Convert dicts to lists for serializer compatibility 

220 for group in grouped_orders: 

221 grouped_orders[group] = list(grouped_orders[group].values()) 

222 

223 return grouped_orders 

224 

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') 

232 

233 total = 0.0 

234 

235 for order in orders: 

236 if order.inventory and hasattr(order.inventory, 'price'): 

237 total += float(order.inventory.price) * order.quantity 

238 

239 return round(total, 2)