diff --git a/backend/api/migrations/0013_profile_description.py b/backend/api/migrations/0013_profile_description.py new file mode 100644 index 0000000..2bed237 --- /dev/null +++ b/backend/api/migrations/0013_profile_description.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-12-15 23:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0012_contentsuggestion_type'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='description', + field=models.TextField(blank=True, default='', help_text='A brief description about the user or their profile.', max_length=500), + ), + ] diff --git a/backend/api/migrations/0014_profile_date_of_birth_profile_profile_picture.py b/backend/api/migrations/0014_profile_date_of_birth_profile_profile_picture.py new file mode 100644 index 0000000..42c754b --- /dev/null +++ b/backend/api/migrations/0014_profile_date_of_birth_profile_profile_picture.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.4 on 2024-12-15 23:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0013_profile_description'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='date_of_birth', + field=models.DateField(blank=True, help_text="User's date of birth (used to calculate age).", null=True), + ), + migrations.AddField( + model_name='profile', + name='profile_picture', + field=models.ImageField(blank=True, help_text='Upload a profile picture.', null=True, upload_to='profile_pictures/'), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index b3bc646..1e2a5a9 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -26,7 +26,26 @@ class Profile(models.Model): choices=UserLabel.choices, blank=False ) - + # Description field + description = models.TextField( + max_length=500, + blank=True, + default='', + help_text="A brief description about the user or their profile." + ) + # Profile Picture + profile_picture = models.ImageField( + upload_to='profile_pictures/', + blank=True, + null=True, + help_text="Upload a profile picture." + ) + # Age (derived from date of birth) + date_of_birth = models.DateField( + blank=True, + null=True, + help_text="User's date of birth (used to calculate age)." + ) # Following and Followers relationships following = models.ManyToManyField( 'self', @@ -44,6 +63,15 @@ def get_following(self): def get_followers(self): return self.followers.all() + def calculate_age(self): + """Calculate the user's age based on their date of birth.""" + if self.date_of_birth: + today = date.today() + return today.year - self.date_of_birth.year - ( + (today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day) + ) + return None + class PasswordReset(models.Model): email = models.EmailField() token = models.CharField(max_length=100) @@ -156,6 +184,6 @@ class SpotifyToken(models.Model): access_token = models.TextField() refresh_token = models.TextField() expires_at = models.DateTimeField() # Add this field - + def __str__(self): return f"{self.user.username}'s Spotify Token" \ No newline at end of file diff --git a/backend/api/tests.py b/backend/api/tests.py index ef65c1f..9243a81 100644 --- a/backend/api/tests.py +++ b/backend/api/tests.py @@ -470,3 +470,109 @@ def test_search_case_insensitive(self): data = response.json() self.assertEqual(data['total_results'], 1) self.assertEqual(data['contents'][0]['description'], 'First content description') +class EditProfileTests(TestCase): + def setUp(self): + """Set up a test user and profile.""" + self.client = Client() + self.user = User.objects.create_user(username='testuser', password='testpass') + self.profile = Profile.objects.create( + user=self.user, + name='OriginalName', + surname='OriginalSurname', + description='Original description' + ) + self.url = reverse('edit_profile') + + def test_edit_profile_success(self): + """Test successful profile update.""" + self.client.login(username='testuser', password='testpass') + data = { + 'name': 'UpdatedName', + 'surname': 'UpdatedSurname', + 'description': 'Updated description' + } + response = self.client.post(self.url, data=data, content_type='application/json') + self.assertEqual(response.status_code, 200) + self.profile.refresh_from_db() + self.assertEqual(self.profile.name, 'UpdatedName') + self.assertEqual(self.profile.surname, 'UpdatedSurname') + self.assertEqual(self.profile.description, 'Updated description') + + def test_partial_update(self): + """Test partial update with only one field.""" + self.client.login(username='testuser', password='testpass') + data = {'name': 'PartialUpdate'} + response = self.client.post(self.url, data=data, content_type='application/json') + self.assertEqual(response.status_code, 200) + self.profile.refresh_from_db() + self.assertEqual(self.profile.name, 'PartialUpdate') + self.assertEqual(self.profile.surname, 'OriginalSurname') + self.assertEqual(self.profile.description, 'Original description') + + def test_invalid_request_format(self): + """Test invalid request format.""" + self.client.login(username='testuser', password='testpass') + response = self.client.post(self.url, data="Invalid Data", content_type='application/json') + self.assertEqual(response.status_code, 400) + + def test_unauthenticated_request(self): + """Test unauthenticated user trying to edit profile.""" + data = {'name': 'ShouldFail'} + response = self.client.post(self.url, data=data, content_type='application/json') + self.assertEqual(response.status_code, 302) # Should redirect to login +from django.core.files.uploadedfile import SimpleUploadedFile + +class AddProfilePictureTests(TestCase): + def setUp(self): + """Set up a test user and profile.""" + self.client = Client() + self.user = User.objects.create_user(username='testuser', password='testpass') + self.profile = Profile.objects.create( + user=self.user, + name='OriginalName', + surname='OriginalSurname', + description='Original description' + ) + self.url = reverse('add_profile_picture') + + def test_add_profile_picture_success(self): + """Test successfully uploading a profile picture.""" + self.client.login(username='testuser', password='testpass') + picture = SimpleUploadedFile("test.jpg", b"file_content", content_type="image/jpeg") + response = self.client.post(self.url, {'profile_picture': picture}) + self.assertEqual(response.status_code, 200) + self.profile.refresh_from_db() + self.assertIsNotNone(self.profile.profile_picture) + self.assertIn('test.jpg', self.profile.profile_picture.name) + + def test_replace_profile_picture(self): + """Test replacing an existing profile picture.""" + self.client.login(username='testuser', password='testpass') + # Add an initial profile picture + initial_picture = SimpleUploadedFile("initial.jpg", b"file_content", content_type="image/jpeg") + self.client.post(self.url, {'profile_picture': initial_picture}) + self.profile.refresh_from_db() + initial_picture_path = self.profile.profile_picture.path + + # Add a new profile picture + new_picture = SimpleUploadedFile("new.jpg", b"new_file_content", content_type="image/jpeg") + response = self.client.post(self.url, {'profile_picture': new_picture}) + self.assertEqual(response.status_code, 200) + self.profile.refresh_from_db() + self.assertIn('new.jpg', self.profile.profile_picture.name) + + # Check that the initial picture has been deleted + from os.path import exists + self.assertFalse(exists(initial_picture_path)) + + def test_missing_profile_picture(self): + """Test trying to upload without providing a file.""" + self.client.login(username='testuser', password='testpass') + response = self.client.post(self.url, {}) + self.assertEqual(response.status_code, 400) + + def test_unauthenticated_request(self): + """Test unauthenticated user trying to upload a profile picture.""" + picture = SimpleUploadedFile("test.jpg", b"file_content", content_type="image/jpeg") + response = self.client.post(self.url, {'profile_picture': picture}) + self.assertEqual(response.status_code, 302) # Should redirect to login diff --git a/backend/api/views.py b/backend/api/views.py index d18b637..9a71df3 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -23,6 +23,8 @@ from django.shortcuts import redirect from urllib.parse import quote from django.http import JsonResponse, HttpResponseRedirect +from django.core.files.storage import default_storage + @require_http_methods(["POST"]) @@ -132,6 +134,7 @@ def register(request): email = data['email'] password = data['password'] labels = data['labels'] # Expecting labels like ['Artist', 'Listener'] + age = data.get('age', None) if User.objects.filter(username=username).exists(): return JsonResponse({'error': 'Username already exists'}, status=400) @@ -1948,6 +1951,79 @@ def add_track_to_playlist(request, playlist_id): except Exception as e: return JsonResponse({"error": str(e)}, status=500) +@login_required +@require_http_methods(["POST", "PUT"]) +def edit_profile(request): + """ + Edit the logged-in user's profile. + The request body should contain 'name', 'surname', 'labels', and 'description'. + """ + try: + user = request.user + profile = Profile.objects.get(user=user) + + # Parse JSON data from the request body + data = json.loads(request.body) + + # Update fields if provided + profile.name = data.get('name', profile.name) + profile.surname = data.get('surname', profile.surname) + profile.description = data.get('description', profile.description) + profile.date_of_birth = data.get('date_of_birth', profile.date_of_birth) + # Save changes + profile.save() + + # Return updated profile details + return JsonResponse({ + "success": True, + "message": "Profile updated successfully", + "profile": { + "name": profile.name, + "surname": profile.surname, + "description": profile.description, + "date_of_birth": profile.date_of_birth + } + }) + + except Profile.DoesNotExist: + return JsonResponse({"success": False, "error": "Profile does not exist"}, status=404) + except json.JSONDecodeError: + return JsonResponse({"success": False, "error": "Invalid JSON data"}, status=400) + except Exception as e: + return JsonResponse({"success": False, "error": str(e)}, status=500) +@login_required +@require_http_methods(["POST"]) +def add_profile_picture(request): + """ + Add or update the profile picture for the logged-in user. + """ + try: + user = request.user + profile = Profile.objects.get(user=user) + + # Check if a file is uploaded + if 'profile_picture' not in request.FILES: + return JsonResponse({"success": False, "error": "No profile picture provided"}, status=400) + + # Delete the old picture if it exists + if profile.profile_picture: + default_storage.delete(profile.profile_picture.path) + + # Save the new profile picture + profile.profile_picture = request.FILES['profile_picture'] + profile.save() + + return JsonResponse({ + "success": True, + "message": "Profile picture updated successfully", + "profile_picture_url": profile.profile_picture.url + }) + + except Profile.DoesNotExist: + return JsonResponse({"success": False, "error": "Profile does not exist"}, status=404) + except Exception as e: + return JsonResponse({"success": False, "error": str(e)}, status=500) + def get_user_spotify_tracks(request, user_id): """Get liked songs for a specific user.""" try: @@ -2043,5 +2119,4 @@ def get_user_spotify_tracks(request, user_id): "error": f"Failed to communicate with Spotify API: {str(e)}" }, status=503) except Exception as e: - return JsonResponse({"error": str(e)}, status=500) - \ No newline at end of file + return JsonResponse({"error": str(e)}, status=500) \ No newline at end of file diff --git a/backend/music_app/urls.py b/backend/music_app/urls.py index c5bc093..a0f6b8c 100644 --- a/backend/music_app/urls.py +++ b/backend/music_app/urls.py @@ -51,6 +51,8 @@ path('api/spotify/playlist//', views.get_playlist_details, name='get_playlist_details'), path('api/spotify/playlist//tracks/', views.add_track_to_playlist, name='add_track_to_playlist'), path('api/spotify/get_user_spotify_playlists//', views.get_user_spotify_playlists, name='get_user_spotify_playlists'), + path('api/edit_profile/', views.edit_profile, name='edit_profile'), + path('api/add_profile_picture/', views.add_profile_picture, name='add_profile_picture'), path('api/spotify/get_user_spotify_tracks//', views.get_user_spotify_tracks, name='get_user_spotify_tracks'), # New endpoint ] diff --git a/backend/requirements.txt b/backend/requirements.txt index 25e77f1..53880ac 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,15 +1,56 @@ +aiohappyeyeballs==2.4.4 +aiohttp==3.11.10 +aiosignal==1.3.2 +annotated-types==0.7.0 +anyio==4.7.0 asgiref==3.8.1 +attrs==24.2.0 +beautifulsoup4==4.12.3 +bs4==0.0.2 certifi==2024.2.2 charset-normalizer==3.3.2 +dataclasses-json==0.6.7 +distro==1.9.0 Django==5.0.4 django-cors-headers==4.3.1 +frozenlist==1.5.0 +h11==0.14.0 +httpcore==1.0.7 +httpx==0.28.1 +httpx-sse==0.4.0 idna==3.7 +jiter==0.8.2 +jsonpatch==1.33 +jsonpointer==3.0.0 +langchain==0.3.12 +langchain-community==0.3.12 +langchain-core==0.3.25 +langchain-text-splitters==0.3.3 +langsmith==0.2.3 +marshmallow==3.23.1 +multidict==6.1.0 +mypy-extensions==1.0.0 +numpy==2.2.0 +openai==1.57.4 +orjson==3.10.12 +packaging==24.2 +pillow==11.0.0 +propcache==0.2.1 psycopg==3.1.18 +pydantic==2.10.3 +pydantic-settings==2.7.0 +pydantic_core==2.27.1 python-dotenv==1.0.1 +PyYAML==6.0.2 requests==2.31.0 +requests-toolbelt==1.0.0 +sniffio==1.3.1 +soupsieve==2.6 +SQLAlchemy==2.0.36 sqlparse==0.5.0 -typing_extensions==4.11.0 +tenacity==9.0.0 +tqdm==4.67.1 +typing-inspect==0.9.0 +typing_extensions==4.12.2 urllib3==2.2.1 -langchain-community>= -openai>= -bs4>= +yarl==1.18.3