diff --git a/lemarche/tenders/admin.py b/lemarche/tenders/admin.py index 70c8786d6..0f64b4cac 100644 --- a/lemarche/tenders/admin.py +++ b/lemarche/tenders/admin.py @@ -54,9 +54,28 @@ def queryset(self, request, queryset): return queryset -class AmountFilter(MultiChoice): - FILTER_LABEL = Tender._meta.get_field("amount").verbose_name - BUTTON_LABEL = "Appliquer" +class AmountCustomFilter(admin.SimpleListFilter): + title = "Montant du besoin" + parameter_name = "amount" + + def lookups(self, request, model_admin): + return ( + ("<10k", "Inférieur (<) à 10k €"), + ("5k-10k", "Entre 5k et 10k €"), + (">=10k", "Supérieur (>=) à 10k €"), + ) + + def queryset(self, request, queryset): + value = self.value() + amount_10k = 10 * 10**3 # 10k + if value == "<10k": + return queryset.filter_by_amount_exact(amount_10k, operation="lt") + elif value == "5k-10k": + return queryset.filter_by_amount_exact(amount_10k, operation="lte").filter_by_amount_exact( + amount_10k, operation="gte" + ) + elif value == ">=10k": + return queryset.filter_by_amount_exact(amount_10k, operation="gte") class ResponseKindFilter(admin.SimpleListFilter): @@ -145,13 +164,13 @@ class TenderAdmin(FieldsetsInlineMixin, admin.ModelAdmin): ] list_filter = [ + AmountCustomFilter, ("kind", KindFilter), AuthorKindFilter, "status", ("scale_marche_useless", ScaleMarcheUselessFilter), ("source", SourceFilter), HasAmountFilter, - ("amount", AmountFilter), "deadline_date", "start_working_date", ResponseKindFilter, diff --git a/lemarche/tenders/constants.py b/lemarche/tenders/constants.py index 508adb277..f405334ab 100644 --- a/lemarche/tenders/constants.py +++ b/lemarche/tenders/constants.py @@ -57,6 +57,23 @@ AMOUNT_RANGE_CHOICE_LIST = [amount[0] for amount in AMOUNT_RANGE_CHOICES] +AMOUNT_RANGE_CHOICE_EXACT = { + AMOUNT_RANGE_0_1: 1000 - 1, # 1000 € + AMOUNT_RANGE_1_5: 5 * 10**3 - 1, # 5000 € + AMOUNT_RANGE_5_10: 10 * 10**3 - 1, # 10000 € + AMOUNT_RANGE_10_15: 15 * 10**3 - 1, # 15000 € + AMOUNT_RANGE_15_20: 20 * 10**3 - 1, # 20000 € + AMOUNT_RANGE_20_30: 30 * 10**3 - 1, # 30000 € + AMOUNT_RANGE_30_50: 50 * 10**3 - 1, # 50000 € + AMOUNT_RANGE_50_100: 100 * 10**3 - 1, # 100000 € + AMOUNT_RANGE_100_150: 150 * 10**3 - 1, # 150000 € + AMOUNT_RANGE_150_250: 250 * 10**3 - 1, # 250000 € + AMOUNT_RANGE_250_500: 500 * 10**3 - 1, # 500000 € + AMOUNT_RANGE_500_750: 750 * 10**3 - 1, # 750000 € + AMOUNT_RANGE_750_1000: 1000 * 10**3 - 1, # 1000000 € + AMOUNT_RANGE_1000_MORE: 1000 * 10**3, # > 1000000 € +} + WHY_AMOUNT_IS_BLANK_DONT_KNOW = "DONT_KNOW" WHY_AMOUNT_IS_BLANK_DONT_WANT_TO_SHARE = "DONT_WANT_TO_SHARE" WHY_AMOUNT_IS_BLANK_CHOICES = ( diff --git a/lemarche/tenders/models.py b/lemarche/tenders/models.py index 8272514d4..23b968be1 100644 --- a/lemarche/tenders/models.py +++ b/lemarche/tenders/models.py @@ -4,7 +4,7 @@ from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.db import IntegrityError, models, transaction -from django.db.models import BooleanField, Case, Count, ExpressionWrapper, F, IntegerField, Q, Sum, When +from django.db.models import BooleanField, Case, Count, ExpressionWrapper, F, IntegerField, Q, Sum, Value, When from django.db.models.functions import Greatest from django.urls import reverse from django.utils import timezone @@ -32,6 +32,35 @@ def get_perimeter_filter(siae): ) +def find_amount_ranges(amount, operation): + """ + Returns the keys from AMOUNT_RANGE that match a given operation on a specified amount. + + :param amount: The amount to compare against. + :param operation: The operation to perform ('lt', 'lte', 'gt', 'gte'). + :return: A list of matching keys. + """ + amount = int(amount) + if operation == "lt": + if amount < tender_constants.AMOUNT_RANGE_CHOICE_EXACT.get(tender_constants.AMOUNT_RANGE_0_1): + return [tender_constants.AMOUNT_RANGE_0_1] + return [key for key, value in tender_constants.AMOUNT_RANGE_CHOICE_EXACT.items() if value < amount] + elif operation == "lte": + if amount <= tender_constants.AMOUNT_RANGE_CHOICE_EXACT.get(tender_constants.AMOUNT_RANGE_0_1): + return [tender_constants.AMOUNT_RANGE_0_1] + return [key for key, value in tender_constants.AMOUNT_RANGE_CHOICE_EXACT.items() if value <= amount] + elif operation == "gt": + if amount >= tender_constants.AMOUNT_RANGE_CHOICE_EXACT.get(tender_constants.AMOUNT_RANGE_1000_MORE): + return [tender_constants.AMOUNT_RANGE_1000_MORE] + return [key for key, value in tender_constants.AMOUNT_RANGE_CHOICE_EXACT.items() if value > amount] + elif operation == "gte": + if amount >= tender_constants.AMOUNT_RANGE_CHOICE_EXACT.get(tender_constants.AMOUNT_RANGE_1000_MORE): + return [tender_constants.AMOUNT_RANGE_1000_MORE] + return [key for key, value in tender_constants.AMOUNT_RANGE_CHOICE_EXACT.items() if value >= amount] + else: + raise ValueError("Unrecognized operation. Use 'lt', 'lte', 'gt', or 'gte'.") + + class TenderQuerySet(models.QuerySet): def prefetch_many_to_many(self): return self.prefetch_related("sectors") # "perimeters", "siaes", "questions" @@ -78,7 +107,46 @@ def is_live(self): return self.sent().filter(deadline_date__gte=datetime.today()) def has_amount(self): - return self.filter(Q(amount__isnull=False) | Q(amount_exact__isnull=False)) + return self.filter(Q(amount__isnull=False) | Q(amount_exact__isnull=False)).annotate( + has_amount_exact=Case( + When(amount_exact__isnull=False, then=Value(True)), default=Value(False), output_field=BooleanField() + ) + ) + + def filter_by_amount_exact(self, amount: int, operation: str = "gte"): + """ + Filters records based on a monetary amount with a specified comparison operation. + It dynamically selects between filtering on an exact amount (`amount_exact`) + or predefined amount ranges when the exact amount is not available for a record. + + Supported operations are 'gte' (>=), 'gt' (>), 'lte' (<=), and 'lt' (<). + + Requires an annotated `has_amount_exact` in the queryset indicating the presence of `amount_exact`. + + Args: + amount (int): Amount to filter by, in the smallest currency unit (e.g., cents). + operation (str, optional): Comparison operation ('gte', 'gt', 'lte', 'lt'). Defaults to 'gte'. + + Returns: + QuerySet: Filtered queryset based on the amount and operation. + + Example: + >>> filtered_queryset = MyModel.objects.all().filter_by_amount_exact(5000, 'gte') + Filters for records with `amount_exact` >= 5000 or in the matching amount range. + """ + amounts_keys = find_amount_ranges(amount=amount, operation=operation) + queryset = self.has_amount() + filter_conditions = { + "gte": Q(has_amount_exact=True, amount_exact__gte=amount) + | Q(has_amount_exact=False, amount__in=amounts_keys), + "gt": Q(has_amount_exact=True, amount_exact__gt=amount) + | Q(has_amount_exact=False, amount__in=amounts_keys), + "lte": Q(has_amount_exact=True, amount_exact__lte=amount) + | Q(has_amount_exact=False, amount__in=amounts_keys), + "lt": Q(has_amount_exact=True, amount_exact__lt=amount) + | Q(has_amount_exact=False, amount__in=amounts_keys), + } + return queryset.filter(filter_conditions[operation]) def in_perimeters(self, post_code, department, region): filters = ( @@ -949,7 +1017,7 @@ class PartnerShareTenderQuerySet(models.QuerySet): def is_active(self): return self.filter(is_active=True) - def filter_by_amount(self, tender: Tender): + def filter_by_amount_exact(self, tender: Tender): """ Return partners with: - an empty 'amount_in' @@ -988,7 +1056,7 @@ def filter_by_perimeter(self, tender: Tender): return self.filter(conditions) def filter_by_tender(self, tender: Tender): - return self.is_active().filter_by_amount(tender).filter_by_perimeter(tender).distinct() + return self.is_active().filter_by_amount_exact(tender).filter_by_perimeter(tender).distinct() class PartnerShareTender(models.Model): diff --git a/lemarche/tenders/tests.py b/lemarche/tenders/tests.py index 8070fd418..1a24a5dc5 100644 --- a/lemarche/tenders/tests.py +++ b/lemarche/tenders/tests.py @@ -20,7 +20,7 @@ from lemarche.tenders import constants as tender_constants from lemarche.tenders.admin import TenderAdmin from lemarche.tenders.factories import PartnerShareTenderFactory, TenderFactory, TenderQuestionFactory -from lemarche.tenders.models import PartnerShareTender, Tender, TenderQuestion, TenderSiae +from lemarche.tenders.models import PartnerShareTender, Tender, TenderQuestion, TenderSiae, find_amount_ranges from lemarche.users.factories import UserFactory from lemarche.users.models import User from lemarche.utils.admin.admin_site import MarcheAdminSite, get_admin_change_view_url @@ -998,3 +998,39 @@ def test_duplicate(self): self.assertNotEqual(self.tender_with_siae.status, new_tender.status) self.assertEqual(self.tender_with_siae.sectors.count(), new_tender.sectors.count()) self.assertNotEqual(self.tender_with_siae.siaes.count(), new_tender.siaes.count()) + + +class FindAmountRangesTests(TestCase): + def test_gte_operation(self): + """Test the 'gte' operation.""" + expected_keys = [ + tender_constants.AMOUNT_RANGE_250_500, + tender_constants.AMOUNT_RANGE_500_750, + tender_constants.AMOUNT_RANGE_750_1000, + tender_constants.AMOUNT_RANGE_1000_MORE, + ] + self.assertListEqual(find_amount_ranges(250000, "gte"), expected_keys) + + def test_lt_operation(self): + """Test the 'lt' operation.""" + expected_keys = [ + tender_constants.AMOUNT_RANGE_0_1, + tender_constants.AMOUNT_RANGE_1_5, + tender_constants.AMOUNT_RANGE_5_10, + ] + self.assertListEqual(find_amount_ranges(10000, "lt"), expected_keys) + + def test_invalid_operation(self): + """Test using an invalid operation.""" + with self.assertRaises(ValueError): + find_amount_ranges(5000, "invalid_op") + + def test_edge_case(self): + """Test an edge case, such as exactly 1M€ for 'gt' operation.""" + expected_keys = [tender_constants.AMOUNT_RANGE_1000_MORE] + self.assertListEqual(find_amount_ranges(1000000, "gt"), expected_keys) + + def test_no_matching_ranges(self): + """Test when no ranges match the criteria.""" + expected_keys = [tender_constants.AMOUNT_RANGE_0_1] + self.assertListEqual(find_amount_ranges(100, "lte"), expected_keys)