| 1 | package edu.ucsb.cs156.dining.controllers; | |
| 2 | ||
| 3 | import edu.ucsb.cs156.dining.entities.MenuItem; | |
| 4 | import edu.ucsb.cs156.dining.entities.Review; | |
| 5 | import edu.ucsb.cs156.dining.models.CommonsAverage; | |
| 6 | import edu.ucsb.cs156.dining.models.CommonsAverageOverTime; | |
| 7 | import edu.ucsb.cs156.dining.models.ItemStatistic; | |
| 8 | import edu.ucsb.cs156.dining.models.MealAverage; | |
| 9 | import edu.ucsb.cs156.dining.repositories.ReviewRepository; | |
| 10 | import edu.ucsb.cs156.dining.statuses.ModerationStatus; | |
| 11 | import io.swagger.v3.oas.annotations.Operation; | |
| 12 | import io.swagger.v3.oas.annotations.Parameter; | |
| 13 | import io.swagger.v3.oas.annotations.tags.Tag; | |
| 14 | import java.time.LocalDateTime; | |
| 15 | import java.time.format.DateTimeFormatter; | |
| 16 | import java.util.ArrayList; | |
| 17 | import java.util.Comparator; | |
| 18 | import java.util.HashMap; | |
| 19 | import java.util.List; | |
| 20 | import java.util.Map; | |
| 21 | import java.util.stream.Collectors; | |
| 22 | import lombok.extern.slf4j.Slf4j; | |
| 23 | import org.springframework.beans.factory.annotation.Autowired; | |
| 24 | import org.springframework.http.ResponseEntity; | |
| 25 | import org.springframework.security.access.prepost.PreAuthorize; | |
| 26 | import org.springframework.web.bind.annotation.ExceptionHandler; | |
| 27 | import org.springframework.web.bind.annotation.GetMapping; | |
| 28 | import org.springframework.web.bind.annotation.PathVariable; | |
| 29 | import org.springframework.web.bind.annotation.RequestMapping; | |
| 30 | import org.springframework.web.bind.annotation.RequestParam; | |
| 31 | import org.springframework.web.bind.annotation.RestController; | |
| 32 | ||
| 33 | /** | |
| 34 | * REST controller that exposes aggregated review statistics. These endpoints power the "Review | |
| 35 | * Statistics" pages of the frontend (see issue #18). | |
| 36 | * | |
| 37 | * <p>Only reviews with moderation status {@link ModerationStatus#APPROVED} (or reviews that have no | |
| 38 | * comments and were therefore auto-approved) are considered when computing the statistics, so that | |
| 39 | * unmoderated user content does not influence what we publish. | |
| 40 | */ | |
| 41 | @Tag(name = "Statistics") | |
| 42 | @RequestMapping("/api/statistics") | |
| 43 | @RestController | |
| 44 | @Slf4j | |
| 45 | public class StatisticsController extends ApiController { | |
| 46 | ||
| 47 | @Autowired ReviewRepository reviewRepository; | |
| 48 | ||
| 49 | @ExceptionHandler(IllegalArgumentException.class) | |
| 50 | public ResponseEntity<Map<String, String>> handleValidationExceptions( | |
| 51 | IllegalArgumentException ex) { | |
| 52 | Map<String, String> errors = new HashMap<>(); | |
| 53 | errors.put("error", ex.getMessage()); | |
| 54 |
1
1. handleValidationExceptions : replaced return value with null for edu/ucsb/cs156/dining/controllers/StatisticsController::handleValidationExceptions → KILLED |
return ResponseEntity.badRequest().body(errors); |
| 55 | } | |
| 56 | ||
| 57 | /** Supported time-period filters for the best/worst items endpoints. */ | |
| 58 | public static final String PERIOD_ALL = "ALL"; | |
| 59 | ||
| 60 | public static final String PERIOD_6M = "6M"; | |
| 61 | public static final String PERIOD_1M = "1M"; | |
| 62 | public static final String PERIOD_1W = "1W"; | |
| 63 | ||
| 64 | private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); | |
| 65 | ||
| 66 | /** | |
| 67 | * Returns the cutoff {@link LocalDateTime} for the supplied period. {@code ALL} (or any | |
| 68 | * unrecognised value) maps to {@code null}, meaning no lower bound. | |
| 69 | */ | |
| 70 | static LocalDateTime cutoffForPeriod(String period, LocalDateTime now) { | |
| 71 |
1
1. cutoffForPeriod : negated conditional → KILLED |
if (period == null) { |
| 72 | return null; | |
| 73 | } | |
| 74 | switch (period) { | |
| 75 | case PERIOD_6M: | |
| 76 |
1
1. cutoffForPeriod : replaced return value with null for edu/ucsb/cs156/dining/controllers/StatisticsController::cutoffForPeriod → KILLED |
return now.minusMonths(6); |
| 77 | case PERIOD_1M: | |
| 78 |
1
1. cutoffForPeriod : replaced return value with null for edu/ucsb/cs156/dining/controllers/StatisticsController::cutoffForPeriod → KILLED |
return now.minusMonths(1); |
| 79 | case PERIOD_1W: | |
| 80 |
1
1. cutoffForPeriod : replaced return value with null for edu/ucsb/cs156/dining/controllers/StatisticsController::cutoffForPeriod → KILLED |
return now.minusWeeks(1); |
| 81 | case PERIOD_ALL: | |
| 82 | default: | |
| 83 | return null; | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | /** Loads only approved reviews from the repository (the only ones we expose in statistics). */ | |
| 88 | private List<Review> approvedReviews() { | |
| 89 | List<Review> approved = new ArrayList<>(); | |
| 90 | for (Review r : reviewRepository.findByStatus(ModerationStatus.APPROVED)) { | |
| 91 | approved.add(r); | |
| 92 | } | |
| 93 |
1
1. approvedReviews : replaced return value with Collections.emptyList for edu/ucsb/cs156/dining/controllers/StatisticsController::approvedReviews → KILLED |
return approved; |
| 94 | } | |
| 95 | ||
| 96 | /** Applies a "served on or after the cutoff" filter (if a cutoff was given). */ | |
| 97 | private List<Review> filterByCutoff(List<Review> reviews, LocalDateTime cutoff) { | |
| 98 |
1
1. filterByCutoff : negated conditional → KILLED |
if (cutoff == null) { |
| 99 |
1
1. filterByCutoff : replaced return value with Collections.emptyList for edu/ucsb/cs156/dining/controllers/StatisticsController::filterByCutoff → KILLED |
return reviews; |
| 100 | } | |
| 101 | List<Review> filtered = new ArrayList<>(); | |
| 102 | for (Review r : reviews) { | |
| 103 |
2
1. filterByCutoff : negated conditional → KILLED 2. filterByCutoff : negated conditional → KILLED |
if (r.getDateItemServed() != null && !r.getDateItemServed().isBefore(cutoff)) { |
| 104 | filtered.add(r); | |
| 105 | } | |
| 106 | } | |
| 107 |
1
1. filterByCutoff : replaced return value with Collections.emptyList for edu/ucsb/cs156/dining/controllers/StatisticsController::filterByCutoff → KILLED |
return filtered; |
| 108 | } | |
| 109 | ||
| 110 | /** Groups reviews by their associated menu item id and computes aggregate statistics. */ | |
| 111 | private List<ItemStatistic> aggregateByItem(List<Review> reviews) { | |
| 112 | Map<Long, List<Review>> byItem = new HashMap<>(); | |
| 113 | for (Review r : reviews) { | |
| 114 | MenuItem item = r.getItem(); | |
| 115 |
1
1. aggregateByItem : negated conditional → KILLED |
if (item == null) { |
| 116 | continue; | |
| 117 | } | |
| 118 |
1
1. lambda$aggregateByItem$0 : replaced return value with Collections.emptyList for edu/ucsb/cs156/dining/controllers/StatisticsController::lambda$aggregateByItem$0 → KILLED |
byItem.computeIfAbsent(item.getId(), k -> new ArrayList<>()).add(r); |
| 119 | } | |
| 120 | List<ItemStatistic> stats = new ArrayList<>(); | |
| 121 | for (Map.Entry<Long, List<Review>> entry : byItem.entrySet()) { | |
| 122 | List<Review> itemReviews = entry.getValue(); | |
| 123 | MenuItem item = itemReviews.get(0).getItem(); | |
| 124 | double total = 0.0; | |
| 125 | long count = 0L; | |
| 126 | for (Review r : itemReviews) { | |
| 127 |
1
1. aggregateByItem : negated conditional → KILLED |
if (r.getItemsStars() != null) { |
| 128 |
1
1. aggregateByItem : Replaced double addition with subtraction → KILLED |
total += r.getItemsStars(); |
| 129 |
1
1. aggregateByItem : Replaced long addition with subtraction → KILLED |
count++; |
| 130 | } | |
| 131 | } | |
| 132 |
1
1. aggregateByItem : negated conditional → KILLED |
if (count == 0L) { |
| 133 | continue; | |
| 134 | } | |
| 135 | stats.add( | |
| 136 | ItemStatistic.builder() | |
| 137 | .itemId(entry.getKey()) | |
| 138 | .itemName(item.getName()) | |
| 139 | .diningCommonsCode(item.getDiningCommonsCode()) | |
| 140 | .mealCode(item.getMealCode()) | |
| 141 |
1
1. aggregateByItem : Replaced double division with multiplication → KILLED |
.station(item.getStation()) |
| 142 | .averageStars(total / count) | |
| 143 | .reviewCount(count) | |
| 144 | .build()); | |
| 145 | } | |
| 146 |
1
1. aggregateByItem : replaced return value with Collections.emptyList for edu/ucsb/cs156/dining/controllers/StatisticsController::aggregateByItem → KILLED |
return stats; |
| 147 | } | |
| 148 | ||
| 149 | /** Rejects negative limits before they reach {@code Stream.limit}, which throws. */ | |
| 150 | private void validateLimit(int limit) { | |
| 151 |
2
1. validateLimit : negated conditional → KILLED 2. validateLimit : changed conditional boundary → KILLED |
if (limit < 0) { |
| 152 | throw new IllegalArgumentException("limit must be non-negative"); | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 156 | /** Best items endpoint, supports a time period filter and a maximum result count. */ | |
| 157 | @Operation(summary = "Best rated items, optionally restricted to a recent time period") | |
| 158 | @PreAuthorize("hasRole('ROLE_USER')") | |
| 159 | @GetMapping("/items/best") | |
| 160 | public List<ItemStatistic> bestItems( | |
| 161 | @Parameter(name = "period", description = "ALL, 6M, 1M, or 1W") | |
| 162 | @RequestParam(name = "period", defaultValue = PERIOD_ALL) | |
| 163 | String period, | |
| 164 | @Parameter(name = "limit", description = "Maximum number of items to return") | |
| 165 | @RequestParam(name = "limit", defaultValue = "5") | |
| 166 | int limit) { | |
| 167 | log.info("statistics.bestItems period={} limit={}", period, limit); | |
| 168 |
1
1. bestItems : removed call to edu/ucsb/cs156/dining/controllers/StatisticsController::validateLimit → KILLED |
validateLimit(limit); |
| 169 | LocalDateTime cutoff = cutoffForPeriod(period, LocalDateTime.now()); | |
| 170 | List<ItemStatistic> stats = aggregateByItem(filterByCutoff(approvedReviews(), cutoff)); | |
| 171 |
1
1. bestItems : removed call to java/util/List::sort → KILLED |
stats.sort( |
| 172 | Comparator.comparingDouble(ItemStatistic::getAverageStars) | |
| 173 | .reversed() | |
| 174 | .thenComparing(Comparator.comparingLong(ItemStatistic::getReviewCount).reversed()) | |
| 175 | .thenComparing(Comparator.comparing(ItemStatistic::getItemId))); | |
| 176 |
1
1. bestItems : replaced return value with Collections.emptyList for edu/ucsb/cs156/dining/controllers/StatisticsController::bestItems → KILLED |
return stats.stream().limit(limit).collect(Collectors.toCollection(ArrayList::new)); |
| 177 | } | |
| 178 | ||
| 179 | /** Worst items endpoint, supports a time period filter and a maximum result count. */ | |
| 180 | @Operation(summary = "Worst rated items, optionally restricted to a recent time period") | |
| 181 | @PreAuthorize("hasRole('ROLE_USER')") | |
| 182 | @GetMapping("/items/worst") | |
| 183 | public List<ItemStatistic> worstItems( | |
| 184 | @Parameter(name = "period", description = "ALL, 6M, 1M, or 1W") | |
| 185 | @RequestParam(name = "period", defaultValue = PERIOD_ALL) | |
| 186 | String period, | |
| 187 | @Parameter(name = "limit", description = "Maximum number of items to return") | |
| 188 | @RequestParam(name = "limit", defaultValue = "5") | |
| 189 | int limit) { | |
| 190 | log.info("statistics.worstItems period={} limit={}", period, limit); | |
| 191 |
1
1. worstItems : removed call to edu/ucsb/cs156/dining/controllers/StatisticsController::validateLimit → KILLED |
validateLimit(limit); |
| 192 | LocalDateTime cutoff = cutoffForPeriod(period, LocalDateTime.now()); | |
| 193 | List<ItemStatistic> stats = aggregateByItem(filterByCutoff(approvedReviews(), cutoff)); | |
| 194 |
1
1. worstItems : removed call to java/util/List::sort → KILLED |
stats.sort( |
| 195 | Comparator.comparingDouble(ItemStatistic::getAverageStars) | |
| 196 | .thenComparing(Comparator.comparingLong(ItemStatistic::getReviewCount).reversed()) | |
| 197 | .thenComparing(Comparator.comparing(ItemStatistic::getItemId))); | |
| 198 |
1
1. worstItems : replaced return value with Collections.emptyList for edu/ucsb/cs156/dining/controllers/StatisticsController::worstItems → KILLED |
return stats.stream().limit(limit).collect(Collectors.toCollection(ArrayList::new)); |
| 199 | } | |
| 200 | ||
| 201 | /** Average review score for each dining commons. */ | |
| 202 | @Operation(summary = "Average review score for each dining commons") | |
| 203 | @PreAuthorize("hasRole('ROLE_USER')") | |
| 204 | @GetMapping("/commons/averages") | |
| 205 | public List<CommonsAverage> commonsAverages() { | |
| 206 | log.info("statistics.commonsAverages"); | |
| 207 | Map<String, double[]> byCommons = new HashMap<>(); | |
| 208 | for (Review r : approvedReviews()) { | |
| 209 | MenuItem item = r.getItem(); | |
| 210 |
3
1. commonsAverages : negated conditional → KILLED 2. commonsAverages : negated conditional → KILLED 3. commonsAverages : negated conditional → KILLED |
if (item == null || item.getDiningCommonsCode() == null || r.getItemsStars() == null) { |
| 211 | continue; | |
| 212 | } | |
| 213 |
1
1. lambda$commonsAverages$1 : replaced return value with null for edu/ucsb/cs156/dining/controllers/StatisticsController::lambda$commonsAverages$1 → KILLED |
double[] acc = byCommons.computeIfAbsent(item.getDiningCommonsCode(), k -> new double[2]); |
| 214 |
1
1. commonsAverages : Replaced double addition with subtraction → KILLED |
acc[0] += r.getItemsStars(); |
| 215 |
1
1. commonsAverages : Replaced double addition with subtraction → KILLED |
acc[1] += 1.0; |
| 216 | } | |
| 217 | List<CommonsAverage> result = new ArrayList<>(); | |
| 218 | for (Map.Entry<String, double[]> entry : byCommons.entrySet()) { | |
| 219 | double[] acc = entry.getValue(); | |
| 220 | long count = (long) acc[1]; | |
| 221 | result.add( | |
| 222 | CommonsAverage.builder() | |
| 223 |
1
1. commonsAverages : Replaced double division with multiplication → KILLED |
.diningCommonsCode(entry.getKey()) |
| 224 | .averageStars(acc[0] / acc[1]) | |
| 225 | .reviewCount(count) | |
| 226 | .build()); | |
| 227 | } | |
| 228 |
1
1. commonsAverages : removed call to java/util/List::sort → KILLED |
result.sort(Comparator.comparing(CommonsAverage::getDiningCommonsCode)); |
| 229 |
1
1. commonsAverages : replaced return value with Collections.emptyList for edu/ucsb/cs156/dining/controllers/StatisticsController::commonsAverages → KILLED |
return result; |
| 230 | } | |
| 231 | ||
| 232 | /** Average review score for each dining commons grouped by month (used to draw a graph). */ | |
| 233 | @Operation(summary = "Average review score for each dining commons grouped by month") | |
| 234 | @PreAuthorize("hasRole('ROLE_USER')") | |
| 235 | @GetMapping("/commons/averages/overtime") | |
| 236 | public List<CommonsAverageOverTime> commonsAveragesOverTime() { | |
| 237 | log.info("statistics.commonsAveragesOverTime"); | |
| 238 | Map<String, double[]> buckets = new HashMap<>(); | |
| 239 | Map<String, String[]> labels = new HashMap<>(); | |
| 240 | for (Review r : approvedReviews()) { | |
| 241 | MenuItem item = r.getItem(); | |
| 242 |
1
1. commonsAveragesOverTime : negated conditional → KILLED |
if (item == null |
| 243 |
1
1. commonsAveragesOverTime : negated conditional → KILLED |
|| item.getDiningCommonsCode() == null |
| 244 |
1
1. commonsAveragesOverTime : negated conditional → KILLED |
|| r.getItemsStars() == null |
| 245 |
1
1. commonsAveragesOverTime : negated conditional → KILLED |
|| r.getDateItemServed() == null) { |
| 246 | continue; | |
| 247 | } | |
| 248 | String code = item.getDiningCommonsCode(); | |
| 249 | String period = r.getDateItemServed().format(MONTH_FORMATTER); | |
| 250 | String key = code + "|" + period; | |
| 251 |
1
1. lambda$commonsAveragesOverTime$2 : replaced return value with null for edu/ucsb/cs156/dining/controllers/StatisticsController::lambda$commonsAveragesOverTime$2 → KILLED |
double[] acc = buckets.computeIfAbsent(key, k -> new double[2]); |
| 252 |
1
1. commonsAveragesOverTime : Replaced double addition with subtraction → KILLED |
acc[0] += r.getItemsStars(); |
| 253 |
1
1. commonsAveragesOverTime : Replaced double addition with subtraction → KILLED |
acc[1] += 1.0; |
| 254 |
1
1. lambda$commonsAveragesOverTime$3 : replaced return value with null for edu/ucsb/cs156/dining/controllers/StatisticsController::lambda$commonsAveragesOverTime$3 → KILLED |
labels.computeIfAbsent(key, k -> new String[] {code, period}); |
| 255 | } | |
| 256 | List<CommonsAverageOverTime> result = new ArrayList<>(); | |
| 257 | for (Map.Entry<String, double[]> entry : buckets.entrySet()) { | |
| 258 | String[] label = labels.get(entry.getKey()); | |
| 259 | double[] acc = entry.getValue(); | |
| 260 | long count = (long) acc[1]; | |
| 261 | result.add( | |
| 262 | CommonsAverageOverTime.builder() | |
| 263 | .diningCommonsCode(label[0]) | |
| 264 |
1
1. commonsAveragesOverTime : Replaced double division with multiplication → KILLED |
.period(label[1]) |
| 265 | .averageStars(acc[0] / acc[1]) | |
| 266 | .reviewCount(count) | |
| 267 | .build()); | |
| 268 | } | |
| 269 |
1
1. commonsAveragesOverTime : removed call to java/util/List::sort → KILLED |
result.sort( |
| 270 | Comparator.comparing(CommonsAverageOverTime::getDiningCommonsCode) | |
| 271 | .thenComparing(CommonsAverageOverTime::getPeriod)); | |
| 272 |
1
1. commonsAveragesOverTime : replaced return value with Collections.emptyList for edu/ucsb/cs156/dining/controllers/StatisticsController::commonsAveragesOverTime → KILLED |
return result; |
| 273 | } | |
| 274 | ||
| 275 | /** Average review score for each meal slot at the supplied dining commons. */ | |
| 276 | @Operation(summary = "Average review score grouped by meal for a single dining commons") | |
| 277 | @PreAuthorize("hasRole('ROLE_USER')") | |
| 278 | @GetMapping("/commons/{code}/meals/averages") | |
| 279 | public List<MealAverage> commonsMealAverages( | |
| 280 | @Parameter(name = "code", description = "dining commons code, e.g. 'carrillo'") | |
| 281 | @PathVariable("code") | |
| 282 | String code) { | |
| 283 | log.info("statistics.commonsMealAverages code={}", code); | |
| 284 | Map<String, double[]> byMeal = new HashMap<>(); | |
| 285 | for (Review r : approvedReviews()) { | |
| 286 | MenuItem item = r.getItem(); | |
| 287 |
1
1. commonsMealAverages : negated conditional → KILLED |
if (item == null |
| 288 |
1
1. commonsMealAverages : negated conditional → KILLED |
|| item.getMealCode() == null |
| 289 |
1
1. commonsMealAverages : negated conditional → KILLED |
|| r.getItemsStars() == null |
| 290 |
1
1. commonsMealAverages : negated conditional → KILLED |
|| !code.equals(item.getDiningCommonsCode())) { |
| 291 | continue; | |
| 292 | } | |
| 293 |
1
1. lambda$commonsMealAverages$4 : replaced return value with null for edu/ucsb/cs156/dining/controllers/StatisticsController::lambda$commonsMealAverages$4 → KILLED |
double[] acc = byMeal.computeIfAbsent(item.getMealCode(), k -> new double[2]); |
| 294 |
1
1. commonsMealAverages : Replaced double addition with subtraction → KILLED |
acc[0] += r.getItemsStars(); |
| 295 |
1
1. commonsMealAverages : Replaced double addition with subtraction → KILLED |
acc[1] += 1.0; |
| 296 | } | |
| 297 | List<MealAverage> result = new ArrayList<>(); | |
| 298 | for (Map.Entry<String, double[]> entry : byMeal.entrySet()) { | |
| 299 | double[] acc = entry.getValue(); | |
| 300 | long count = (long) acc[1]; | |
| 301 | result.add( | |
| 302 | MealAverage.builder() | |
| 303 | .diningCommonsCode(code) | |
| 304 |
1
1. commonsMealAverages : Replaced double division with multiplication → KILLED |
.mealCode(entry.getKey()) |
| 305 | .averageStars(acc[0] / acc[1]) | |
| 306 | .reviewCount(count) | |
| 307 | .build()); | |
| 308 | } | |
| 309 |
1
1. commonsMealAverages : removed call to java/util/List::sort → KILLED |
result.sort(Comparator.comparing(MealAverage::getMealCode)); |
| 310 |
1
1. commonsMealAverages : replaced return value with Collections.emptyList for edu/ucsb/cs156/dining/controllers/StatisticsController::commonsMealAverages → KILLED |
return result; |
| 311 | } | |
| 312 | } | |
Mutations | ||
| 54 |
1.1 |
|
| 71 |
1.1 |
|
| 76 |
1.1 |
|
| 78 |
1.1 |
|
| 80 |
1.1 |
|
| 93 |
1.1 |
|
| 98 |
1.1 |
|
| 99 |
1.1 |
|
| 103 |
1.1 2.2 |
|
| 107 |
1.1 |
|
| 115 |
1.1 |
|
| 118 |
1.1 |
|
| 127 |
1.1 |
|
| 128 |
1.1 |
|
| 129 |
1.1 |
|
| 132 |
1.1 |
|
| 141 |
1.1 |
|
| 146 |
1.1 |
|
| 151 |
1.1 2.2 |
|
| 168 |
1.1 |
|
| 171 |
1.1 |
|
| 176 |
1.1 |
|
| 191 |
1.1 |
|
| 194 |
1.1 |
|
| 198 |
1.1 |
|
| 210 |
1.1 2.2 3.3 |
|
| 213 |
1.1 |
|
| 214 |
1.1 |
|
| 215 |
1.1 |
|
| 223 |
1.1 |
|
| 228 |
1.1 |
|
| 229 |
1.1 |
|
| 242 |
1.1 |
|
| 243 |
1.1 |
|
| 244 |
1.1 |
|
| 245 |
1.1 |
|
| 251 |
1.1 |
|
| 252 |
1.1 |
|
| 253 |
1.1 |
|
| 254 |
1.1 |
|
| 264 |
1.1 |
|
| 269 |
1.1 |
|
| 272 |
1.1 |
|
| 287 |
1.1 |
|
| 288 |
1.1 |
|
| 289 |
1.1 |
|
| 290 |
1.1 |
|
| 293 |
1.1 |
|
| 294 |
1.1 |
|
| 295 |
1.1 |
|
| 304 |
1.1 |
|
| 309 |
1.1 |
|
| 310 |
1.1 |