Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add user profile pictures #2253

Merged
merged 26 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
927f288
new profile pic feature
UdaySagar-Git Jun 7, 2024
e038343
Merge branch 'develop' into profile-pic
UdaySagar-Git Jun 7, 2024
c30735b
bug fix
UdaySagar-Git Jun 7, 2024
348351c
Merge branch 'profile-pic' of https://github.com/UdaySagar-Git/care i…
UdaySagar-Git Jun 7, 2024
fc4c573
removes new bucket
UdaySagar-Git Jul 9, 2024
4fb9910
fix bug
UdaySagar-Git Jul 9, 2024
1f408c6
checks for write permissions
UdaySagar-Git Jul 9, 2024
8f5020c
Merge branch 'develop' into profile-pic
UdaySagar-Git Aug 15, 2024
0057a6a
lint fix
UdaySagar-Git Aug 15, 2024
39bee6c
adds permission checks
UdaySagar-Git Aug 18, 2024
e2d7aa5
Merge branch 'develop' into profile-pic
UdaySagar-Git Sep 17, 2024
54bcf29
Merge branch 'develop' into profile-pic
UdaySagar-Git Sep 20, 2024
aaa48e2
Merge branch 'develop' into profile-pic
sainak Sep 21, 2024
9e83dc0
refactor cover image upload logic
sainak Sep 21, 2024
d6f4baf
update perms and cleanup
sainak Sep 21, 2024
8ef37b8
update migrations
sainak Sep 21, 2024
a95a80a
Merge remote-tracking branch 'origin/develop' into profile-pic
sainak Sep 21, 2024
2191cdb
add tests
sainak Sep 21, 2024
69de284
cleanup parsers
sainak Sep 21, 2024
ec87341
lint fix
sainak Sep 21, 2024
db5926c
optimize unique key generation
sainak Sep 21, 2024
237258e
delete images from s3 with delete operation
sainak Sep 21, 2024
8a8b533
fix tests
sainak Sep 21, 2024
777bffc
fix
sainak Sep 22, 2024
4caf143
Merge remote-tracking branch 'origin/develop' into profile-pic
sainak Sep 22, 2024
8dd0f6a
Merge branch 'develop' into profile-pic
vigneshhari Sep 23, 2024
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
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 @@ -102,11 +98,11 @@

facility_type = ChoiceField(choices=FACILITY_TYPES)
# A valid location => {
# "latitude": 49.8782482189424,

Check failure on line 101 in care/facility/api/serializers/facility.py

View workflow job for this annotation

GitHub Actions / Lint Code Base

Ruff (ERA001)

care/facility/api/serializers/facility.py:101:5: ERA001 Found commented-out code
# "longitude": 24.452545489
# }

Check failure on line 103 in care/facility/api/serializers/facility.py

View workflow job for this annotation

GitHub Actions / Lint Code Base

Ruff (ERA001)

care/facility/api/serializers/facility.py:103:5: ERA001 Found commented-out code
read_cover_image_url = serializers.URLField(read_only=True)
# location = PointField(required=False)

Check failure on line 105 in care/facility/api/serializers/facility.py

View workflow job for this annotation

GitHub Actions / Lint Code Base

Ruff (ERA001)

care/facility/api/serializers/facility.py:105:5: ERA001 Found commented-out code
features = serializers.ListField(
child=serializers.ChoiceField(choices=FEATURE_CHOICES),
required=False,
Expand All @@ -120,46 +116,46 @@

class Meta:
model = Facility
fields = [
"id",
"name",
"ward",
"local_body",
"district",
"state",
"facility_type",
"address",
"longitude",
"latitude",
"features",
"pincode",
"oxygen_capacity",
"phone_number",
"ward_object",
"local_body_object",
"district_object",
"state_object",
"modified_date",
"created_date",
"kasp_empanelled",
"middleware_address",
"expected_oxygen_requirement",
"type_b_cylinders",
"type_c_cylinders",
"type_d_cylinders",
"expected_type_b_cylinders",
"expected_type_c_cylinders",
"expected_type_d_cylinders",
"read_cover_image_url",
"patient_count",
"bed_count",
"facility_flags",
]

Check failure on line 153 in care/facility/api/serializers/facility.py

View workflow job for this annotation

GitHub Actions / Lint Code Base

Ruff (RUF012)

care/facility/api/serializers/facility.py:119:18: RUF012 Mutable class attributes should be annotated with `typing.ClassVar`
read_only_fields = ("modified_date", "created_date")

def validate_middleware_address(self, value):
if not value:
raise serializers.ValidationError("Middleware Address is required")

Check failure on line 158 in care/facility/api/serializers/facility.py

View workflow job for this annotation

GitHub Actions / Lint Code Base

Ruff (EM101)

care/facility/api/serializers/facility.py:158:47: EM101 Exception must not use a string literal, assign to variable first
value = value.strip()
if not value:
return value
Expand All @@ -171,7 +167,7 @@
def validate_features(self, value):
if len(value) != len(set(value)):
raise serializers.ValidationError(
"Features should not contain duplicate values."

Check failure on line 170 in care/facility/api/serializers/facility.py

View workflow job for this annotation

GitHub Actions / Lint Code Base

Ruff (EM101)

care/facility/api/serializers/facility.py:170:17: EM101 Exception must not use a string literal, assign to variable first
)
return value

Expand Down Expand Up @@ -215,12 +211,12 @@
hub: Facility = self.context["facility"]

if hub == spoke:
raise serializers.ValidationError("Cannot set a facility as it's own spoke")

Check failure on line 214 in care/facility/api/serializers/facility.py

View workflow job for this annotation

GitHub Actions / Lint Code Base

Ruff (EM101)

care/facility/api/serializers/facility.py:214:47: EM101 Exception must not use a string literal, assign to variable first

if FacilityHubSpoke.objects.filter(
Q(hub=hub, spoke=spoke) | Q(hub=spoke, spoke=hub)
).first():
raise serializers.ValidationError("Facility is already a spoke/hub")

Check failure on line 219 in care/facility/api/serializers/facility.py

View workflow job for this annotation

GitHub Actions / Lint Code Base

Ruff (EM101)

care/facility/api/serializers/facility.py:219:47: EM101 Exception must not use a string literal, assign to variable first

return spoke

Expand All @@ -238,26 +234,14 @@
# Check DRYpermissions before updating
fields = ("cover_image", "read_cover_image_url")

def save(self, **kwargs):

Check failure on line 237 in care/facility/api/serializers/facility.py

View workflow job for this annotation

GitHub Actions / Lint Code Base

Ruff (ARG002)

care/facility/api/serializers/facility.py:237:22: ARG002 Unused method argument: `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 All @@ -44,7 +46,7 @@
kasp_empanelled = filters.BooleanFilter(field_name="kasp_empanelled")
exclude_user = filters.CharFilter(method="filter_exclude_user")

def filter_exclude_user(self, queryset, name, value):

Check failure on line 49 in care/facility/api/viewsets/facility.py

View workflow job for this annotation

GitHub Actions / Lint Code Base

Ruff (ARG002)

care/facility/api/viewsets/facility.py:49:45: ARG002 Unused method argument: `name`
if value:
queryset = queryset.exclude(facilityuser__user__username=value)
return queryset
Expand Down Expand Up @@ -99,19 +101,13 @@
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 @@
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 @@
@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()
UdaySagar-Git marked this conversation as resolved.
Show resolved Hide resolved
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
Loading