Skip to content

Commit

Permalink
feat(Admin): Ajout d'un filtre pour retrouver les dépôts de besoins i…
Browse files Browse the repository at this point in the history
…ncrémentaux (#1090)

* add filter in the admin for incremental tenders

* add tests

* fix mistake

* improve Admin filter

* improve filtering by amount

* update order of filter

* clean from review
  • Loading branch information
madjid-asa authored Feb 22, 2024
1 parent 1c5a9ed commit e53be2c
Show file tree
Hide file tree
Showing 4 changed files with 149 additions and 9 deletions.
27 changes: 23 additions & 4 deletions lemarche/tenders/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions lemarche/tenders/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
76 changes: 72 additions & 4 deletions lemarche/tenders/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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):
Expand Down
38 changes: 37 additions & 1 deletion lemarche/tenders/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

0 comments on commit e53be2c

Please sign in to comment.