Django Models Polymorphism and Foreign Keys

1k views Asked by At

I have 3 different kinds of users in my application.

  1. Customer that goes on, finds appointments, and books appointments.
  2. Individual that can create appointments for customers to sign up for, and collect payments for the appointments
  3. Organization that can create appointments for customers to sign up for, collect payments for appointments, and provide links to individual providers that are employed in the organization (i.e. users in group 2 above)

I see types 1 and 2 having some overlap in that they are both individuals, and have fields like gender and date of birth that 3 does not, whereas 2 and 3 have overlap in that they are able to create appointments and collect payments.

I have structured my model classes as such:

class BaseProfileModel(models.Model):
    user = models.OneToOneField(User, related_name="profile", primary_key=True)
    phone = PhoneNumberField(verbose_name="Phone Number")
    pic = models.ImageField(upload_to=get_upload_file_name,
                            width_field="width_field",
                            height_field="height_field",
                            null=True,
                            blank=True,
                            verbose_name="Profile Picture"
                           )
    height_field = models.PositiveIntegerField(null=True, default=0)
    width_field = models.PositiveIntegerField(null=True, default=0)
    thumbnail = ImageSpecField(source='pic',
                                   processors=[ResizeToFill(180,180)],
                                   format='JPEG',
                                   options={'quality': 100})
    bio = models.TextField(
        verbose_name="About",
        default="",
        blank=True,
        max_length=800
    )
    is_provider=False

    class Meta:
        abstract = True

    def __str__(self):
        if self.user.email:
            return self.user.email
        else:
            return self.user.username

    @property
    def thumbnail_url(self):
        """
        Returns the URL of the image associated with this Object.
        If an image hasn't been uploaded yet, it returns a stock image

        :returns: str -- the image url

        """
        if self.pic and hasattr(self.pic, 'url'):
            return self.thumbnail.url
        else:
            # Return url for default thumbnail
            # Make it the size of a thumbnail
            return '/media/StockImage.png'

    @property 
    def image_url(self):
        if self.pic and hasattr(self.pic, 'url'):
            return self.pic.url
        else:
            # Return url for full sized stock image
            return '/media/StockImage.png'

    def get_absolute_url(self):
        return reverse_lazy(self.profile_url_name, kwargs={'pk': self.pk})

class BaseHumanUserModel(BaseProfileModel):
    birth_date = models.DateField(verbose_name="Date of Birth", null=True, blank=True)
    GENDER_CHOICES = (
        ('M', 'Male'),
        ('F', 'Female'),
        ('N', 'Not Specified'),
    )
    gender = models.CharField(
        max_length=1, choices=GENDER_CHOICES, blank=False, default='N', verbose_name='Gender')

    class Meta:
        abstract = True

class BaseProviderModel(models.Model):
    stripe_access_token = models.TextField(blank=True, default='')
    is_provider=True

    class Meta:
        abstract = True

    def rating(self):
        avg = self.reviews.aggregate(Avg('rating'))
        return avg['rating__avg']

    def rounded_rating(self):
        avg = self.rating()
        return round(avg * 2) / 2

    # More methods...


class IndividualProviderProfile(BaseProviderModel, BaseHumanUserModel):
    locations = models.ManyToManyField(Location, null=True, blank=True, related_name='providers')
    specialties = models.CharField(
        verbose_name = "Specialties",
        max_length=200,
        blank=True,
    )
    certifications = models.CharField(
        verbose_name = "Certifications", max_length=200,
        blank=True, null=True
    )
    self.profile_url_name = 'profiles:individual_provider_profile'

    def certifications_as_list(self):
        return ''.join(self.certifications.split()).split(',')

    def specialties_as_list(self):
        return ''.join(self.specialties.split()).split(',')


class CustomerProfile(BaseHumanUserModel):
    home_location = models.OneToOneField(
        Location,
        related_name='customer',
        null=True,
        blank=True,
        on_delete=models.SET_NULL
        )
    self.profile_url_name = 'profiles:customer_profile'

    # More methods...

class OrganizationProviderProfile(BaseProviderModel):
    website = models.URLField(blank=True)
    location = models.ForeignKey(Location)
    employees = models.ManyToManyField(IndividualProviderProfile)
    self.profile_url_name = 'profiles:organization_provider_profile'

    # More methods

I am wondering a few things:

Does this separation of models into different classes make sense? Or would it be better to do something like making providers into one model, individual or not, and just leaving some fields as blank and a field specifying provider type? This just seems like a mess to me.

However, I'm seeing an issue with the way that I want to do things when it comes to ForeignKey relationships. I want users to be able to leave reviews on providers, which would require a foreign key to the provider. If they are different model classes then one ForeignKey will not cut it, unless I use the django contenttypes framework, which I haven't really looked into much. GenericForeignKeys seem like the way to go, unless this is bad practice to use a GenericForeignKey that is really only meant for two classes. So my question, for someone who has worked with the contenttypes framework before (or someone who has had a similar predicament), it be bad practice, and/or could my code end up getting messy, if I set up my models like this and use generic foreign keys to assign relationships to providers?

EDIT

Upon reconsidering, maybe this would be a better structure: Let me know what you think vs the above:

Leave the BaseProfileModel, BaseHumanUserModel, and CustomerProfileModel the same as above, and change the following to have OneToOne relationships

class ProviderDetails(models.Model):
    stripe_access_token = models.TextField(blank=True, default='')

    def rating(self):
        avg = self.reviews.aggregate(Avg('rating'))
        return avg['rating__avg']

    def rounded_rating(self):
        avg = self.rating()
        return round(avg * 2) / 2

    # More methods...


class IndividualProviderProfile(BaseHumanUserModel):
    provider_details = models.OneToOneField(ProviderDetails, related_name='profile')
    locations = models.ManyToManyField(Location, null=True, blank=True, related_name='providers')
    specialties = models.CharField(
        verbose_name = "Specialties",
        max_length=200,
        blank=True,
    )
    certifications = models.CharField(
        verbose_name = "Certifications", max_length=200,
        blank=True, null=True
    )
    self.profile_url_name = 'profiles:individual_provider_profile'

    def certifications_as_list(self):
        return ''.join(self.certifications.split()).split(',')

    def specialties_as_list(self):
        return ''.join(self.specialties.split()).split(',')


class OrganizationProviderProfile(BaseProfileModel):
    provider_details = models.OneToOneField(ProviderDetails, related_name='profile')
    website = models.URLField(blank=True)
    location = models.ForeignKey(Location)
    employees = models.ManyToManyField(IndividualProviderProfile)
    self.profile_url_name = 'profiles:organization_provider_profile'

    # More methods
1

There are 1 answers

3
aychedee On

I think this is massively over complicated. You don't have customers, users, and organisations. You have Users with different permissions or access who belong to different organisations (or accounts). You'll probably also have at least one other type of user. Site administrators. Doesn't mean they should be a different class. You implement it something like this:

class User(models.Model):
    role = models.TextField()

    def is_administrator(self):
        return self.role == "admin"

    def can_create_appointment(self):
        return self.role == "publisher"

It's also possible that the role might be on the organisation? So that all members of of one account have the same permissions. But you can see how that would work.

EDIT, to clarify my reasoning:

When you have a person logged in, Django will give you access to a user. Do you really want to create a situation where you have to constantly consider which type of user you have available? Or do you just want to be able to use the logged in user and modify the accessible urls and available actions based on some simple rules. The latter is much less complicated.