Skip to content
Draft
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
87 changes: 87 additions & 0 deletions backend/tests/test_contact_group_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import pytest
from django.db import IntegrityError
from users.contact_group import ContactGroup
from users.models import User


def _make_user(phone: str) -> User:
return User.objects.create_user(
phone_number=phone, password="testpass123", first_name="Test", last_name="Member"
)


@pytest.mark.django_db
class TestContactGroupModel:
def test_create_group(self):
owner = _make_user("+15550002001")
group = ContactGroup.objects.create(owner=owner, name="board game group")
assert group.id is not None
assert group.owner == owner
assert group.name == "board game group"
assert group.created_at is not None
assert group.updated_at is not None
assert group.members.count() == 0
assert str(group) == "board game group"

def test_add_and_remove_members(self):
owner = _make_user("+15550002002")
alice = _make_user("+15550002003")
bob = _make_user("+15550002004")
group = ContactGroup.objects.create(owner=owner, name="movie club")

group.members.add(alice, bob)
assert set(group.members.all()) == {alice, bob}

group.members.remove(alice)
assert set(group.members.all()) == {bob}

def test_owner_reverse_relation(self):
owner = _make_user("+15550002005")
ContactGroup.objects.create(owner=owner, name="dinner crew")
ContactGroup.objects.create(owner=owner, name="walk group")
assert owner.contact_groups.count() == 2

def test_membership_reverse_relation(self):
owner = _make_user("+15550002006")
member = _make_user("+15550002007")
group = ContactGroup.objects.create(owner=owner, name="hiking")
group.members.add(member)
assert list(member.contact_group_memberships.all()) == [group]

def test_owner_scoping_isolates_groups(self):
owner_a = _make_user("+15550002008")
owner_b = _make_user("+15550002009")
ContactGroup.objects.create(owner=owner_a, name="a-group")
ContactGroup.objects.create(owner=owner_b, name="b-group")
assert list(ContactGroup.objects.filter(owner=owner_a).values_list("name", flat=True)) == [
"a-group"
]

def test_name_unique_per_owner(self):
owner = _make_user("+15550002010")
ContactGroup.objects.create(owner=owner, name="duplicate")
with pytest.raises(IntegrityError):
ContactGroup.objects.create(owner=owner, name="duplicate")

def test_same_name_allowed_for_different_owners(self):
owner_a = _make_user("+15550002011")
owner_b = _make_user("+15550002012")
ContactGroup.objects.create(owner=owner_a, name="shared name")
ContactGroup.objects.create(owner=owner_b, name="shared name")
assert ContactGroup.objects.filter(name="shared name").count() == 2

def test_deleting_owner_cascades(self):
owner = _make_user("+15550002013")
ContactGroup.objects.create(owner=owner, name="temp")
owner.delete()
assert ContactGroup.objects.count() == 0

def test_deleting_member_drops_membership_not_group(self):
owner = _make_user("+15550002014")
member = _make_user("+15550002015")
group = ContactGroup.objects.create(owner=owner, name="keep me")
group.members.add(member)
member.delete()
group.refresh_from_db()
assert ContactGroup.objects.filter(id=group.id).exists()
assert group.members.count() == 0
10 changes: 10 additions & 0 deletions backend/users/admin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin

from users.contact_group import ContactGroup
from users.models import User
from users.roles import PROTECTED_ROLE_NAMES, Role

Expand Down Expand Up @@ -54,3 +55,12 @@ def has_delete_permission(self, request, obj=None):
if obj and obj.name in PROTECTED_ROLE_NAMES:
return False
return super().has_delete_permission(request, obj)


@admin.register(ContactGroup)
class ContactGroupAdmin(admin.ModelAdmin):
list_display = ("name", "owner", "created_at")
search_fields = ("name", "owner__phone_number", "owner__display_name")
raw_id_fields = ("owner",)
filter_horizontal = ("members",)
readonly_fields = ("id", "created_at", "updated_at")
34 changes: 34 additions & 0 deletions backend/users/contact_group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import uuid

from django.conf import settings
from django.db import models


class ContactGroup(models.Model):
"""A member-owned, named set of people for batch event invites."""

id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="contact_groups",
)
name = models.CharField(max_length=100)
members = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name="contact_group_memberships",
blank=True,
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

class Meta:
ordering = ["name"]
constraints = [
models.UniqueConstraint(
fields=["owner", "name"], name="unique_contact_group_name_per_owner"
),
]

def __str__(self) -> str:
return self.name
54 changes: 54 additions & 0 deletions backend/users/migrations/0036_contactgroup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Generated by Django 6.0.3 on 2026-07-13 10:30

import uuid

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("users", "0035_user_hide_last_name"),
]

operations = [
migrations.CreateModel(
name="ContactGroup",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
("name", models.CharField(max_length=100)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"members",
models.ManyToManyField(
blank=True,
related_name="contact_group_memberships",
to=settings.AUTH_USER_MODEL,
),
),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="contact_groups",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["name"],
"constraints": [
models.UniqueConstraint(
fields=("owner", "name"), name="unique_contact_group_name_per_owner"
)
],
},
),
]
3 changes: 3 additions & 0 deletions backend/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
from django.utils import timezone

from users._name_parsing import sync_display_name
from users.contact_group import (
ContactGroup, # noqa: F401 — re-exported so Django discovers it in the users app
)
from users.roles import Role # noqa: F401 — re-exported so Django discovers it in the users app


Expand Down