Skip to content

Commit

Permalink
[#173] Update Pet and Profile models to improve data handling
Browse files Browse the repository at this point in the history
Implement the following changes:

- Convert `pet_type` values to lowercase on save in `Pet` model
- Order `Profile` objects by creation date, oldest first
- Add `get_default_profile()` method to `User` model
- Allow reconciling pets for a profile via `reconcile_pets` action
- Refine `PetsForm` component and client pet schema

Rationale:

- Ensure consistent `pet_type` values by converting to lowercase
- Order profiles by creation date to determine the "main" profile easily
- Provide a convenient method to retrieve the default/first profile
- Enable synchronizing pet data between client and server efficiently
- Improve form UI and validation for better user experience

Benefits:

- Data integrity and consistency
- Intuitive profile ordering and retrieval
- Streamlined pet data management
- Enhanced form usability and validation
  • Loading branch information
delano committed Jul 1, 2024
1 parent 731eb3d commit 4dc9819
Show file tree
Hide file tree
Showing 8 changed files with 297 additions and 177 deletions.
14 changes: 9 additions & 5 deletions apps/api/afbcore/models/pet.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ class Pet(HasDetailsMixin, BaseAbstractModel):
"""
Pet model to store information about pets belonging to a profile.
The maximum number of pet profiles that would be allowed
to be created would be deteremined from the Branch
setting of "Number of Pet's Serviced/Houeshold" above
The maximum number of pet profiles that would be allowed to be
created would be deteremined from the Branch setting of "Number
of Pet's Serviced/Houeshold". The default is 4.
"""

Expand All @@ -36,12 +36,16 @@ class Pet(HasDetailsMixin, BaseAbstractModel):
blank=True,
)

pet_type = models.CharField(max_length=50) # e.g. "Dog", "Cat", etc.
pet_type = models.CharField(max_length=50) # e.g. "dog", "cat", etc.
pet_name = models.CharField(max_length=50) # e.g. "Frankie"
pet_dob = models.CharField(max_length=10) # date of birth

food_details = JSONField(default=dict) # JSON blob
dog_details = JSONField(default=dict) # JSON blob

def save(self, *args, **kwargs):
self.pet_type = self.pet_type.lower()
super().save(*args, **kwargs)

def __str__(self):
return self.pet_name
return "%s (%s)" % (self.pet_name, self.pet_type)
6 changes: 4 additions & 2 deletions apps/api/afbcore/models/users/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ class Profile(HasDetailsMixin, BaseAbstractModel):
"""

class Meta:
ordering = ["-created"]
# Order by oldest to newest by default so that the first
# profile created is always the "main" profile.
ordering = ["created"]

id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(
Expand Down Expand Up @@ -150,4 +152,4 @@ class Meta:
)

def __str__(self):
return f"{self.preferred_name}"
return f"{self.user}/{self.id}"
6 changes: 6 additions & 0 deletions apps/api/afbcore/models/users/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,9 @@ class User(UUIDModel, TimeStampedModel, HasDetailsMixin, AbstractUser):
"Indicates whether the user has agreed to the terms of service."
),
)

def __str__(self):
return "%s" % self.id

def get_default_profile(self):
return self.profiles.first()
7 changes: 3 additions & 4 deletions apps/api/afbcore/tests/models/test_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,18 @@ def setUpTestData(cls):
validated_postal_code="12345",
country="USA",
status="active",
branch=cls.Branch,
)

cls.profile.branches.add(cls.Branch)
cls.profile.delivery_regions.add(cls.DeliveryRegion)

def test_profile_user_relation(self):
profile = Profile.objects.get(id=self.profile.id)
self.assertEqual(profile.user, self.user)

def test_profile_branches_relation(self):
def test_profile_branch_relation(self):
profile = Profile.objects.get(id=self.profile.id)
self.assertEqual(profile.branches.count(), 1)
self.assertEqual(profile.branches.first(), self.Branch)
self.assertEqual(profile.branch, self.Branch)

def test_profile_delivery_regions_relation(self):
profile = Profile.objects.get(id=self.profile.id)
Expand Down
5 changes: 5 additions & 0 deletions apps/api/afbcore/tests/views/test_pet_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@

User = get_user_model()

"""
How to run the tests:
- pnpm django:test apps/api/afbcore/tests/views/test_pet_viewset.py
"""


class PetViewSetTestCase(APITestCase):
def setUp(self):
Expand Down
139 changes: 118 additions & 21 deletions apps/api/afbcore/views/profile_viewset.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,52 @@
import logging

from django.contrib.auth import get_user_model
from django.db import transaction
from rest_framework import permissions, status, viewsets
from rest_framework.authentication import TokenAuthentication
from rest_framework.decorators import action
from rest_framework.exceptions import MethodNotAllowed
from rest_framework.response import Response

from ..models import Profile
from ..serializers import ProfileSerializer
from ..models import Pet, Profile
from ..permissions import IsOwner
from ..serializers import PetSerializer, ProfileSerializer
from .base import UserFilterBaseViewSet

User = get_user_model()

logger = logging.getLogger(__name__)


# Example of a viewset with custom actions.
#
"""
The ProfileViewSet class is a viewset that provides CRUD operations
for the Profile model and is limited to profile(s) associated with
the currently logged in user. Even though multiple profiles can be
associated with a user, currently the application only supports one
profile per user (as of summer 2024).
About permissions:
The ProfileViewSet uses the IsOwner permission class to limit
access to the profile(s) associated with the currently logged
in user.
About updating pets:
The reconcile_pets method allows for adding, updating, and
removing pets in a single operation, which is useful for
synchronizing pet data with a client application.
Example data in a POST request::
{
"pets": [
{"id": "existing-pet-id", "pet_name": "Updated Name", "pet_type": "dog", "pet_dob": "2020-01-01"},
{"pet_name": "New Pet", "pet_type": "Cat", "pet_dob": "2021-05-15"}
]
}
"""


class ProfileViewSet(UserFilterBaseViewSet):
"""
API endpoint for the Profile CRUD operations, limited to Profiles
Expand All @@ -26,23 +57,89 @@ class ProfileViewSet(UserFilterBaseViewSet):
serializer_class = ProfileSerializer # must be a class, not string
permission_classes = [
permissions.IsAuthenticated,
IsOwner,
]
authentication_classes = [TokenAuthentication]

