Skip to content

Commit

Permalink
Add user profile pictures (#2253)
Browse files Browse the repository at this point in the history
Add user profile pictures (#2253)

---------

Co-authored-by: Aakash Singh <[email protected]>
Co-authored-by: Vignesh Hari <[email protected]>
  • Loading branch information
3 people authored Sep 23, 2024
1 parent d6f9b51 commit e6a55d7
Show file tree
Hide file tree
Showing 11 changed files with 257 additions and 35 deletions.
34 changes: 9 additions & 25 deletions care/facility/api/serializers/facility.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import uuid

import boto3
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db.models import Q
from rest_framework import serializers
Expand All @@ -16,7 +12,7 @@
StateSerializer,
WardSerializer,
)
from care.utils.csp.config import BucketType, get_client_config
from care.utils.file_uploads.cover_image import upload_cover_image
from care.utils.models.validators import (
cover_image_validator,
custom_image_extension_validator,
Expand Down Expand Up @@ -239,25 +235,13 @@ class Meta:
fields = ("cover_image", "read_cover_image_url")

def save(self, **kwargs):
facility = self.instance
facility: Facility = self.instance
image = self.validated_data["cover_image"]

config, bucket_name = get_client_config(BucketType.FACILITY)
s3 = boto3.client("s3", **config)

if facility.cover_image_url:
s3.delete_object(Bucket=bucket_name, Key=facility.cover_image_url)

image_extension = image.name.rsplit(".", 1)[-1]
image_location = f"cover_images/{facility.external_id}_{str(uuid.uuid4())[0:8]}.{image_extension}"
boto_params = {
"Bucket": bucket_name,
"Key": image_location,
"Body": image.file,
}
if settings.BUCKET_HAS_FINE_ACL:
boto_params["ACL"] = "public-read"
s3.put_object(**boto_params)
facility.cover_image_url = image_location
facility.save()
facility.cover_image_url = upload_cover_image(
image,
str(facility.external_id),
"cover_images",
facility.cover_image_url,
)
facility.save(update_fields=["cover_image_url"])
return facility
14 changes: 6 additions & 8 deletions care/facility/api/viewsets/facility.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from django.conf import settings
from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator
from django_filters import rest_framework as filters
from djqscsv import render_to_csv_response
from drf_spectacular.utils import extend_schema, extend_schema_view
from dry_rest_permissions.generics import DRYPermissionFiltersBase, DRYPermissions
from rest_framework import filters as drf_filters
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.decorators import action, parser_classes
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
Expand All @@ -25,6 +26,7 @@
)
from care.facility.models.facility import FacilityHubSpoke, FacilityUser
from care.users.models import User
from care.utils.file_uploads.cover_image import delete_cover_image
from care.utils.queryset.facility import get_facility_queryset


Expand Down Expand Up @@ -99,19 +101,13 @@ def initialize_request(self, request, *args, **kwargs):
self.action = self.action_map.get(request.method.lower())
return super().initialize_request(request, *args, **kwargs)

def get_parsers(self):
if self.action == "cover_image":
return [MultiPartParser()]
return super().get_parsers()

def get_serializer_class(self):
if self.request.query_params.get("all") == "true":
return FacilityBasicInfoSerializer
if self.action == "cover_image":
# Check DRYpermissions before updating
return FacilityImageUploadSerializer
else:
return FacilitySerializer
return FacilitySerializer

def destroy(self, request, *args, **kwargs):
instance = self.get_object()
Expand Down Expand Up @@ -154,6 +150,7 @@ def list(self, request, *args, **kwargs):
return super(FacilityViewSet, self).list(request, *args, **kwargs)

@extend_schema(tags=["facility"])
@method_decorator(parser_classes([MultiPartParser]))
@action(methods=["POST"], detail=True)
def cover_image(self, request, external_id):
facility = self.get_object()
Expand All @@ -166,6 +163,7 @@ def cover_image(self, request, external_id):
@cover_image.mapping.delete
def cover_image_delete(self, *args, **kwargs):
facility = self.get_object()
delete_cover_image(facility.cover_image_url, "cover_images")
facility.cover_image_url = None
facility.save()
return Response(status=status.HTTP_204_NO_CONTENT)
Expand Down
34 changes: 34 additions & 0 deletions care/users/api/serializers/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
)
from care.users.api.serializers.skill import UserSkillSerializer
from care.users.models import GENDER_CHOICES, User
from care.utils.file_uploads.cover_image import upload_cover_image
from care.utils.models.validators import (
cover_image_validator,
custom_image_extension_validator,
)
from care.utils.queryset.facility import get_home_facility_queryset
from care.utils.serializer.external_id_field import ExternalIdSerializerField
from config.serializers import ChoiceField
Expand Down Expand Up @@ -279,6 +284,7 @@ class UserSerializer(SignUpSerializer):
source="home_facility",
read_only=True,
)
read_profile_picture_url = serializers.URLField(read_only=True)

