From e30fab5cfaa2f06c7ec4ff0302d2b67bae0c8b00 Mon Sep 17 00:00:00 2001 From: yusufaygun Date: Mon, 16 Dec 2024 21:20:20 +0300 Subject: [PATCH] Fix annotation endpoints - Ensured proper json format with frontend's implementations - Fixed model, serializer, endpoints, swagger docs and tests accordingly --- .../0005_alter_annotation_options_and_more.py | 41 +++++ ..._remove_annotation_body_format_and_more.py | 51 ++++++ .../migrations/0007_annotation_context.py | 18 ++ app/backend/v1/apps/games/models.py | 23 ++- app/backend/v1/apps/games/serializers.py | 22 ++- app/backend/v1/apps/games/tests.py | 114 ++++++++----- app/backend/v1/apps/games/views.py | 154 ++++++++---------- 7 files changed, 282 insertions(+), 141 deletions(-) create mode 100644 app/backend/v1/apps/games/migrations/0005_alter_annotation_options_and_more.py create mode 100644 app/backend/v1/apps/games/migrations/0006_remove_annotation_body_format_and_more.py create mode 100644 app/backend/v1/apps/games/migrations/0007_annotation_context.py diff --git a/app/backend/v1/apps/games/migrations/0005_alter_annotation_options_and_more.py b/app/backend/v1/apps/games/migrations/0005_alter_annotation_options_and_more.py new file mode 100644 index 0000000..8d6ebba --- /dev/null +++ b/app/backend/v1/apps/games/migrations/0005_alter_annotation_options_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 5.1.4 on 2024-12-16 17:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0004_annotation'), + ] + + operations = [ + migrations.AlterModelOptions( + name='annotation', + options={'ordering': ['-created']}, + ), + migrations.RenameField( + model_name='annotation', + old_name='created_at', + new_name='created', + ), + migrations.RenameField( + model_name='annotation', + old_name='modified_at', + new_name='modified', + ), + migrations.RemoveField( + model_name='annotation', + name='target_fen', + ), + migrations.AddField( + model_name='annotation', + name='fen', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='annotation', + name='move_number', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/app/backend/v1/apps/games/migrations/0006_remove_annotation_body_format_and_more.py b/app/backend/v1/apps/games/migrations/0006_remove_annotation_body_format_and_more.py new file mode 100644 index 0000000..afef55d --- /dev/null +++ b/app/backend/v1/apps/games/migrations/0006_remove_annotation_body_format_and_more.py @@ -0,0 +1,51 @@ +# Generated by Django 5.1.4 on 2024-12-16 17:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0005_alter_annotation_options_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='annotation', + name='body_format', + ), + migrations.RemoveField( + model_name='annotation', + name='body_value', + ), + migrations.RemoveField( + model_name='annotation', + name='fen', + ), + migrations.RemoveField( + model_name='annotation', + name='move_number', + ), + migrations.AddField( + model_name='annotation', + name='body', + field=models.JSONField(default={'format': 'text/plain', 'type': 'TextualBody', 'value': 'Default annotation'}), + preserve_default=False, + ), + migrations.AddField( + model_name='annotation', + name='target', + field=models.JSONField(default={'source': 'http://example.com/games/0', 'state': {'fen': 'startpos', 'moveNumber': 0}, 'type': 'ChessPosition'}), + preserve_default=False, + ), + migrations.AddField( + model_name='annotation', + name='type', + field=models.CharField(default='Annotation', max_length=50), + ), + migrations.AlterField( + model_name='annotation', + name='motivation', + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/app/backend/v1/apps/games/migrations/0007_annotation_context.py b/app/backend/v1/apps/games/migrations/0007_annotation_context.py new file mode 100644 index 0000000..a6bddd4 --- /dev/null +++ b/app/backend/v1/apps/games/migrations/0007_annotation_context.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.4 on 2024-12-16 17:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0006_remove_annotation_body_format_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='annotation', + name='context', + field=models.CharField(default='http://www.w3.org/ns/anno.jsonld', max_length=255), + ), + ] diff --git a/app/backend/v1/apps/games/models.py b/app/backend/v1/apps/games/models.py index 0fc73ca..460ccb0 100644 --- a/app/backend/v1/apps/games/models.py +++ b/app/backend/v1/apps/games/models.py @@ -65,16 +65,21 @@ def __str__(self): return self.name class Annotation(models.Model): + context = models.CharField(max_length=255, default="http://www.w3.org/ns/anno.jsonld") id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - game = models.ForeignKey('Game', on_delete=models.CASCADE, related_name='annotations') + game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name='annotations') creator = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name='annotations') - body_value = models.TextField() # The content of the annotation - body_format = models.CharField(max_length=50, default="text/plain") - target_fen = models.CharField(max_length=255) # The specific FEN position for this annotation - move_number = models.IntegerField() # Move number in the game - motivation = models.CharField(max_length=50, default="commenting") - created_at = models.DateTimeField(auto_now_add=True) - modified_at = models.DateTimeField(auto_now=True) + + type = models.CharField(max_length=50, default="Annotation") # "Annotation" + created = models.DateTimeField(auto_now_add=True) # Timestamp for creation + modified = models.DateTimeField(auto_now=True) # Timestamp for modification + + body = models.JSONField() # Store the body as JSON (type, value, format) + target = models.JSONField() # Store the target as JSON (type, source, state) + motivation = models.CharField(max_length=100, null=True, blank=True) # e.g., "commenting" + + class Meta: + ordering = ['-created'] def __str__(self): - return f"Annotation by {self.creator.username} on FEN: {self.target_fen} (Game {self.game.id})" \ No newline at end of file + return f"Annotation {self.id} on Game {self.game.id} by {self.creator.username}" \ No newline at end of file diff --git a/app/backend/v1/apps/games/serializers.py b/app/backend/v1/apps/games/serializers.py index 83a6cae..4365477 100644 --- a/app/backend/v1/apps/games/serializers.py +++ b/app/backend/v1/apps/games/serializers.py @@ -18,12 +18,22 @@ def get_fens_list(self, obj): return obj.get_fens_list() class AnnotationSerializer(serializers.ModelSerializer): + context = serializers.CharField(default="http://www.w3.org/ns/anno.jsonld") + body = serializers.JSONField() # Because body is a JSON object + target = serializers.JSONField() # Because target is a JSON object + class Meta: model = Annotation - fields = ['id', 'game', 'creator', 'body_value', 'body_format', - 'target_fen', 'move_number', 'motivation', 'created_at', 'modified_at'] - read_only_fields = ['id', 'created_at', 'modified_at', 'creator'] + fields = ['context', 'id', 'type', 'created', 'modified', 'creator', 'body', 'target', 'motivation'] + + def to_representation(self, instance): + """ Customize the JSON output to follow the required structure """ + representation = super().to_representation(instance) + representation['@context'] = representation.pop('context') # Change context to @context + representation['creator'] = { + "id": f"user-{instance.creator.id}", + "name": instance.creator.username, + "type": "Person" + } + return representation - def create(self, validated_data): - validated_data['creator'] = self.context['request'].user # Set creator as the authenticated user - return super().create(validated_data) \ No newline at end of file diff --git a/app/backend/v1/apps/games/tests.py b/app/backend/v1/apps/games/tests.py index d8a1d7f..d2f1c4b 100644 --- a/app/backend/v1/apps/games/tests.py +++ b/app/backend/v1/apps/games/tests.py @@ -218,78 +218,114 @@ def setUp(self): self.annotation = Annotation.objects.create( game=self.game, creator=self.user, - body_value="Test annotation", - body_format="text/plain", - target_fen="rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", - move_number=1, + type="Annotation", + created="2024-03-14T12:00:00Z", + modified="2024-03-14T12:00:00Z", + body={ + "type": "TextualBody", + "value": "Test annotation", + "format": "text/plain" + }, + target={ + "type": "ChessPosition", + "source": f"http://localhost/games/{self.game.id}", + "state": { + "fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", + "moveNumber": 1 + } + }, motivation="commenting" - ) + ) self.get_url = reverse('annotations_list_create', kwargs={'game_id': self.game.id}) self.post_url = reverse('annotations_list_create', kwargs={'game_id': self.game.id}) self.put_url = reverse('annotation_detail', kwargs={'game_id': self.game.id, 'anno_id': self.annotation.id}) self.delete_url = reverse('annotation_detail', kwargs={'game_id': self.game.id, 'anno_id': self.annotation.id}) - - # GET Annotations Test + def test_get_annotations_authenticated(self): self.client.force_authenticate(user=self.user) response = self.client.get(self.get_url) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertGreaterEqual(len(response.data), 1) # At least 1 annotation - self.assertEqual(response.data[0]['body_value'], "Test annotation") - - # POST Annotation Test + self.assertGreaterEqual(len(response.data), 1) + self.assertIn('@context', response.data[0]) + self.assertEqual(response.data[0]['@context'], "http://www.w3.org/ns/anno.jsonld") + self.assertEqual(response.data[0]['body']['value'], "Test annotation") + def test_create_annotation_authenticated(self): self.client.force_authenticate(user=self.user) data = { - 'body_value': 'New annotation content', - 'body_format': 'text/plain', - 'target_fen': 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR', - 'move_number': 2, - 'motivation': 'commenting' + "@context": "http://www.w3.org/ns/anno.jsonld", + "type": "Annotation", + "body": { + "type": "TextualBody", + "value": "New annotation content", + "format": "text/plain" + }, + "target": { + "type": "ChessPosition", + "source": f"http://localhost/games/{self.game.id}", + "state": { + "fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", + "moveNumber": 2 + } + }, + "motivation": "commenting" } - response = self.client.post(self.post_url, data) + response = self.client.post(self.post_url, data, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.data['annotation']['body_value'], 'New annotation content') - - # PUT Annotation Test + self.assertEqual(response.data['body']['value'], 'New annotation content') + + def test_update_annotation_authenticated(self): self.client.force_authenticate(user=self.user) data = { - 'body_value': 'Updated annotation content', - 'move_number': 3 + "body": { + "value": "Updated annotation content" + }, + "modified": "2024-03-14T12:10:00Z" } - response = self.client.put(self.put_url, data) + response = self.client.put(self.put_url, data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.annotation.refresh_from_db() - self.assertEqual(self.annotation.body_value, 'Updated annotation content') - self.assertEqual(self.annotation.move_number, 3) - - # DELETE Annotation Test + self.assertEqual(self.annotation.body['value'], 'Updated annotation content') + def test_delete_annotation_authenticated(self): self.client.force_authenticate(user=self.user) response = self.client.delete(self.delete_url) self.assertEqual(response.status_code, status.HTTP_200_OK) annotation_exists = Annotation.objects.filter(id=self.annotation.id).exists() self.assertFalse(annotation_exists) - - # Permissions Tests + def test_unauthenticated_user_cannot_create_annotation(self): data = { - 'body_value': 'Unauthorized annotation content', - 'body_format': 'text/plain', - 'target_fen': 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR', - 'move_number': 2, - 'motivation': 'commenting' + "type": "Annotation", + "body": { + "type": "TextualBody", + "value": "Unauthorized annotation content", + "format": "text/plain" + }, + "target": { + "type": "ChessPosition", + "source": f"http://localhost/games/{self.game.id}", + "state": { + "fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", + "moveNumber": 2 + } + }, + "motivation": "commenting" } - response = self.client.post(self.post_url, data) + response = self.client.post(self.post_url, data, format='json') self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - + def test_only_creator_can_update_annotation(self): self.client.force_authenticate(user=self.other_user) - data = {'body_value': 'Malicious update'} - response = self.client.put(self.put_url, data) + data = { + "body": { + "value": "Malicious update" + } + } + response = self.client.put(self.put_url, data, format='json') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - + def test_only_creator_can_delete_annotation(self): self.client.force_authenticate(user=self.other_user) response = self.client.delete(self.delete_url) diff --git a/app/backend/v1/apps/games/views.py b/app/backend/v1/apps/games/views.py index c09dedf..18420a8 100644 --- a/app/backend/v1/apps/games/views.py +++ b/app/backend/v1/apps/games/views.py @@ -17,7 +17,7 @@ from rest_framework import status from django.shortcuts import get_object_or_404 -from .serializers import GameCommentSerializer +from .serializers import GameCommentSerializer, AnnotationSerializer # Swagger Parameters @@ -1003,7 +1003,7 @@ def get_tournament_round_pgn(request, roundId): 'application/json': [ { "@context": "http://www.w3.org/ns/anno.jsonld", - "id": "annotation-1", + "id": "341c8d29-867d-43f6-8892-9f675ea7d8c5", "type": "Annotation", "created": "2024-03-14T12:00:00Z", "modified": "2024-03-14T12:00:00Z", @@ -1050,28 +1050,33 @@ def get_tournament_round_pgn(request, roundId): ) @swagger_auto_schema( method='post', - operation_description="Create a new annotation for a specific game. The annotation includes information about the user, move details, and a comment.", + operation_description="Create a new annotation for a specific game.", operation_summary="Create an Annotation for a Game", manual_parameters=[auth_header], request_body=openapi.Schema( type=openapi.TYPE_OBJECT, properties={ - "type": openapi.Schema(type=openapi.TYPE_STRING, description="The type of the annotation", example="Annotation"), - "created": openapi.Schema(type=openapi.TYPE_STRING, format="date-time", description="The creation timestamp", example="2024-03-14T12:00:00Z"), - "body": openapi.Schema(type=openapi.TYPE_OBJECT, properties={ - "type": openapi.Schema(type=openapi.TYPE_STRING, description="Body type", example="TextualBody"), - "value": openapi.Schema(type=openapi.TYPE_STRING, description="Comment or annotation content", example="This is a strong pawn move."), - "format": openapi.Schema(type=openapi.TYPE_STRING, description="Format of the annotation body", example="text/plain") - }), - "target": openapi.Schema(type=openapi.TYPE_OBJECT, properties={ - "type": openapi.Schema(type=openapi.TYPE_STRING, description="The type of target", example="ChessPosition"), - "source": openapi.Schema(type=openapi.TYPE_STRING, description="The source URL of the game", example="http://example.com/games/7"), - "state": openapi.Schema(type=openapi.TYPE_OBJECT, properties={ - "fen": openapi.Schema(type=openapi.TYPE_STRING, description="FEN notation for the position", example="rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"), - "moveNumber": openapi.Schema(type=openapi.TYPE_INTEGER, description="Move number", example=1) - }) - }), - "motivation": openapi.Schema(type=openapi.TYPE_STRING, description="Reason for the annotation", example="commenting") + "@context": openapi.Schema(type=openapi.TYPE_STRING, description="Context for the annotation", example="http://www.w3.org/ns/anno.jsonld"), + "type": openapi.Schema(type=openapi.TYPE_STRING, description="Type of annotation", example="Annotation"), + "body": openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "type": openapi.Schema(type=openapi.TYPE_STRING, description="Type of body", example="TextualBody"), + "value": openapi.Schema(type=openapi.TYPE_STRING, description="Content of the annotation", example="Opening position - Classic setup"), + "format": openapi.Schema(type=openapi.TYPE_STRING, description="Format of the annotation body", example="text/plain") + } + ), + "target": openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "state": openapi.Schema(type=openapi.TYPE_OBJECT, properties={ + "fen": openapi.Schema(type=openapi.TYPE_STRING, description="FEN notation", example="rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"), + "moveNumber": openapi.Schema(type=openapi.TYPE_INTEGER, description="Move number", example=1) + }), + "source": openapi.Schema(type=openapi.TYPE_STRING, description="URL of the game", example="http://localhost/games/1") + } + ), + "motivation": openapi.Schema(type=openapi.TYPE_STRING, description="Reason for annotation", example="commenting") } ), responses={ @@ -1079,7 +1084,18 @@ def get_tournament_round_pgn(request, roundId): description="Annotation created successfully", examples={ 'application/json': { - "message": "Annotation created successfully" + "id": "341c8d29-867d-43f6-8892-9f675ea7d8c5", + "@context": "http://www.w3.org/ns/anno.jsonld", + "type": "Annotation", + "body": { + "value": "Opening position - Classic setup" + }, + "target": { + "state": { + "fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR" + } + }, + "motivation": "commenting" } } ), @@ -1106,51 +1122,35 @@ def get_tournament_round_pgn(request, roundId): def annotations_list_create(request, game_id): if request.method == 'GET': annotations = Annotation.objects.filter(game_id=game_id) - serialized_data = [ - { - 'id': annotation.id, - 'body_value': annotation.body_value, - 'body_format': annotation.body_format, - 'target_fen': annotation.target_fen, - 'move_number': annotation.move_number, - 'motivation': annotation.motivation, - } - for annotation in annotations - ] - return Response(serialized_data, status=status.HTTP_200_OK) + serializer = AnnotationSerializer(annotations, many=True, context={'request': request}) + return Response(serializer.data, status=status.HTTP_200_OK) if request.method == 'POST': - game = get_object_or_404(Game, id=game_id) data = request.data - annotation = Annotation.objects.create( - game=game, - creator=request.user, - body_value=data.get('body_value', ''), - body_format=data.get('body_format', 'text/plain'), - target_fen=data.get('target_fen', ''), - move_number=data.get('move_number', 0), - motivation=data.get('motivation', 'commenting') - ) - return Response({"annotation": { - "id": annotation.id, - "body_value": annotation.body_value, - "body_format": annotation.body_format, - "target_fen": annotation.target_fen, - "move_number": annotation.move_number, - "motivation": annotation.motivation, - }}, status=status.HTTP_201_CREATED) + game = get_object_or_404(Game, id=game_id) # Oyunu veritabanından al + data['creator'] = request.user.id # Yaratıcıyı ekle + serializer = AnnotationSerializer(data=data, context={'request': request}) + if serializer.is_valid(): + # save() metodu ile ForeignKey ilişkilendirmesini yapıyoruz. + annotation = serializer.save(game=game, creator=request.user) + response_serializer = AnnotationSerializer(annotation) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @swagger_auto_schema( method='put', - operation_description="Update an existing annotation for a specific game. Only the creator of the annotation can update it.", + operation_description="Update an existing annotation for a specific game.", operation_summary="Update an Annotation for a Game", manual_parameters=[auth_header], request_body=openapi.Schema( type=openapi.TYPE_OBJECT, properties={ - "body": openapi.Schema(type=openapi.TYPE_OBJECT, properties={ - "value": openapi.Schema(type=openapi.TYPE_STRING, description="Updated comment or annotation content", example="Updated content for the annotation") - }), + "body": openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "value": openapi.Schema(type=openapi.TYPE_STRING, description="Updated comment", example="Updated content"), + } + ), "modified": openapi.Schema(type=openapi.TYPE_STRING, format="date-time", description="The modification timestamp", example="2024-03-14T12:10:00Z") } ), @@ -1159,15 +1159,10 @@ def annotations_list_create(request, game_id): description="Annotation updated successfully", examples={ 'application/json': { - "message": "Annotation updated successfully" - } - } - ), - 403: openapi.Response( - description="User is not the creator of the annotation", - examples={ - 'application/json': { - "error": "Only the creator can update this annotation" + "id": "341c8d29-867d-43f6-8892-9f675ea7d8c5", + "body": { + "value": "Updated content" + } } } ), @@ -1183,9 +1178,8 @@ def annotations_list_create(request, game_id): ) @swagger_auto_schema( method='delete', - operation_description="Delete an annotation for a specific game. Only the creator of the annotation can delete it.", + operation_description="Delete an annotation for a specific game.", operation_summary="Delete an Annotation for a Game", - manual_parameters=[auth_header], responses={ 200: openapi.Response( description="Annotation deleted successfully", @@ -1195,14 +1189,6 @@ def annotations_list_create(request, game_id): } } ), - 403: openapi.Response( - description="User is not the creator of the annotation", - examples={ - 'application/json': { - "error": "Only the creator can delete this annotation" - } - } - ), 401: openapi.Response( description="Authentication required", examples={ @@ -1222,22 +1208,16 @@ def annotation_detail(request, game_id, anno_id): if request.user != annotation.creator: return Response({"error": "Only the creator can update this annotation."}, status=status.HTTP_403_FORBIDDEN) - annotation.body_value = request.data.get('body_value', annotation.body_value) - annotation.move_number = request.data.get('move_number', annotation.move_number) - annotation.save() - - return Response({"annotation": { - "id": annotation.id, - "body_value": annotation.body_value, - "body_format": annotation.body_format, - "target_fen": annotation.target_fen, - "move_number": annotation.move_number, - "motivation": annotation.motivation, - }}, status=status.HTTP_200_OK) - + serializer = AnnotationSerializer(annotation, data=request.data, partial=True, context={'request': request}) + if serializer.is_valid(): + annotation = serializer.save() + response_serializer = AnnotationSerializer(annotation) + return Response(response_serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + if request.method == 'DELETE': if request.user != annotation.creator: return Response({"error": "Only the creator can delete this annotation."}, status=status.HTTP_403_FORBIDDEN) annotation.delete() - return Response({"message": "Annotation deleted successfully."}, status=status.HTTP_200_OK) + return Response({"message": "Annotation deleted successfully."}, status=status.HTTP_200_OK) \ No newline at end of file