diff --git a/care/facility/api/serializers/facility.py b/care/facility/api/serializers/facility.py index b75bf06897..92dea0870a 100644 --- a/care/facility/api/serializers/facility.py +++ b/care/facility/api/serializers/facility.py @@ -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 @@ -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, @@ -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 diff --git a/care/facility/api/viewsets/facility.py b/care/facility/api/viewsets/facility.py index 427be23f94..7b9d64c068 100644 --- a/care/facility/api/viewsets/facility.py +++ b/care/facility/api/viewsets/facility.py @@ -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 @@ -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 @@ -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() @@ -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() @@ -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) diff --git a/care/users/api/serializers/user.py b/care/users/api/serializers/user.py index 8040ed71dc..edb72c38b4 100644 --- a/care/users/api/serializers/user.py +++ b/care/users/api/serializers/user.py @@ -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 @@ -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()) @@ -321,6 +327,7 @@ class Meta: "pf_endpoint", "pf_p256dh", "pf_auth", + "read_profile_picture_url", "user_flags", ) read_only_fields = ( @@ -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 @@ -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 diff --git a/care/users/api/viewsets/users.py b/care/users/api/viewsets/users.py index 7c2bb4ccca..937a8d3d00 100644 --- a/care/users/api/viewsets/users.py +++ b/care/users/api/viewsets/users.py @@ -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 @@ -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): @@ -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: @@ -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 @@ -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) diff --git a/care/users/migrations/0018_user_profile_picture_url.py b/care/users/migrations/0018_user_profile_picture_url.py new file mode 100644 index 0000000000..fea3ac507c --- /dev/null +++ b/care/users/migrations/0018_user_profile_picture_url.py @@ -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), + ), + ] diff --git a/care/users/models.py b/care/users/models.py index 1f6d3d7d8d..2a701a090e 100644 --- a/care/users/models.py +++ b/care/users/models.py @@ -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 @@ -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() diff --git a/care/users/tests/test_api.py b/care/users/tests/test_api.py index 28b87af4bf..096699deba 100644 --- a/care/users/tests/test_api.py +++ b/care/users/tests/test_api.py @@ -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 @@ -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), } @@ -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 + ) diff --git a/care/utils/file_uploads/__init__.py b/care/utils/file_uploads/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/utils/file_uploads/cover_image.py b/care/utils/file_uploads/cover_image.py new file mode 100644 index 0000000000..55f1183782 --- /dev/null +++ b/care/utils/file_uploads/cover_image.py @@ -0,0 +1,53 @@ +import logging +import secrets +from typing import Literal + +import boto3 +from django.conf import settings +from django.core.files.uploadedfile import UploadedFile + +from care.utils.csp.config import BucketType, get_client_config + +logger = logging.getLogger(__name__) + + +def delete_cover_image(image_key: str, folder: Literal["cover_images", "avatars"]): + config, bucket_name = get_client_config(BucketType.FACILITY) + s3 = boto3.client("s3", **config) + + try: + s3.delete_object(Bucket=bucket_name, Key=image_key) + except Exception: + logger.warning(f"Failed to delete cover image {image_key}") + + +def upload_cover_image( + image: UploadedFile, + object_external_id: str, + folder: Literal["cover_images", "avatars"], + old_key: str = None, +) -> str: + config, bucket_name = get_client_config(BucketType.FACILITY) + s3 = boto3.client("s3", **config) + + if old_key: + try: + s3.delete_object(Bucket=bucket_name, Key=old_key) + except Exception: + logger.warning(f"Failed to delete old cover image {old_key}") + + image_extension = image.name.rsplit(".", 1)[-1] + image_key = ( + f"{folder}/{object_external_id}_{secrets.token_hex(8)}.{image_extension}" + ) + + boto_params = { + "Bucket": bucket_name, + "Key": image_key, + "Body": image.file, + } + if settings.BUCKET_HAS_FINE_ACL: + boto_params["ACL"] = "public-read" + s3.put_object(**boto_params) + + return image_key diff --git a/care/utils/tests/test_utils.py b/care/utils/tests/test_utils.py index 87350be35b..1f668b7585 100644 --- a/care/utils/tests/test_utils.py +++ b/care/utils/tests/test_utils.py @@ -107,6 +107,8 @@ class TestUtils: Base class for tests, handles most of the test setup and tools for setting up data """ + maxDiff = None + def setUp(self) -> None: self.client.force_login(self.user) diff --git a/data/dummy/users.json b/data/dummy/users.json index 5f20d2f3b6..fc55f68670 100644 --- a/data/dummy/users.json +++ b/data/dummy/users.json @@ -31,6 +31,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -67,6 +68,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -103,6 +105,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -139,6 +142,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -175,6 +179,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -211,6 +216,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -247,6 +253,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -283,6 +290,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -319,6 +327,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -355,6 +364,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -391,6 +401,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -427,6 +438,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -463,6 +475,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -499,6 +512,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -535,6 +549,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -571,6 +586,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -607,6 +623,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -643,6 +660,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -679,6 +697,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -715,6 +734,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -751,6 +771,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -787,6 +808,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -823,6 +845,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] } @@ -859,6 +882,7 @@ "pf_p256dh": null, "pf_auth": null, "asset": null, + "profile_picture_url": null, "groups": [], "user_permissions": [] }