home_facility = ExternalIdSerializerField(queryset=Facility.objects.all())

Expand Down Expand Up @@ -321,6 +327,7 @@ class Meta:
"pf_endpoint",
"pf_p256dh",
"pf_auth",
"read_profile_picture_url",
"user_flags",
)
read_only_fields = (
Expand Down Expand Up @@ -415,6 +422,7 @@ class UserListSerializer(serializers.ModelSerializer):
read_only=True,
)
home_facility = ExternalIdSerializerField(queryset=Facility.objects.all())
read_profile_picture_url = serializers.URLField(read_only=True)

class Meta:
model = User
Expand All @@ -437,4 +445,30 @@ class Meta:
"home_facility_object",
"home_facility",
"video_connect_link",
"read_profile_picture_url",
)


class UserImageUploadSerializer(serializers.ModelSerializer):
profile_picture = serializers.ImageField(
required=True,
write_only=True,
validators=[custom_image_extension_validator, cover_image_validator],
)
read_profile_picture_url = serializers.URLField(read_only=True)

class Meta:
model = User
fields = ("profile_picture", "read_profile_picture_url")

def save(self, **kwargs):
user: User = self.instance
image = self.validated_data["profile_picture"]
user.profile_picture_url = upload_cover_image(
image,
str(user.external_id),
"avatars",
user.profile_picture_url,
)
user.save(update_fields=["profile_picture_url"])
return user
36 changes: 34 additions & 2 deletions care/users/api/viewsets/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
from django.db.models import F, Q, Subquery
from django.http import Http404
from django.utils import timezone
from django.utils.decorators import method_decorator
from django_filters import rest_framework as filters
from drf_spectacular.utils import extend_schema
from dry_rest_permissions.generics import DRYPermissions
from rest_framework import filters as drf_filters
from rest_framework import filters as rest_framework_filters
from rest_framework import mixins, status
from rest_framework.decorators import action
from rest_framework.decorators import action, parser_classes
from rest_framework.generics import get_object_or_404
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
Expand All @@ -21,11 +23,13 @@
from care.facility.models.facility import Facility, FacilityUser
from care.users.api.serializers.user import (
UserCreateSerializer,
UserImageUploadSerializer,
UserListSerializer,
UserSerializer,
)
from care.users.models import User
from care.utils.cache.cache_allowed_facilities import get_accessible_facilities
from care.utils.file_uploads.cover_image import delete_cover_image


def remove_facility_user_cache(user_id):
Expand Down Expand Up @@ -168,7 +172,7 @@ def get_queryset(self):
)
return self.queryset.filter(query)

def get_object(self):
def get_object(self) -> User:
try:
return super().get_object()
except Http404:
Expand All @@ -181,6 +185,8 @@ def get_serializer_class(self):
return UserCreateSerializer
# elif self.action == "create":
# return SignUpSerializer
elif self.action == "profile_picture":
return UserImageUploadSerializer
else:
return UserSerializer

Expand Down Expand Up @@ -388,3 +394,29 @@ def check_availability(self, request, username):
if User.check_username_exists(username):
return Response(status=status.HTTP_409_CONFLICT)
return Response(status=status.HTTP_200_OK)

def has_profile_image_write_permission(self, request, user):
return request.user.is_superuser or (user.id == request.user.id)

@extend_schema(tags=["users"])
@method_decorator(parser_classes([MultiPartParser]))
@action(detail=True, methods=["POST"], permission_classes=[IsAuthenticated])
def profile_picture(self, request, *args, **kwargs):
user = self.get_object()
if not self.has_profile_image_write_permission(request, user):
return Response(status=status.HTTP_403_FORBIDDEN)
serializer = self.get_serializer(user, data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(status=status.HTTP_200_OK)

@extend_schema(tags=["users"])
@profile_picture.mapping.delete
def profile_picture_delete(self, request, *args, **kwargs):
user = self.get_object()
if not self.has_profile_image_write_permission(request, user):
return Response(status=status.HTTP_403_FORBIDDEN)
delete_cover_image(user.profile_picture_url, "avatars")
user.profile_picture_url = None
user.save()
return Response(status=status.HTTP_204_NO_CONTENT)
18 changes: 18 additions & 0 deletions care/users/migrations/0018_user_profile_picture_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2024-09-21 09:50

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("users", "0017_userflag"),
]

