diff --git a/src/meshapi/admin/member.py b/src/meshapi/admin/member.py index 80f2f34f6..7c1bb67b2 100644 --- a/src/meshapi/admin/member.py +++ b/src/meshapi/admin/member.py @@ -27,6 +27,7 @@ class MemberAdmin(admin.ModelAdmin): "stripe_email_address__icontains", "additional_email_addresses__icontains", "phone_number__icontains", + "additional_phone_numbers__icontains", "slack_handle__icontains", # Search by building details "installs__building__street_address__icontains", @@ -71,6 +72,7 @@ class MemberAdmin(admin.ModelAdmin): { "fields": [ "phone_number", + "additional_phone_numbers", "slack_handle", ] }, diff --git a/src/meshapi/management/commands/scramble_members.py b/src/meshapi/management/commands/scramble_members.py index 2e382be2e..7e2d23232 100644 --- a/src/meshapi/management/commands/scramble_members.py +++ b/src/meshapi/management/commands/scramble_members.py @@ -45,6 +45,7 @@ def handle(self, *args: Any, **options: Any) -> None: member.stripe_email_address = "" member.additional_email_addresses = [] member.phone_number = fake.phone_number() + member.additional_phone_numbers = [] if randint(0, 100) > 0 else [fake.phone_number()] member.slack_handle = "" member.notes = fake.text() member.save() diff --git a/src/meshapi/migrations/0009_member_additional_phone_numbers_and_more.py b/src/meshapi/migrations/0009_member_additional_phone_numbers_and_more.py new file mode 100644 index 000000000..73fad6b25 --- /dev/null +++ b/src/meshapi/migrations/0009_member_additional_phone_numbers_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.10 on 2024-06-01 23:49 + +import django_jsonform.models.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("meshapi", "0008_alter_building_notes_alter_install_notes_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="member", + name="additional_phone_numbers", + field=django_jsonform.models.fields.ArrayField( + base_field=models.CharField(), + blank=True, + default=list, + help_text="Any additional phone numbers used by this member", + null=True, + size=None, + ), + ), + migrations.AlterField( + model_name="member", + name="phone_number", + field=models.CharField( + blank=True, default=None, help_text="A primary contact phone number for this member", null=True + ), + ), + ] diff --git a/src/meshapi/models/member.py b/src/meshapi/models/member.py index 6aff4d601..d73b687cf 100644 --- a/src/meshapi/models/member.py +++ b/src/meshapi/models/member.py @@ -22,7 +22,14 @@ class Member(models.Model): help_text="Any additional email addresses associated with this member", ) phone_number = models.CharField( - default=None, blank=True, null=True, help_text="A contact phone number for this member" + default=None, blank=True, null=True, help_text="A primary contact phone number for this member" + ) + additional_phone_numbers = JSONFormArrayField( + models.CharField(), + default=list, + null=True, + blank=True, + help_text="Any additional phone numbers used by this member", ) slack_handle = models.CharField(default=None, blank=True, null=True, help_text="The member's slack handle") notes = models.TextField( @@ -55,3 +62,16 @@ def all_email_addresses(self) -> List[str]: all_emails.append(email) return all_emails + + @property + def all_phone_numbers(self) -> List[str]: + all_phone_numbers = [] + if self.phone_number and self.phone_number not in all_phone_numbers: + all_phone_numbers.append(self.phone_number) + + if self.additional_phone_numbers: + for phone_number in self.additional_phone_numbers: + if phone_number not in all_phone_numbers: + all_phone_numbers.append(phone_number) + + return all_phone_numbers diff --git a/src/meshapi/serializers/model_api.py b/src/meshapi/serializers/model_api.py index f1cd93cf2..82c10054f 100644 --- a/src/meshapi/serializers/model_api.py +++ b/src/meshapi/serializers/model_api.py @@ -24,6 +24,7 @@ class Meta: fields = "__all__" all_email_addresses: serializers.ReadOnlyField = serializers.ReadOnlyField() + all_phone_numbers: serializers.ReadOnlyField = serializers.ReadOnlyField() installs: serializers.PrimaryKeyRelatedField = serializers.PrimaryKeyRelatedField(many=True, read_only=True) diff --git a/src/meshapi/serializers/query_api.py b/src/meshapi/serializers/query_api.py index 3a1f56f4d..aa1f971a2 100644 --- a/src/meshapi/serializers/query_api.py +++ b/src/meshapi/serializers/query_api.py @@ -21,6 +21,7 @@ class Meta: "zip_code", "name", "phone_number", + "additional_phone_numbers", "primary_email_address", "stripe_email_address", "additional_email_addresses", @@ -36,6 +37,9 @@ class Meta: name = serializers.CharField(source="member.name") phone_number = serializers.CharField(source="member.phone_number") + additional_phone_numbers = serializers.ListField( + source="member.additional_phone_numbers", child=serializers.CharField() + ) network_number = serializers.IntegerField(source="node.network_number", allow_null=True) diff --git a/src/meshapi/tests/test_lookups.py b/src/meshapi/tests/test_lookups.py index 58d4dac7a..0861a0164 100644 --- a/src/meshapi/tests/test_lookups.py +++ b/src/meshapi/tests/test_lookups.py @@ -26,6 +26,7 @@ def setUp(self): stripe_email_address="donny.stripe@example.com", additional_email_addresses=["donny.addl@example.com"], phone_number="555-555-6666", + additional_phone_numbers=["123-555-8888"], ) m2.save() @@ -105,6 +106,19 @@ def test_member_phone_search(self): self.assertEqual(len(response_objs), 1) self.assertEqual(response_objs[0]["name"], "Donald Smith") + def test_member_additional_phone_search(self): + response = self.c.get("/api/v1/members/lookup/?phone_number=8888") + code = 200 + self.assertEqual( + code, + response.status_code, + f"status code incorrect. Should be {code}, but got {response.status_code}", + ) + + response_objs = json.loads(response.content)["results"] + self.assertEqual(len(response_objs), 1) + self.assertEqual(response_objs[0]["name"], "Donald Smith") + def test_member_combined_search(self): response = self.c.get("/api/v1/members/lookup/?phone_number=555&email_address=smith&name=don") code = 200 diff --git a/src/meshapi/tests/test_member.py b/src/meshapi/tests/test_member.py index 882e68640..666fbc714 100644 --- a/src/meshapi/tests/test_member.py +++ b/src/meshapi/tests/test_member.py @@ -52,6 +52,30 @@ def test_member_all_emails_field(self): ["foo@example.com", "stripe@example.com", "bar@example.com", "baz@example.com"], ) + def test_member_all_phone_numbers_field(self): + test_member = Member( + name="Stacy Fakename", + primary_email_address="foo@example.com", + phone_number="+1 123-555-5555", + additional_phone_numbers=["+1 456-555-6666"], + ) + test_member.save() + + response = self.c.get(f"/api/v1/members/{test_member.id}/") + code = 200 + self.assertEqual( + code, + response.status_code, + f"status code incorrect. Should be {code}, but got {response.status_code}", + ) + + response_obj = json.loads(response.content) + self.assertEqual(response_obj["name"], "Stacy Fakename") + self.assertEqual(response_obj["primary_email_address"], "foo@example.com") + self.assertEqual(response_obj["phone_number"], "+1 123-555-5555") + self.assertEqual(response_obj["additional_phone_numbers"], ["+1 456-555-6666"]) + self.assertEqual(response_obj["all_phone_numbers"], ["+1 123-555-5555", "+1 456-555-6666"]) + def test_broken_member(self): err_member = { "id": "Error", diff --git a/src/meshapi/views/lookups.py b/src/meshapi/views/lookups.py index a4995a5ab..229339e4f 100644 --- a/src/meshapi/views/lookups.py +++ b/src/meshapi/views/lookups.py @@ -41,7 +41,7 @@ def get(self, request: Request, *args: Any, **kwargs: Any) -> Response: class MemberFilter(filters.FilterSet): name = filters.CharFilter(field_name="name", lookup_expr="icontains") email_address = filters.CharFilter(method="filter_on_all_emails") - phone_number = filters.CharFilter(field_name="phone_number", lookup_expr="icontains") + phone_number = filters.CharFilter(method="filter_on_all_phone_numbers") def filter_on_all_emails(self, queryset: QuerySet[Member], field_name: str, value: str) -> QuerySet[Member]: return queryset.filter( @@ -50,6 +50,9 @@ def filter_on_all_emails(self, queryset: QuerySet[Member], field_name: str, valu | Q(additional_email_addresses__icontains=value) ) + def filter_on_all_phone_numbers(self, queryset: QuerySet[Member], field_name: str, value: str) -> QuerySet[Member]: + return queryset.filter(Q(phone_number__icontains=value) | Q(additional_phone_numbers__icontains=value)) + class Meta: model = Member fields: List[Any] = [] @@ -78,7 +81,7 @@ class Meta: "phone_number", OpenApiTypes.STR, OpenApiParameter.QUERY, - description="Filter members by the phone_number field using case-insensitve substring matching", + description="Filter members by any of the phone number fields using case-insensitve substring matching", required=False, ), ], diff --git a/src/meshapi/views/query_api.py b/src/meshapi/views/query_api.py index f2b490fc0..cd8552eab 100644 --- a/src/meshapi/views/query_api.py +++ b/src/meshapi/views/query_api.py @@ -22,7 +22,7 @@ class QueryMemberFilter(filters.FilterSet): name = filters.CharFilter(field_name="member.name", lookup_expr="icontains") email_address = filters.CharFilter(method="filter_on_all_emails") - phone_number = filters.CharFilter(field_name="member.phone_number", lookup_expr="icontains") + phone_number = filters.CharFilter(method="filter_on_all_phone_numbers") def filter_on_all_emails(self, queryset: QuerySet[Member], field_name: str, value: str) -> QuerySet[Member]: return queryset.filter( @@ -31,6 +31,9 @@ def filter_on_all_emails(self, queryset: QuerySet[Member], field_name: str, valu | Q(member__additional_email_addresses__icontains=value) ) + def filter_on_all_phone_numbers(self, queryset: QuerySet[Member], name: str, value: str) -> QuerySet[Member]: + return queryset.filter(Q(phone_number__icontains=value) | Q(additional_phone_numbers__icontains=value)) + class Meta: model = Install fields: List[str] = []