From 961f86ef533ef2a5b808fdd0728f4fbdf7e684c7 Mon Sep 17 00:00:00 2001 From: "madjid.asa" Date: Mon, 19 Feb 2024 17:11:52 +0100 Subject: [PATCH 1/7] add filter in the admin for incremental tenders --- lemarche/tenders/admin.py | 19 ++++++++++++++--- lemarche/tenders/constants.py | 17 +++++++++++++++ lemarche/tenders/models.py | 40 +++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/lemarche/tenders/admin.py b/lemarche/tenders/admin.py index 70c8786d6..1dd772119 100644 --- a/lemarche/tenders/admin.py +++ b/lemarche/tenders/admin.py @@ -54,9 +54,22 @@ def queryset(self, request, queryset): return queryset -class AmountFilter(MultiChoice): - FILTER_LABEL = Tender._meta.get_field("amount").verbose_name +class AmountCustomFilter(admin.SimpleListFilter): + title = "Montant du besoin" + # FILTER_LABEL = Tender._meta.get_field("amount").verbose_name BUTTON_LABEL = "Appliquer" + parameter_name = "amount" + + def lookups(self, request, model_admin): + return (("<10k", "Inférieur (<) à 10k €"), (">=10k", "Supérieur (>=) à 10k €")) + + def queryset(self, request, queryset): + value = self.value() + amount = 10 * 10**3 # 10k + if value == "<10k": + return queryset.filter_by_amount(amount, operation="lt") + elif value == ">=10k": + return queryset.filter_by_amount(amount, operation="gte") class ResponseKindFilter(admin.SimpleListFilter): @@ -151,7 +164,7 @@ class TenderAdmin(FieldsetsInlineMixin, admin.ModelAdmin): ("scale_marche_useless", ScaleMarcheUselessFilter), ("source", SourceFilter), HasAmountFilter, - ("amount", AmountFilter), + AmountCustomFilter, "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..d0c85dfdc 100644 --- a/lemarche/tenders/models.py +++ b/lemarche/tenders/models.py @@ -32,6 +32,34 @@ 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) + import ipdb + + ipdb.set_trace() + if operation == "lt": + return [key for key, value in tender_constants.AMOUNT_RANGE_CHOICE_EXACT.items() if value < amount] + elif operation == "lte": + return [key for key, value in tender_constants.AMOUNT_RANGE_CHOICE_EXACT.items() if value <= amount] + elif operation == "gt": + if amount >= 10 * 10**5: + 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 >= 10 * 10**5: + 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" @@ -80,6 +108,18 @@ def is_live(self): def has_amount(self): return self.filter(Q(amount__isnull=False) | Q(amount_exact__isnull=False)) + def filter_by_amount(self, amount, operation: str = "gte"): + amounts_keys = find_amount_ranges(amount=amount, operation=operation) + queryset = self.has_amount() + if operation == "gte": + return queryset.filter(Q(amount__in=amounts_keys) | Q(amount_exact__gte=amount)) + elif operation == "gt": + return queryset.filter(Q(amount__in=amounts_keys) | Q(amount_exact__gt=amount)) + elif operation == "lte": + return queryset.filter(Q(amount__in=amounts_keys) | Q(amount_exact__lte=amount)) + elif operation == "lt": + return queryset.filter(Q(amount__in=amounts_keys) | Q(amount_exact__lt=amount)) + def in_perimeters(self, post_code, department, region): filters = ( Q(perimeters__post_codes__contains=[post_code]) From 662dd62b82c196b7c80c4f316c8976d7a3904e08 Mon Sep 17 00:00:00 2001 From: "madjid.asa" Date: Mon, 19 Feb 2024 17:53:27 +0100 Subject: [PATCH 2/7] add tests --- lemarche/tenders/tests.py | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/lemarche/tenders/tests.py b/lemarche/tenders/tests.py index 8070fd418..ececfe0b3 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, + ] + print("find_amount_ranges(250000", find_amount_ranges(250000, "gte")) + 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.""" + self.assertListEqual(find_amount_ranges(100, "gte"), [tender_constants.AMOUNT_RANGE_0_1]) From 1547f40e3a6cdf9fee2a22c3a7dad4069c8bf828 Mon Sep 17 00:00:00 2001 From: "madjid.asa" Date: Mon, 19 Feb 2024 18:13:39 +0100 Subject: [PATCH 3/7] fix mistake --- lemarche/tenders/models.py | 11 ++++++----- lemarche/tenders/tests.py | 3 +-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lemarche/tenders/models.py b/lemarche/tenders/models.py index d0c85dfdc..b14ccce41 100644 --- a/lemarche/tenders/models.py +++ b/lemarche/tenders/models.py @@ -41,19 +41,20 @@ def find_amount_ranges(amount, operation): :return: A list of matching keys. """ amount = int(amount) - import ipdb - - ipdb.set_trace() 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 >= 10 * 10**5: + 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 >= 10 * 10**5: + 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: diff --git a/lemarche/tenders/tests.py b/lemarche/tenders/tests.py index ececfe0b3..609397417 100644 --- a/lemarche/tenders/tests.py +++ b/lemarche/tenders/tests.py @@ -1009,7 +1009,6 @@ def test_gte_operation(self): tender_constants.AMOUNT_RANGE_750_1000, tender_constants.AMOUNT_RANGE_1000_MORE, ] - print("find_amount_ranges(250000", find_amount_ranges(250000, "gte")) self.assertListEqual(find_amount_ranges(250000, "gte"), expected_keys) def test_lt_operation(self): @@ -1033,4 +1032,4 @@ def test_edge_case(self): def test_no_matching_ranges(self): """Test when no ranges match the criteria.""" - self.assertListEqual(find_amount_ranges(100, "gte"), [tender_constants.AMOUNT_RANGE_0_1]) + self.assertListEqual(find_amount_ranges(100, "lte"), [tender_constants.AMOUNT_RANGE_0_1]) From 1d1e9d4b92d89105aef576cd1dfb55193825913c Mon Sep 17 00:00:00 2001 From: "madjid.asa" Date: Tue, 20 Feb 2024 10:16:44 +0100 Subject: [PATCH 4/7] improve Admin filter --- lemarche/tenders/admin.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lemarche/tenders/admin.py b/lemarche/tenders/admin.py index 1dd772119..b8e2b08a4 100644 --- a/lemarche/tenders/admin.py +++ b/lemarche/tenders/admin.py @@ -61,15 +61,21 @@ class AmountCustomFilter(admin.SimpleListFilter): parameter_name = "amount" def lookups(self, request, model_admin): - return (("<10k", "Inférieur (<) à 10k €"), (">=10k", "Supérieur (>=) à 10k €")) + return ( + ("<10k", "Inférieur (<) à 10k €"), + ("5k-10k", "Entre 5k et 10k €"), + (">=10k", "Supérieur (>=) à 10k €"), + ) def queryset(self, request, queryset): value = self.value() - amount = 10 * 10**3 # 10k + amount_10k = 10 * 10**3 # 10k if value == "<10k": - return queryset.filter_by_amount(amount, operation="lt") + return queryset.filter_by_amount(amount_10k, operation="lt") + elif value == "5k-10k": + return queryset.filter_by_amount(amount_10k, operation="lte").filter_by_amount(amount_10k, operation="gte") elif value == ">=10k": - return queryset.filter_by_amount(amount, operation="gte") + return queryset.filter_by_amount(amount_10k, operation="gte") class ResponseKindFilter(admin.SimpleListFilter): From 6ac4f10e13ac9ca7490de9218ffe776ce4632baa Mon Sep 17 00:00:00 2001 From: "madjid.asa" Date: Tue, 20 Feb 2024 10:26:45 +0100 Subject: [PATCH 5/7] improve filtering by amount --- lemarche/tenders/models.py | 49 +++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/lemarche/tenders/models.py b/lemarche/tenders/models.py index b14ccce41..64bcf06b8 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 @@ -107,19 +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(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' (<). - def filter_by_amount(self, amount, operation: str = "gte"): + 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(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() - if operation == "gte": - return queryset.filter(Q(amount__in=amounts_keys) | Q(amount_exact__gte=amount)) - elif operation == "gt": - return queryset.filter(Q(amount__in=amounts_keys) | Q(amount_exact__gt=amount)) - elif operation == "lte": - return queryset.filter(Q(amount__in=amounts_keys) | Q(amount_exact__lte=amount)) - elif operation == "lt": - return queryset.filter(Q(amount__in=amounts_keys) | Q(amount_exact__lt=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 = ( From 444e312a6bf7db42f888bb51cd2dc9437d1d898d Mon Sep 17 00:00:00 2001 From: "madjid.asa" Date: Tue, 20 Feb 2024 10:33:13 +0100 Subject: [PATCH 6/7] update order of filter --- lemarche/tenders/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lemarche/tenders/admin.py b/lemarche/tenders/admin.py index b8e2b08a4..18ed274c0 100644 --- a/lemarche/tenders/admin.py +++ b/lemarche/tenders/admin.py @@ -164,13 +164,13 @@ class TenderAdmin(FieldsetsInlineMixin, admin.ModelAdmin): ] list_filter = [ + AmountCustomFilter, ("kind", KindFilter), AuthorKindFilter, "status", ("scale_marche_useless", ScaleMarcheUselessFilter), ("source", SourceFilter), HasAmountFilter, - AmountCustomFilter, "deadline_date", "start_working_date", ResponseKindFilter, From f3201e404f43378eeaf7559db49aa7967c98b3d9 Mon Sep 17 00:00:00 2001 From: "madjid.asa" Date: Thu, 22 Feb 2024 09:49:25 +0100 Subject: [PATCH 7/7] clean from review --- lemarche/tenders/admin.py | 10 +++++----- lemarche/tenders/models.py | 8 ++++---- lemarche/tenders/tests.py | 3 ++- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/lemarche/tenders/admin.py b/lemarche/tenders/admin.py index 18ed274c0..0f64b4cac 100644 --- a/lemarche/tenders/admin.py +++ b/lemarche/tenders/admin.py @@ -56,8 +56,6 @@ def queryset(self, request, queryset): class AmountCustomFilter(admin.SimpleListFilter): title = "Montant du besoin" - # FILTER_LABEL = Tender._meta.get_field("amount").verbose_name - BUTTON_LABEL = "Appliquer" parameter_name = "amount" def lookups(self, request, model_admin): @@ -71,11 +69,13 @@ def queryset(self, request, queryset): value = self.value() amount_10k = 10 * 10**3 # 10k if value == "<10k": - return queryset.filter_by_amount(amount_10k, operation="lt") + return queryset.filter_by_amount_exact(amount_10k, operation="lt") elif value == "5k-10k": - return queryset.filter_by_amount(amount_10k, operation="lte").filter_by_amount(amount_10k, operation="gte") + 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(amount_10k, operation="gte") + return queryset.filter_by_amount_exact(amount_10k, operation="gte") class ResponseKindFilter(admin.SimpleListFilter): diff --git a/lemarche/tenders/models.py b/lemarche/tenders/models.py index 64bcf06b8..23b968be1 100644 --- a/lemarche/tenders/models.py +++ b/lemarche/tenders/models.py @@ -113,7 +113,7 @@ def has_amount(self): ) ) - def filter_by_amount(self, amount: int, operation: str = "gte"): + 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`) @@ -131,7 +131,7 @@ def filter_by_amount(self, amount: int, operation: str = "gte"): QuerySet: Filtered queryset based on the amount and operation. Example: - >>> filtered_queryset = MyModel.objects.all().filter_by_amount(5000, 'gte') + >>> 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) @@ -1017,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' @@ -1056,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 609397417..1a24a5dc5 100644 --- a/lemarche/tenders/tests.py +++ b/lemarche/tenders/tests.py @@ -1032,4 +1032,5 @@ def test_edge_case(self): def test_no_matching_ranges(self): """Test when no ranges match the criteria.""" - self.assertListEqual(find_amount_ranges(100, "lte"), [tender_constants.AMOUNT_RANGE_0_1]) + expected_keys = [tender_constants.AMOUNT_RANGE_0_1] + self.assertListEqual(find_amount_ranges(100, "lte"), expected_keys)