Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Admin): Ajout d'un filtre pour retrouver les dépôts par leur montant #1090

Merged
merged 7 commits into from
Feb 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo ici : je corrige dans #1112

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