operations = [
migrations.AddField(
model_name="user",
name="profile_picture_url",
field=models.CharField(blank=True, default=None, max_length=500, null=True),
),
]
8 changes: 8 additions & 0 deletions care/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,9 @@ class User(AbstractUser):

gender = models.IntegerField(choices=GENDER_CHOICES, blank=False)
date_of_birth = models.DateField(null=True, blank=True)
profile_picture_url = models.CharField(
blank=True, null=True, default=None, max_length=500
)
skills = models.ManyToManyField("Skill", through=UserSkill)
home_facility = models.ForeignKey(
"facility.Facility", on_delete=models.PROTECT, null=True, blank=True
Expand Down Expand Up @@ -347,6 +350,11 @@ class User(AbstractUser):

CSV_MAKE_PRETTY = {"user_type": (lambda x: User.REVERSE_TYPE_MAP[x])}

def read_profile_picture_url(self):
if self.profile_picture_url:
return f"{settings.FACILITY_S3_BUCKET_EXTERNAL_ENDPOINT}/{settings.FACILITY_S3_BUCKET}/{self.profile_picture_url}"
return None

@property
def full_name(self):
return self.get_full_name()
Expand Down
69 changes: 69 additions & 0 deletions care/users/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import io
from datetime import date, timedelta

from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils import timezone
from PIL import Image
from rest_framework import status
from rest_framework.test import APITestCase

Expand Down Expand Up @@ -46,6 +49,7 @@ def get_detail_representation(self, obj=None) -> dict:
"doctor_qualification": obj.doctor_qualification,
"weekly_working_hours": obj.weekly_working_hours,
"video_connect_link": obj.video_connect_link,
"read_profile_picture_url": obj.profile_picture_url,
"user_flags": [],
**self.get_local_body_district_state_representation(obj),
}
Expand Down Expand Up @@ -292,3 +296,68 @@ def test_home_facility_filter(self):
self.assertIn(
self.user_5.username, {r["username"] for r in res_data_json["results"]}
)


class TestUserProfilePicture(TestUtils, APITestCase):
@classmethod
def setUpTestData(cls) -> None:
cls.state = cls.create_state()
cls.district = cls.create_district(cls.state)
cls.super_user = cls.create_super_user("su", cls.district)
cls.user = cls.create_user("staff1", cls.district)

def get_base_url(self) -> str:
return f"/api/v1/users/{self.user.username}/profile_picture/"

def get_payload(self) -> dict:
image = Image.new("RGB", (400, 400))
file = io.BytesIO()
image.save(file, format="JPEG")
test_file = SimpleUploadedFile("test.jpg", file.getvalue(), "image/jpeg")
test_file.size = 2000
return {"profile_picture": test_file}

def test_user_can_upload_profile_picture(self):
image = Image.new("RGB", (400, 400))
file = io.BytesIO()
image.save(file, format="JPEG")
test_file = SimpleUploadedFile("test.jpg", file.getvalue(), "image/jpeg")
test_file.size = 2000
response = self.client.post(
self.get_base_url(), self.get_payload(), format="multipart"
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsNotNone(
User.objects.get(username=self.user.username).profile_picture_url
)

def test_user_can_delete_profile_picture(self):
self.user.profile_picture_url = "image.jpg"
self.user.save(update_fields=["profile_picture_url"])

response = self.client.delete(self.get_base_url())
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertIsNone(
User.objects.get(username=self.user.username).profile_picture_url
)

def test_superuser_can_upload_profile_picture(self):
self.client.force_authenticate(self.super_user)
response = self.client.post(
self.get_base_url(), self.get_payload(), format="multipart"
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsNotNone(
User.objects.get(username=self.user.username).profile_picture_url
)

def test_superuser_can_delete_profile_picture(self):
self.user.profile_picture_url = "image.jpg"
self.user.save(update_fields=["profile_picture_url"])

self.client.force_authenticate(self.super_user)
response = self.client.delete(self.get_base_url())
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertIsNone(
User.objects.get(username=self.user.username).profile_picture_url
)
Empty file.
Loading

0 comments on commit e6a55d7

Please sign in to comment.