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

Fix annotation endpoints #412

Merged
merged 1 commit into from
Dec 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -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),
),
]
Original file line number Diff line number Diff line change
@@ -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),
),
]
18 changes: 18 additions & 0 deletions app/backend/v1/apps/games/migrations/0007_annotation_context.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
23 changes: 14 additions & 9 deletions app/backend/v1/apps/games/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})"
return f"Annotation {self.id} on Game {self.game.id} by {self.creator.username}"
22 changes: 16 additions & 6 deletions app/backend/v1/apps/games/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
114 changes: 75 additions & 39 deletions app/backend/v1/apps/games/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading