From e4010fe6d0d9ba66957b2598dbc863e0b1cd0aa8 Mon Sep 17 00:00:00 2001 From: yujeong Date: Fri, 11 Oct 2024 17:13:08 +0900 Subject: [PATCH 1/9] =?UTF-8?q?refactor:=20materials/models.py=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20Image,=20ImageSerializer,=20ImageCreateView=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- materials/models.py | 55 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/materials/models.py b/materials/models.py index a4dffa5..65e2fa6 100644 --- a/materials/models.py +++ b/materials/models.py @@ -1,9 +1,27 @@ +import uuid + from accounts.models import CustomUser from courses.models import Course, Topic from django.db import models +def upload_to(instance, filename): + """ + ImageField를 통해 파일이 업로드될 때 해당 파일의 저장 경로를 동적으로 생성합니다. + - 모델 인스턴스가 save() 호출될 때, 파일이 저장되기 전 upload_to에 정의된 경로를 생성하기 위해 호출됩니다. + - ImageField의 upload_to 인자로 전달됩니다. + - 생성된 경로를 반환하며, 이 경로는 Django가 해당 파일을 저장할 때 사용됩니다. + - (장점) 사용자 접근성을 높이면서 중복 파일 이름 문제를 해결합니다. + """ + ext = filename.split(".")[-1] + return f"images/{uuid.uuid4()}.{ext}" + + class Image(models.Model): + """ + 이미지 객체를 위해 작성된 모델입니다. + """ + course = models.OneToOneField( Course, on_delete=models.CASCADE, @@ -18,30 +36,34 @@ class Image(models.Model): null=True, blank=True, ) - title = models.CharField(max_length=255, verbose_name="이미지 제목") - file = models.ImageField(upload_to="images/", verbose_name="이미지 파일") + image_url = models.ImageField( + upload_to="images/", blank=True, null=True, verbose_name="이미지 파일" + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def __str__(self): - return f"{self.topic.title} - {self.title}" - - # 실제 이미지 파일 - image = models.ImageField(upload_to="images/", blank=True, null=True) + if self.user: + return f"{self.user}'s Image" + elif self.course: + return f"Course Image for {self.course}" + return "Image" def save(self, *args, **kwargs): - if self.user and not self.file: - self.file = "images/default_user_image.jpg" - if self.course and not self.file: - self.file = "images/default_course_image.jpg" + if self.user and not self.image_url: + self.image_url = "images/default_user_image.jpg" + if self.course and not self.image_url: + self.image_url = "images/default_course_image.jpg" super().save(*args, **kwargs) class Video(models.Model): topic = models.OneToOneField(Topic, on_delete=models.CASCADE, related_name="video") - title = models.CharField(max_length=255, verbose_name="비디오 제목") - file = models.FileField(upload_to="videos/", verbose_name="비디오 파일") + course = models.OneToOneField( + Course, on_delete=models.CASCADE, related_name="video" + ) + video_url = models.FileField(upload_to="videos/", verbose_name="비디오 파일") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -56,11 +78,18 @@ class VideoEventData(models.Model): ("leave", "Left Page"), ] + user = models.ForeignKey( + CustomUser, + on_delete=models.CASCADE, + related_name="video_event_datas", + verbose_name="시청 기록의 해당 사용자", + ) + video = models.ForeignKey( Video, on_delete=models.CASCADE, related_name="video_event_datas", - verbose_name="해당 비디오", + verbose_name="시청 기록의 해당 비디오", ) event_type = models.CharField( From dcce41bb723861047d90ae049e224134cb64ac0e Mon Sep 17 00:00:00 2001 From: yujeong Date: Fri, 11 Oct 2024 17:13:37 +0900 Subject: [PATCH 2/9] =?UTF-8?q?refactor:=20materials/serializers.py=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20Image,=20ImageSerializer,=20ImageCreat?= =?UTF-8?q?eView=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- materials/serializers.py | 46 ++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/materials/serializers.py b/materials/serializers.py index 8b61872..23325e6 100644 --- a/materials/serializers.py +++ b/materials/serializers.py @@ -6,43 +6,35 @@ class ImageSerializer(serializers.ModelSerializer): + """ + 이미지 생성(업로드)을 위한 시리얼라이저입니다. + - 형식, 손상 여부에 대해 유효성 검사를 합니다. + """ class Meta: model = Image fields = [ "id", - "title", - "file", + "image_url", "created_at", "updated_at", ] read_only_fields = ["id", "created_at", "updated_at"] - def validate_file(self, value): - # 이미지 형식과 크기 유효성 검사 + def validate_image_url(self, value): allowed_image_extensions = (".png", ".jpg", ".jpeg") - max_image_size = 5 * 1024 * 1024 # 5MB if not value.name.endswith(allowed_image_extensions): raise serializers.ValidationError( "지원하지 않는 파일 형식입니다. PNG, JPG, JPEG만 가능합니다." ) - if value.size > max_image_size: - raise serializers.ValidationError("파일 크기는 5MB를 초과할 수 없습니다.") - - # 이미지 손상 여부 검사 try: img = PILImage.open(value) img.verify() except Exception: raise serializers.ValidationError("유효한 이미지 파일이 아닙니다.") - # 중복 파일명 검사 - user = self.context["request"].user - if Image.objects.filter(user=user, file__exact=value.name).exists(): - raise serializers.ValidationError("이미 존재하는 파일명입니다.") - return value @@ -57,8 +49,7 @@ class Meta: "topic", "topic_title", "course_title", - "title", - "file", + "video_url", "created_at", "updated_at", ] @@ -73,7 +64,7 @@ def validate_topic(self, value): ) return value - def validate_file(self, value): + def validate_image_url(self, value): # 영상 형식과 크기 유효성 검사 allowed_extensions = ["mp4", "avi", "mov", "wmv"] max_size = 100 * 1024 * 1024 # 100MB @@ -169,10 +160,23 @@ def create(self, validated_data): return video_event_data -class ViewEventListSerializer(serializers.ModelSerializer): - pass +class UserViewEventListSerializer(serializers.ModelSerializer): + duration_in_minutes = serializers.SerializerMethodField() + current_time_in_minutes = serializers.SerializerMethodField() + class Meta: + model = VideoEventData + fields = [ + "event_type", + "duration", + "current_time", + "timestamp", + "duration_in_minutes", # 분과 초로 변환된 전체 재생시간 + "current_time_in_minutes", # 분과 초로 변환된 현재 시간 + ] -class WatchHistorySerializer(serializers.Serializer): + def get_duration_in_minutes(self, obj): + return obj.get_duration_in_minutes() - pass + def get_current_time_in_minutes(self, obj): + return obj.get_current_time_in_minutes() From c7d2fa1b9895d03f9b9005917b22cbc47942e92b Mon Sep 17 00:00:00 2001 From: yujeong Date: Fri, 11 Oct 2024 17:13:53 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refactor:=20materials/views.py=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20Image,=20ImageSerializer,=20ImageCreateView=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- materials/views.py | 141 +++++++++++++++++++++++++++------------------ 1 file changed, 86 insertions(+), 55 deletions(-) diff --git a/materials/views.py b/materials/views.py index 8263a06..e6f1158 100644 --- a/materials/views.py +++ b/materials/views.py @@ -1,10 +1,12 @@ import io import boto3 +import ffmpeg from botocore.exceptions import ClientError from django.conf import settings from django.shortcuts import get_object_or_404 from PIL import Image as PILImage +from PIL import ImageFilter from rest_framework import generics, permissions, status from rest_framework.exceptions import PermissionDenied from rest_framework.parsers import FormParser, MultiPartParser @@ -14,16 +16,37 @@ from .models import Image, Video, VideoEventData from .serializers import ( ImageSerializer, + UserViewEventListSerializer, VideoEventSerializer, VideoSerializer, - WatchHistorySerializer, ) + # 리팩토링할 때 중복 함수 이곳에 작성 +def optimize_image(self, image_file): + """ + 이미지를 최적화하는 메서드입니다. + - 포맷 변환 + - 리사이징 + - 필터링 + """ + # Pillow를 사용하여 이미지 열기 + img = PILImage.open(image_file) + + # 포맷 변환 (필요한 경우) + img = img.convert("RGB") + + # 리사이징: 최대 너비/높이 800x600으로 조정 + img.thumbnail((800, 600)) + + # 이미지 필터링: 샤프닝 필터 적용 + img = img.filter(ImageFilter.SHARPEN) + + return img class ImageCreateView(generics.CreateAPIView): - # POST 요청: 이미지 파일을 업로드합니다. + # POST 요청: Image 객체를 사용해서 S3에 이미지 파일을 업로드합니다. queryset = Image.objects.all() serializer_class = ImageSerializer @@ -32,15 +55,25 @@ class ImageCreateView(generics.CreateAPIView): def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) - # 시리얼라이저에서 유효성 검사 수행 - if serializer.is_valid(): - file = request.FILES.get("file") - if not file: - return Response( - {"error": "No file provided"}, status=status.HTTP_400_BAD_REQUEST - ) + image_file = request.FILES.get("image_url") + + if not image_file: + return Response( + {"error": "이미지 파일이 필요합니다."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + optimized_image = self.optimize_image(image_file) + + try: + # 최적화된 이미지를 임시로 메모리에 저장 + image_io = io.BytesIO() + optimized_image.save(image_io, format="JPEG", quality=85) + image_io.seek(0) + # S3에 파일 업로드 s3_client = boto3.client( "s3", aws_access_key_id=settings.AWS_ACCESS_KEY_ID, @@ -48,36 +81,41 @@ def create(self, request, *args, **kwargs): region_name=settings.AWS_S3_REGION_NAME, ) - try: - # 이미지 최적화 (선택사항) - img = PILImage.open(file) - buffer = io.BytesIO() - img.save(buffer, format="JPEG", quality=85) - buffer.seek(0) + user = get_object_or_404(CustomUser, id=request.data.get("user_id")) + course = get_object_or_404(Course, id=request.data.get("course_id")) - # S3에 파일 업로드 - file_name = f"images/{file.name}" - s3_client.upload_fileobj( - buffer, - settings.AWS_STORAGE_BUCKET_NAME, - file_name, - ExtraArgs={"ContentType": "image/jpeg"}, + # 파일 이름 생성: 사용자 ID와 코스 ID를 포함 + if user: + file_name = f"images/user_{user.id}/{image_file.name}" + elif course: + file_name = f"images/course_{course.id}/{image_file.name}" + else: + return Response( + {"error": "유효한 사용자 또는 코스가 필요합니다."}, + status=status.HTTP_400_BAD_REQUEST, ) - # 업로드된 파일의 URL 생성 - file_url = f"https://{settings.AWS_S3_CUSTOM_DOMAIN}/{file_name}" + s3_client.upload_fileobj( + image_io, + settings.AWS_STORAGE_BUCKET_NAME, + file_name, + ExtraArgs={"ContentType": "image/jpeg"}, + ) - # 이미지 객체 생성 및 저장 - image = serializer.save(file=file_url) + # 업로드된 파일의 URL 생성 + file_url = f"https://{settings.AWS_S3_CUSTOM_DOMAIN}/{file_name}" - return Response( - self.get_serializer(image).data, status=status.HTTP_201_CREATED - ) - except ClientError as e: - return Response( - {"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + # 시리얼라이저에 전달 후 저장 + serializer.validated_data["image_url"] = file_url + image = serializer.save(file=file_url) + + return Response( + self.get_serializer(image).data, status=status.HTTP_201_CREATED + ) + except ClientError as e: + return Response( + {"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) class ImageListCreateView(generics.ListCreateAPIView): @@ -123,6 +161,13 @@ def create(self, request, *args, **kwargs): {"error": "No file provided"}, status=status.HTTP_400_BAD_REQUEST ) + max_image_size = 5 * 1024 * 1024 # 5MB + + if value.size > max_image_size: + raise serializers.ValidationError( + "파일 크기는 5MB를 초과할 수 없습니다." + ) + # S3 클라이언트 설정 s3_client = boto3.client( "s3", @@ -198,26 +243,12 @@ class VideoEventCreateView(generics.CreateAPIView): serializer_class = VideoEventSerializer -class VideoEventListView(APIView): - # GET 요청: - def get(self, request, video_id): - # video_id는 URL에서 가져옵니다 - video = get_object_or_404(Video, id=video_id) - video_event_data_list = ( - video.videoEventData.all() - ) # related_name을 통해 데이터 조회 - return Response({"events": [str(event) for event in video_event_data_list]}) - - -class WatchHistoryRetrieveUpdateView(generics.RetrieveUpdateAPIView): - # GET 요청: 특정 영상의 시청 기록을 조회합니다. - # PUT 요청: 특정 영상의 시청 기록을 업데이트합니다. - - serializer_class = WatchHistorySerializer - permission_classes = [] +class UserVideoEventListView(generics.ListAPIView): + serializer_class = UserViewEventListSerializer - def get_object(self): - pass + def get_queryset(self): + user_id = self.kwargs.get("user_id") + video_id = self.kwargs.get("video_id") - def perform_update(self, serializer): - pass + # 특정 사용자와 특정 비디오에 대한 이벤트 데이터를 필터링 + return VideoEventData.objects.filter(user_id=user_id, video_id=video_id) From 9f8d3f5caadf5e56cd4fb02fbc32d6538ed2b016 Mon Sep 17 00:00:00 2001 From: yujeong Date: Fri, 11 Oct 2024 17:14:44 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20materials/urls.py=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=ED=95=84=EC=9A=94=EC=97=86=EB=8A=94=20URL=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- materials/urls.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/materials/urls.py b/materials/urls.py index 5c6546a..486731c 100644 --- a/materials/urls.py +++ b/materials/urls.py @@ -27,14 +27,9 @@ views.VideoEventCreateView.as_view(), name="video-event-data", ), - path( - "video//events", - views.VideoEventListView.as_view(), - name="video-event-list", - ), path( "users//videos//watch-history/", - views.WatchHistoryRetrieveUpdateView.as_view(), - name="watch-history", + views.UserVideoEventListView.as_view(), + name="video-event-list", ), ] From 43e863598758ff08e941d4daa013e355457e9f62 Mon Sep 17 00:00:00 2001 From: yujeong Date: Fri, 11 Oct 2024 17:15:20 +0900 Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20requirements.txt=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20ffmpeg-python=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/requirements.txt b/requirements.txt index 93dd0ab..95bfe31 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,15 +1,13 @@ asgiref==3.8.1 attrs==24.2.0 boto3==1.35.35 +botocore==1.35.35 certifi==2024.8.30 cffi==1.17.1 charset-normalizer==3.3.2 colorama==0.4.6 cryptography==43.0.1 -defusedxml==0.8.0rc2 -dj-rest-auth==6.0.0 Django==5.1.1 -django-allauth==65.0.2 django-appconf==1.0.6 django-cors-headers==4.4.0 django-filter==24.3 @@ -20,6 +18,8 @@ djangorestframework==3.15.2 drf-spectacular==0.27.2 drf-yasg==1.21.7 Faker==30.3.0 +ffmpeg-python==0.2.0 +future==1.0.0 idna==3.10 inflection==0.5.1 iniconfig==2.0.0 @@ -29,7 +29,7 @@ jsonschema-specifications==2023.12.1 model-bakery==1.19.5 mypy==1.11.2 mypy-extensions==1.0.0 -oauthlib==3.2.2 +numpy==2.1.2 opencv-python==4.10.0.84 packaging==24.1 pillow==10.4.0 @@ -42,21 +42,17 @@ pytest==8.3.3 pytest-django==4.9.0 python-dateutil==2.9.0.post0 python-dotenv==1.0.1 -python3-openid==3.2.0 pytz==2024.2 PyYAML==6.0.2 referencing==0.35.1 requests==2.32.3 -requests-oauthlib==2.0.0 rpds-py==0.20.0 s3transfer==0.10.2 setuptools==75.1.0 six==1.16.0 -social-auth-app-django==5.4.2 -social-auth-core==4.5.4 sqlparse==0.5.1 toposort==1.10 typing_extensions==4.12.2 tzdata==2024.2 uritemplate==4.1.1 -urllib3==2.2.3 \ No newline at end of file +urllib3==2.2.3 From 334fc5e81e22ac528290840c20cbc9e47c4e99d8 Mon Sep 17 00:00:00 2001 From: yujeong Date: Fri, 11 Oct 2024 17:18:24 +0900 Subject: [PATCH 6/9] =?UTF-8?q?refactor:=20materials/views.py=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20Image,=20ImageSerializer,=20ImageCreateView=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- materials/views.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/materials/views.py b/materials/views.py index e6f1158..9d95627 100644 --- a/materials/views.py +++ b/materials/views.py @@ -14,12 +14,8 @@ from rest_framework.views import APIView from .models import Image, Video, VideoEventData -from .serializers import ( - ImageSerializer, - UserViewEventListSerializer, - VideoEventSerializer, - VideoSerializer, -) +from .serializers import (ImageSerializer, UserViewEventListSerializer, + VideoEventSerializer, VideoSerializer) # 리팩토링할 때 중복 함수 이곳에 작성 @@ -33,10 +29,10 @@ def optimize_image(self, image_file): # Pillow를 사용하여 이미지 열기 img = PILImage.open(image_file) - # 포맷 변환 (필요한 경우) + # 포맷 변환 img = img.convert("RGB") - # 리사이징: 최대 너비/높이 800x600으로 조정 + # 리사이징 img.thumbnail((800, 600)) # 이미지 필터링: 샤프닝 필터 적용 From d5f3c464d61ca25853747557c44d981b8586d0a2 Mon Sep 17 00:00:00 2001 From: yujeong Date: Fri, 11 Oct 2024 17:18:49 +0900 Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20=EC=88=98=EB=8F=99=20=EB=A8=B8?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- weaverse/settings.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/weaverse/settings.py b/weaverse/settings.py index 9a162c1..0ff6f93 100644 --- a/weaverse/settings.py +++ b/weaverse/settings.py @@ -151,7 +151,7 @@ # CORS 설정 if DEBUG: CORS_ALLOWED_ORIGINS = [ - "https://www.weaverse.site", # 프로덕션 환경 + "https://www.weaverse.site/", # 프로덕션 환경 "http://localhost:3000", # 개발 환경 프론트엔드 ] else: @@ -181,18 +181,6 @@ "CacheControl": "max-age=86400", } -DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" - -# S3 설정 -AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID") -AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY") -AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME") -AWS_S3_REGION_NAME = os.getenv("AWS_S3_REGION_NAME") -AWS_S3_CUSTOM_DOMAIN = f"{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com" -AWS_S3_OBJECT_PARAMETERS = { - "CacheControl": "max-age=86400", -} - # boto3 설정 DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" From 7d3967dc09606b78aa98231589a83a47d9ea986f Mon Sep 17 00:00:00 2001 From: yujeong Date: Fri, 11 Oct 2024 19:06:41 +0900 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20materials/models.py=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20Image,=20ImageSerializer,=20ImageCreateView=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- materials/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/materials/models.py b/materials/models.py index 65e2fa6..01888fe 100644 --- a/materials/models.py +++ b/materials/models.py @@ -36,7 +36,7 @@ class Image(models.Model): null=True, blank=True, ) - image_url = models.ImageField( + image_url = models.URLField( upload_to="images/", blank=True, null=True, verbose_name="이미지 파일" ) created_at = models.DateTimeField(auto_now_add=True) From 77f67453fa4955b45ca329030bcc40049fd47011 Mon Sep 17 00:00:00 2001 From: yujeong Date: Fri, 11 Oct 2024 19:09:13 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20requirements.txt=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/requirements.txt b/requirements.txt index 95bfe31..ab041d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,10 @@ cffi==1.17.1 charset-normalizer==3.3.2 colorama==0.4.6 cryptography==43.0.1 +defusedxml==0.8.0rc2 +dj-rest-auth==6.0.0 Django==5.1.1 +django-allauth==65.0.2 django-appconf==1.0.6 django-cors-headers==4.4.0 django-filter==24.3 @@ -30,6 +33,7 @@ model-bakery==1.19.5 mypy==1.11.2 mypy-extensions==1.0.0 numpy==2.1.2 +oauthlib==3.2.2 opencv-python==4.10.0.84 packaging==24.1 pillow==10.4.0 @@ -42,14 +46,18 @@ pytest==8.3.3 pytest-django==4.9.0 python-dateutil==2.9.0.post0 python-dotenv==1.0.1 +python3-openid==3.2.0 pytz==2024.2 PyYAML==6.0.2 referencing==0.35.1 requests==2.32.3 +requests-oauthlib==2.0.0 rpds-py==0.20.0 s3transfer==0.10.2 setuptools==75.1.0 six==1.16.0 +social-auth-app-django==5.4.2 +social-auth-core==4.5.4 sqlparse==0.5.1 toposort==1.10 typing_extensions==4.12.2