# def get(self, request, version=None, *args, **kwargs):
# """
# Retrieve the current authenticated user.
# """
# logger.debug("API Version: #{version}")
# serializer = self.get_serializer(
# request.user, context={"request": request}
# )
# return Response(serializer.data)

# def update(self, request, *args, **kwargs):
# partial = kwargs.pop('partial', False)
# instance = self.get_object()
# serializer = self.get_serializer(instance, data=request.data, partial=partial)
# serializer.is_valid(raise_exception=True)
# self.perform_update(serializer)
# return Response(serializer.data)
def get_queryset(self):
"""
Limit the available profiles to ones associated with the
currently authenticated user.
"""
return Profile.objects.filter(user=self.request.user)

def destroy(self, request, *args, **kwargs):
raise MethodNotAllowed("DELETE")

@action(detail=True, methods=["post"])
@transaction.atomic
def reconcile_pets(self, request, pk=None, version=None):
"""
Reconcile the pets for a specific profile.
Allows for adding, updating, and removing pets in a single
operation. This method will:
1. Add new pets that don't exist
2. Update existing pets
3. Remove pets that are not in the provided list
It processes each pet in the list:
- If the pet has an ID, it updates the existing pet.
- If the pet doesn't have an ID, it creates a new pet.
- Removes any pets that were associated with the profile
but not included in the request data.
Uses @transaction.atomic to ensure that all database operations
are performed in a single transaction. e.g. the changes are
either all saved or all rolled back in case of an error.
"""
profile = self.get_object()
pets_data = request.data.get("pets", [])

if not isinstance(pets_data, list):
return Response(
{"error": "Pets data must be a list"},
status=status.HTTP_400_BAD_REQUEST,
)

# Create a set of existing pet IDs for this profile
existing_pet_ids = set(
str(id) for id in profile.pets.values_list("id", flat=True)
)

# Process each pet in the request
processed_pet_ids = set()
for pet_data in pets_data:
pet_id = pet_data.get("id")

if pet_id:
# Update existing pet
try:
pet = profile.pets.get(id=pet_id)
serializer = PetSerializer(pet, data=pet_data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
processed_pet_ids.add(str(pet_id))
except Pet.DoesNotExist:
return Response(
{
"error": f"Pet with id {pet_id} does not exist for this profile"
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
# Create new pet
serializer = PetSerializer(data=pet_data)
serializer.is_valid(raise_exception=True)
pet = serializer.save(profile=profile)
processed_pet_ids.add(str(pet.id))

# Remove pets that were not in the request
pets_to_remove = existing_pet_ids - processed_pet_ids
profile.pets.filter(id__in=pets_to_remove).delete()

# Return updated profile with reconciled pets
serializer = self.get_serializer(profile)
return Response(serializer.data)
6 changes: 3 additions & 3 deletions apps/ui/components/PetsForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ try {
pets: event.data.pets,
}
const petsPath = `/api/v1/profiles/${profileData.id}/pets/`
const petsPath = `/api/v1/pets/`
// Make the API call
const response = await $fetch(petsPath, {
Expand Down Expand Up @@ -122,8 +122,8 @@ onMounted(() => {
icon: 'i-heroicons-check-circle',
},
onClick: (event: FormSubmitEvent<any>) => {
event.form.setLoading('save', true);
onSubmit(event);
//event.form.setLoading('save', true);
//onSubmit(event);
},
},
Expand Down
Loading

0 comments on commit 4dc9819

Please sign in to comment.