Skip to content
Snippets Groups Projects
Commit e6fde835 authored by florimondmanca's avatar florimondmanca
Browse files

add visit participants model, api, tests, update student api, update admin

parent a6ff004a
No related branches found
No related tags found
No related merge requests found
......@@ -10,8 +10,14 @@ app_name = 'api'
# Register API routes here
urlpatterns = []
router = routers.DefaultRouter()
# Visits views
router.register('visits', visits_views.VisitViewSet)
router.register('visit-participants', visits_views.VisitParticipantViewSet)
# Users views
router.register(r'users', users_views.UserViewSet)
router.register(r'tutors', users_views.TutorViewSet)
......@@ -31,7 +37,4 @@ router.register(r'categories', showcase_site_views.CategoryViewSet)
router.register(r'testimonies', showcase_site_views.TestimonyViewSet)
router.register(r'keyfigures', showcase_site_views.KeyFigureViewSet)
# Visits views
router.register(r'visits', visits_views.VisitViewSet)
urlpatterns = router.urls
urlpatterns += router.urls
......@@ -267,3 +267,29 @@ class VisitWithClosedRegistrationsFactory(VisitFactory):
date_random_range = (0, 5)
deadline_random_range = (-10, -6) # guaranteed to be before today
class VisitParticipantFactory(factory.DjangoModelFactory):
"""Visit participant object factory.
Student and visit are picked from pre-existing students and visits,
instead of being created from scratch.
This means the database must have at least one student and one visit to
create a VisitParticipant object.
"""
class Meta: # noqa
model = visits.models.VisitParticipant
@factory.lazy_attribute
def student(self):
return random.choice(users.models.Student.objects.all())
@factory.lazy_attribute
def visit(self):
visits_without_participants = (
visits.models.Visit.objects.filter(participants=None)
)
return random.choice(visits_without_participants)
present = factory.LazyFunction(lambda: random.choice([None, False, True]))
"""Visit API tests."""
from rest_framework import status
from tests.factory import VisitFactory
from tests.factory import VisitFactory, UserFactory
from tests.utils import HyperlinkedAPITestCase
from visits.serializers import VisitSerializer
......@@ -30,3 +30,22 @@ class VisitEndpointsTest(HyperlinkedAPITestCase):
def test_retrieve_authentication_required(self):
self.assertRequiresAuth(
self.perform_retrieve, expected_status_code=status.HTTP_200_OK)
def perform_list_participants(self, obj=None):
if obj is None:
obj = self.factory.create()
url = '/api/visits/{obj.pk}/participants/'.format(obj=obj)
response = self.client.get(url)
return response
def test_participants_contains_participants_of_visit(self):
self.client.force_login(UserFactory.create())
obj = self.factory.create()
response = self.perform_list_participants(obj=obj)
num_participants = obj.participants.all().count()
self.assertEqual(len(response.data), num_participants)
def test_participants_authentication_required(self):
self.assertRequiresAuth(
self.perform_list_participants,
expected_status_code=status.HTTP_200_OK)
"""VisitParticipant API tests."""
from rest_framework import status
from tests.factory import VisitParticipantFactory
from tests.factory import VisitFactory, StudentFactory
from tests.utils import HyperlinkedAPITestCase
class VisitParticipantEndpointsTest(HyperlinkedAPITestCase):
"""Test access to the VisitParticipants endpoints."""
factory = VisitParticipantFactory
@classmethod
def setUpClass(cls):
super().setUpClass()
# create a bunch of students and visits to choose from
StudentFactory.create_batch(10)
VisitFactory.create_batch(10)
cls.factory.create_batch(5)
def perform_list(self):
url = '/api/visit-participants/'
response = self.client.get(url)
return response
def test_list_authentication_required(self):
self.assertRequiresAuth(
self.perform_list, expected_status_code=status.HTTP_200_OK)
def perform_retrieve(self):
obj = self.factory.create()
url = '/api/visit-participants/{obj.pk}/'.format(obj=obj)
response = self.client.get(url)
return response
def test_retrieve_authentication_required(self):
self.assertRequiresAuth(
self.perform_retrieve, expected_status_code=status.HTTP_200_OK)
......@@ -34,4 +34,7 @@ class StudentTestCase(ProfileTestMixin, ModelTestCase):
self.assertEqual(TutoringGroup.objects.get(), self.obj.tutoring_group)
self.assertIn(self.obj, TutoringGroup.objects.get().students.all())
def test_visits_relationship(self):
self.assertEqual(self.obj.visit_set.all().count(), 0)
# TODO test 1-n relationship with tutoring group
"""VisitParticipant model tests."""
from django.db.utils import IntegrityError
from visits.models import VisitParticipant
from tests.utils import ModelTestCase
from tests.factory import StudentFactory, VisitFactory, UserFactory
class VisitParticipantTest(ModelTestCase):
"""Test the VisitParticipant model."""
model = VisitParticipant
field_tests = {
'student': {
'verbose_name': 'lycéen',
},
'visit': {
'verbose_name': 'sortie',
},
'present': {
'verbose_name': 'présent',
'null': True,
}
}
model_tests = {
'verbose_name': 'participant à la sortie',
'verbose_name_plural': 'participants à la sortie',
'unique_together': (('student', 'visit'),),
}
def create(self):
return VisitParticipant.objects.create(student=self.student,
visit=self.visit)
def setUp(self):
self.student = StudentFactory.create()
self.visit = VisitFactory.create()
self.obj = self.create()
def test_student_cannot_participate_more_than_once(self):
with self.assertRaises(IntegrityError):
self.create()
def test_str_contains_student(self):
self.assertIn(str(self.student), str(self.obj))
def test_str_contains_visit(self):
self.assertIn(str(self.visit), str(self.obj))
def test_get_absolute_url(self):
self.client.force_login(UserFactory.create())
url = self.obj.get_absolute_url()
expected = '/api/visit-participants/{}/'.format(self.obj.pk)
self.assertEqual(url, expected)
response = self.client.get(url)
self.assertEqual(200, response.status_code)
......@@ -6,6 +6,8 @@ from django.contrib.auth.admin import UserAdmin
from .models import User, Tutor, Student, SchoolStaffMember
from tutoring.admin import TutoringGroupMembershipInline
from visits.admin import VisitParticipantInline
# Register your models here.
......@@ -49,18 +51,32 @@ class CustomUserAdmin(UserAdmin):
class TutorAdmin(admin.ModelAdmin):
"""Tutor admin panel."""
inlines = [
TutoringGroupMembershipInline
]
inlines = (TutoringGroupMembershipInline,)
class Meta: # noqa
model = Tutor
class StudentVisitParticipantInline(VisitParticipantInline):
"""Inline for VisitParticipant on the Student admin panel.
All fields are read-only.
"""
readonly_fields = ('student', 'visit', 'present')
verbose_name = 'Participation aux sorties'
verbose_name_plural = 'Participation aux sorties'
def has_add_permission(self, request):
return False
@admin.register(Student)
class StudentAdmin(admin.ModelAdmin):
"""Student admin panel."""
inlines = (StudentVisitParticipantInline, )
class Meta: # noqa
model = Student
......
"""Users API serializers."""
from django.contrib.auth import get_user_model
from django.shortcuts import reverse
from rest_framework import serializers
from .models import Tutor, Student, SchoolStaffMember
from tutoring.models import School, TutoringGroup
from visits.models import Visit
from visits.serializers import VisitSerializer
User = get_user_model()
......@@ -110,17 +112,22 @@ class StudentSerializer(ProfileSerializer,
tutoring_group = serializers.HyperlinkedRelatedField(
queryset=TutoringGroup.objects.all(),
view_name='api:tutoring_group-detail',
view_name='api:student-tutoringgroup',
)
school = serializers.HyperlinkedRelatedField(
queryset=School.objects.all(),
view_name='api:school-detail',
)
visits = serializers.HyperlinkedRelatedField(
source='visit_set',
many=True,
view_name='api:visit-detail',
read_only=True)
class Meta: # noqa
model = Student
fields = ('user_id', 'user', 'address', 'tutoring_group',
'school', 'url')
fields = ('user_id', 'url', 'user', 'address', 'tutoring_group',
'school', 'visits',)
extra_kwargs = {
'url': {'view_name': 'api:student-detail'},
}
......
......@@ -7,6 +7,7 @@ from rest_framework.decorators import detail_route
from rest_framework.response import Response
from tutoring.serializers import TutoringGroupSerializer
from visits.serializers import VisitSerializer
from .models import SchoolStaffMember, Student, Tutor
from .serializers import (SchoolStaffMembersSerializer, StudentSerializer,
......@@ -126,6 +127,15 @@ class StudentViewSet(ProfileViewSet):
context={'request': request})
return Response(serializer.data)
@detail_route()
def visits(self, request, pk=None):
"""List detailed info about the visits a student participates in."""
student = self.get_object()
visits = student.visit_set.all()
serializer = VisitSerializer(visits, many=True,
context={'request': request})
return Response(serializer.data)
class SchoolStaffMemberViewSet(ProfileViewSet):
"""API endpoint that allows school staff members to be viewed or edited."""
......
......@@ -36,6 +36,10 @@ class RegistrationsOpenFilter(admin.SimpleListFilter):
class VisitForm(forms.ModelForm):
"""Custom admin form for Visit."""
class Meta: # noqa
model = Visit
fields = '__all__'
def clean(self):
"""Validate that the deadline is before the date date."""
cleaned_data = super().clean()
......@@ -49,12 +53,21 @@ class VisitForm(forms.ModelForm):
self.add_error('deadline', error)
class VisitParticipantInline(admin.TabularInline):
"""Inline for VisitParticipant."""
model = Visit.participants.through
extra = 0
@admin.register(Visit)
class VisitAdmin(admin.ModelAdmin):
"""Admin panel for visits."""
form = VisitForm
inlines = (VisitParticipantInline,)
list_display = ('__str__', 'place', 'date', 'deadline',
'_registrations_open', 'fact_sheet')
list_filter = ('date', RegistrationsOpenFilter)
search_fields = ('title', 'place',)
exclude = ('participants',)
......@@ -35,6 +35,51 @@ class VisitQuerySet(models.QuerySet):
return self.filter(deadline__lt=now)
class VisitParticipant(models.Model):
"""Through-model for visit participants.
Allows to store whether the student was present to the visit.
"""
student = models.ForeignKey('users.Student', verbose_name='lycéen',
on_delete=models.CASCADE)
visit = models.ForeignKey('Visit', verbose_name='sortie',
on_delete=models.CASCADE)
present = models.NullBooleanField('présent')
class Meta: # noqa
verbose_name = 'participant à la sortie'
verbose_name_plural = 'participants à la sortie'
# prevent a student from participating visit multiple times
unique_together = (('student', 'visit'),)
def get_absolute_url(self):
return reverse('api:visitparticipant-detail', args=[str(self.pk)])
# Permissions
@staticmethod
@authenticated_users
def has_read_permission(request):
return True
@authenticated_users
def has_object_read_permission(self, request):
return True
@staticmethod
@authenticated_users
def has_write_permission(request):
return True
@authenticated_users
def has_object_write_permission(self, request):
return True
def __str__(self):
return '{} participates in {}'.format(self.student, self.visit)
class Visit(models.Model):
"""Represents a visit that students can attend."""
......@@ -83,6 +128,8 @@ class Visit(models.Model):
fact_sheet = models.FileField(
'fiche sortie', blank=True, null=True,
help_text="Formats supportés : PDF")
participants = models.ManyToManyField('users.Student',
through='VisitParticipant')
def _registrations_open(self):
return timezone.now() < self.deadline
......@@ -107,6 +154,15 @@ class Visit(models.Model):
def has_object_read_permission(self, request):
return True
@staticmethod
@authenticated_users
def has_write_permission(request):
return True
@authenticated_users
def has_object_write_permission(self, request):
return True
def get_absolute_url(self):
return reverse('api:visit-detail', args=[str(self.pk)])
......
"""Visits serializers."""
from rest_framework import serializers
from .models import Visit
from .models import Visit, VisitParticipant
from users.models import Student
class VisitSerializer(serializers.HyperlinkedModelSerializer):
"""Serializer for Visit."""
participants = serializers.StringRelatedField(many=True)
class Meta: # noqa
model = Visit
fields = ('id', 'url', 'title', 'summary', 'description',
'place', 'date', 'deadline',
'registrations_open',
'participants',
'image', 'fact_sheet',)
extra_kwargs = {
'url': {'view_name': 'api:visit-detail'},
}
class VisitParticipantReadSerializer(serializers.HyperlinkedModelSerializer):
"""Readable serializer for visit participants."""
student = serializers.HyperlinkedRelatedField(
'api:student-detail',
read_only=True,
)
visit = serializers.HyperlinkedRelatedField(
'api:visit-detail',
read_only=True,
)
class Meta: # noqa
model = VisitParticipant
fields = ('id', 'url', 'student', 'visit', 'present')
extra_kwargs = {
'url': {'view_name': 'api:visitparticipant-detail'}
}
class VisitParticipantWriteSerializer(serializers.HyperlinkedModelSerializer):
"""Writable serializer for visit participants."""
student = serializers.PrimaryKeyRelatedField(
write_only=True,
queryset=Student.objects.all())
visit = serializers.PrimaryKeyRelatedField(
write_only=True,
queryset=Visit.objects.all())
class Meta: # noqa
model = VisitParticipant
fields = ('id', 'student', 'visit', 'present')
class VisitParticipantDetailSerializer(serializers.ModelSerializer):
"""Serializer with detailed information about visit participants."""
student_id = serializers.PrimaryKeyRelatedField(
source='student.id',
queryset=Student.objects.all(),
label='Lycéen')
first_name = serializers.CharField(
source='student.user.first_name', read_only=True)
last_name = serializers.CharField(
source='student.user.last_name', read_only=True)
phone_number = serializers.CharField(
source='student.user.phone_number', read_only=True)
email = serializers.EmailField(source='student.user.email',
read_only=True)
class Meta: # noqa
model = VisitParticipant
fields = ('student_id', 'first_name', 'last_name',
'phone_number', 'email', 'present',)
"""Visits API views."""
from rest_framework import viewsets
from rest_framework import status
from rest_framework.response import Response
from rest_framework.decorators import detail_route
from dry_rest_permissions.generics import DRYPermissions
from .serializers import VisitSerializer
from .models import Visit
from .serializers import VisitParticipantReadSerializer
from .serializers import VisitParticipantWriteSerializer
from .serializers import VisitParticipantDetailSerializer
from .models import Visit, VisitParticipant
class VisitViewSet(viewsets.ReadOnlyModelViewSet):
"""API endpoint that allows visits to be viewed."""
"""API endpoints that allows visits to be viewed."""
serializer_class = VisitSerializer
queryset = Visit.objects.all()
permission_classes = (DRYPermissions,)
@detail_route()
def participants(self, request, pk=None):
"""List participants of a visit with their contact information."""
visit = self.get_object()
participants = VisitParticipant.objects.filter(visit=visit)
serializer = VisitParticipantDetailSerializer(participants, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
class VisitParticipantViewSet(viewsets.ModelViewSet):
"""API endpoints to manage participants of visits."""
queryset = VisitParticipant.objects.all()
permission_classes = (DRYPermissions,)
def get_serializer_class(self):
if self.action in ('list', 'retrieve'):
return VisitParticipantReadSerializer
else:
return VisitParticipantWriteSerializer
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment