diff --git a/apps/api/apps.py b/apps/api/apps.py index fb08f6a..6e03468 100644 --- a/apps/api/apps.py +++ b/apps/api/apps.py @@ -3,5 +3,8 @@ from django.utils.translation import gettext_lazy as _ class APIConfig(AppConfig): + """ + Manage the inscription through a JSON API. + """ name = 'api' verbose_name = _('API') diff --git a/apps/api/serializers.py b/apps/api/serializers.py new file mode 100644 index 0000000..1685020 --- /dev/null +++ b/apps/api/serializers.py @@ -0,0 +1,80 @@ +from rest_framework import serializers +from member.models import TFJMUser, Authorization, MotivationLetter, Solution, Synthesis +from tournament.models import Team, Tournament, Pool + + +class UserSerializer(serializers.ModelSerializer): + """ + Serialize a User object into JSON. + """ + class Meta: + model = TFJMUser + exclude = ( + 'username', + 'password', + 'groups', + 'user_permissions', + ) + + +class TeamSerializer(serializers.ModelSerializer): + """ + Serialize a Team object into JSON. + """ + class Meta: + model = Team + fields = "__all__" + + +class TournamentSerializer(serializers.ModelSerializer): + """ + Serialize a Tournament object into JSON. + """ + class Meta: + model = Tournament + fields = "__all__" + + +class AuthorizationSerializer(serializers.ModelSerializer): + """ + Serialize an Authorization object into JSON. + """ + class Meta: + model = Authorization + fields = "__all__" + + +class MotivationLetterSerializer(serializers.ModelSerializer): + """ + Serialize a MotivationLetter object into JSON. + """ + class Meta: + model = MotivationLetter + fields = "__all__" + + +class SolutionSerializer(serializers.ModelSerializer): + """ + Serialize a Solution object into JSON. + """ + class Meta: + model = Solution + fields = "__all__" + + +class SynthesisSerializer(serializers.ModelSerializer): + """ + Serialize a Synthesis object into JSON. + """ + class Meta: + model = Synthesis + fields = "__all__" + + +class PoolSerializer(serializers.ModelSerializer): + """ + Serialize a Pool object into JSON. + """ + class Meta: + model = Pool + fields = "__all__" diff --git a/apps/api/urls.py b/apps/api/urls.py index b8f5c3e..b2e617f 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -1,152 +1,8 @@ from django.conf.urls import url, include -from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import routers, serializers, status -from rest_framework.filters import SearchFilter -from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet -from member.models import TFJMUser, Authorization, Solution, Synthesis, MotivationLetter -from tournament.models import Team, Tournament, Pool - - -class UserSerializer(serializers.ModelSerializer): - class Meta: - model = TFJMUser - exclude = ( - 'email', - 'password', - 'groups', - 'user_permissions', - ) - - -class TeamSerializer(serializers.ModelSerializer): - class Meta: - model = Team - fields = "__all__" - - -class TournamentSerializer(serializers.ModelSerializer): - class Meta: - model = Tournament - fields = "__all__" - - -class AuthorizationSerializer(serializers.ModelSerializer): - class Meta: - model = Authorization - fields = "__all__" - - -class MotivationLetterSerializer(serializers.ModelSerializer): - class Meta: - model = MotivationLetter - fields = "__all__" - - -class SolutionSerializer(serializers.ModelSerializer): - class Meta: - model = Solution - fields = "__all__" - - -class SynthesisSerializer(serializers.ModelSerializer): - class Meta: - model = Synthesis - fields = "__all__" - - -class PoolSerializer(serializers.ModelSerializer): - class Meta: - model = Pool - fields = "__all__" - - -class UserViewSet(ModelViewSet): - queryset = TFJMUser.objects.all() - serializer_class = UserSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] - filterset_fields = ['id', 'first_name', 'last_name', 'email', 'gender', 'student_class', 'role', 'year', 'team', - 'team__trigram', 'is_superuser', 'is_staff', 'is_active', ] - search_fields = ['$first_name', '$last_name', ] - - -class TeamViewSet(ModelViewSet): - queryset = Team.objects.all() - serializer_class = TeamSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] - filterset_fields = ['name', 'trigram', 'validation_status', 'selected_for_final', 'access_code', 'tournament', - 'year', ] - search_fields = ['$name', 'trigram', ] - - -class TournamentViewSet(ModelViewSet): - queryset = Tournament.objects.all() - serializer_class = TournamentSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] - filterset_fields = ['name', 'size', 'price', 'date_start', 'date_end', 'final', 'organizers', 'year', ] - search_fields = ['$name', ] - - -class AuthorizationViewSet(ModelViewSet): - queryset = Authorization.objects.all() - serializer_class = AuthorizationSerializer - filter_backends = [DjangoFilterBackend] - filterset_fields = ['user', 'type', ] - - -class MotivationLetterViewSet(ModelViewSet): - queryset = MotivationLetter.objects.all() - serializer_class = MotivationLetterSerializer - filter_backends = [DjangoFilterBackend] - filterset_fields = ['team', 'team__trigram', ] - - -class SolutionViewSet(ModelViewSet): - queryset = Solution.objects.all() - serializer_class = SolutionSerializer - filter_backends = [DjangoFilterBackend] - filterset_fields = ['team', 'team__trigram', 'problem', ] - - -class SynthesisViewSet(ModelViewSet): - queryset = Synthesis.objects.all() - serializer_class = SynthesisSerializer - filter_backends = [DjangoFilterBackend] - filterset_fields = ['team', 'team__trigram', 'source', 'round', ] - - -class PoolViewSet(ModelViewSet): - queryset = Pool.objects.all() - serializer_class = PoolSerializer - filter_backends = [DjangoFilterBackend] - filterset_fields = ['teams', 'teams__trigram', 'round', ] - - def create(self, request, *args, **kwargs): - data = request.data - try: - spl = data.split(";") - if len(spl) >= 7: - round = int(spl[0]) - teams = [] - solutions = [] - for i in range((len(spl) - 1) // 2): - trigram = spl[1 + 2 * i] - pb = int(spl[2 + 2 * i]) - team = Team.objects.get(trigram=trigram) - solution = Solution.objects.get(team=team, problem=pb, final=team.selected_for_final) - teams.append(team) - solutions.append(solution) - pool = Pool.objects.create(round=round) - pool.teams.set(teams) - pool.solutions.set(solutions) - pool.save() - serializer = PoolSerializer(pool) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) - except BaseException: # JSON data - pass - return super().create(request, *args, **kwargs) +from rest_framework import routers +from .viewsets import UserViewSet, TeamViewSet, TournamentViewSet, AuthorizationViewSet, MotivationLetterViewSet, \ + SolutionViewSet, SynthesisViewSet, PoolViewSet # Routers provide an easy way of automatically determining the URL conf. # Register each app API router and user viewset diff --git a/apps/api/viewsets.py b/apps/api/viewsets.py new file mode 100644 index 0000000..785e446 --- /dev/null +++ b/apps/api/viewsets.py @@ -0,0 +1,124 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import status +from rest_framework.filters import SearchFilter +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +from member.models import TFJMUser, Authorization, MotivationLetter, Solution, Synthesis +from tournament.models import Team, Tournament, Pool + +from .serializers import UserSerializer, TeamSerializer, TournamentSerializer, AuthorizationSerializer, \ + MotivationLetterSerializer, SolutionSerializer, SynthesisSerializer, PoolSerializer + + +class UserViewSet(ModelViewSet): + """ + Display list of users. + """ + queryset = TFJMUser.objects.all() + serializer_class = UserSerializer + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['id', 'first_name', 'last_name', 'email', 'gender', 'student_class', 'role', 'year', 'team', + 'team__trigram', 'is_superuser', 'is_staff', 'is_active', ] + search_fields = ['$first_name', '$last_name', ] + + +class TeamViewSet(ModelViewSet): + """ + Display list of teams. + """ + queryset = Team.objects.all() + serializer_class = TeamSerializer + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['name', 'trigram', 'validation_status', 'selected_for_final', 'access_code', 'tournament', + 'year', ] + search_fields = ['$name', 'trigram', ] + + +class TournamentViewSet(ModelViewSet): + """ + Display list of tournaments. + """ + queryset = Tournament.objects.all() + serializer_class = TournamentSerializer + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['name', 'size', 'price', 'date_start', 'date_end', 'final', 'organizers', 'year', ] + search_fields = ['$name', ] + + +class AuthorizationViewSet(ModelViewSet): + """ + Display list of authorizations. + """ + queryset = Authorization.objects.all() + serializer_class = AuthorizationSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['user', 'type', ] + + +class MotivationLetterViewSet(ModelViewSet): + """ + Display list of motivation letters. + """ + queryset = MotivationLetter.objects.all() + serializer_class = MotivationLetterSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['team', 'team__trigram', ] + + +class SolutionViewSet(ModelViewSet): + """ + Display list of solutions. + """ + queryset = Solution.objects.all() + serializer_class = SolutionSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['team', 'team__trigram', 'problem', ] + + +class SynthesisViewSet(ModelViewSet): + """ + Display list of syntheses. + """ + queryset = Synthesis.objects.all() + serializer_class = SynthesisSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['team', 'team__trigram', 'source', 'round', ] + + +class PoolViewSet(ModelViewSet): + """ + Display list of pools. + If the request is a POST request and the format is "A;X;x;Y;y;Z;z;..." where A = 1 or 1 = 2, + X, Y, Z, ... are team trigrams, x, y, z, ... are numbers of problems, then this is interpreted as a + creation a pool for the round A with the solutions of problems x, y, z, ... of the teams X, Y, Z, ... respectively. + """ + queryset = Pool.objects.all() + serializer_class = PoolSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['teams', 'teams__trigram', 'round', ] + + def create(self, request, *args, **kwargs): + data = request.data + try: + spl = data.split(";") + if len(spl) >= 7: + round = int(spl[0]) + teams = [] + solutions = [] + for i in range((len(spl) - 1) // 2): + trigram = spl[1 + 2 * i] + pb = int(spl[2 + 2 * i]) + team = Team.objects.get(trigram=trigram) + solution = Solution.objects.get(team=team, problem=pb, final=team.selected_for_final) + teams.append(team) + solutions.append(solution) + pool = Pool.objects.create(round=round) + pool.teams.set(teams) + pool.solutions.set(solutions) + pool.save() + serializer = PoolSerializer(pool) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + except BaseException: # JSON data + pass + return super().create(request, *args, **kwargs) \ No newline at end of file diff --git a/apps/member/admin.py b/apps/member/admin.py index f275084..bbed356 100644 --- a/apps/member/admin.py +++ b/apps/member/admin.py @@ -5,35 +5,51 @@ from member.models import TFJMUser, Document, Solution, Synthesis, MotivationLet @admin.register(TFJMUser) class TFJMUserAdmin(UserAdmin): + """ + Django admin page for users. + """ list_display = ('email', 'first_name', 'last_name', 'role', ) @admin.register(Document) class DocumentAdmin(PolymorphicParentModelAdmin): + """ + Django admin page for any documents. + """ child_models = (Authorization, MotivationLetter, Solution, Synthesis,) polymorphic_list = True @admin.register(Authorization) class AuthorizationAdmin(PolymorphicChildModelAdmin): - pass + """ + Django admin page for Authorization. + """ @admin.register(MotivationLetter) class MotivationLetterAdmin(PolymorphicChildModelAdmin): - pass + """ + Django admin page for Motivation letters. + """ @admin.register(Solution) class SolutionAdmin(PolymorphicChildModelAdmin): - pass + """ + Django admin page for solutions. + """ @admin.register(Synthesis) class SynthesisAdmin(PolymorphicChildModelAdmin): - pass + """ + Django admin page for syntheses. + """ @admin.register(Config) class ConfigAdmin(admin.ModelAdmin): - pass + """ + Django admin page for configurations. + """ diff --git a/apps/member/apps.py b/apps/member/apps.py index 6635e7e..61c9ae8 100644 --- a/apps/member/apps.py +++ b/apps/member/apps.py @@ -3,5 +3,8 @@ from django.utils.translation import gettext_lazy as _ class MemberConfig(AppConfig): + """ + The member app handles the information that concern a user, its documents, ... + """ name = 'member' verbose_name = _('member') diff --git a/apps/member/forms.py b/apps/member/forms.py index 9cbfde3..083b7b4 100644 --- a/apps/member/forms.py +++ b/apps/member/forms.py @@ -2,18 +2,22 @@ from django.contrib.auth.forms import UserCreationForm from django import forms from django.utils.translation import gettext_lazy as _ -from member.models import TFJMUser +from .models import TFJMUser class SignUpForm(UserCreationForm): + """ + Coaches and participants register on the website through this form. + TODO: Check if this form works, render it better + """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["first_name"].required = True self.fields["last_name"].required = True self.fields["role"].choices = [ ('', _("Choose a role...")), - ('participant', _("Participant")), - ('encadrant', _("Encadrant")), + ('3participant', _("Participant")), + ('2coach', _("Coach")), ] class Meta: @@ -40,6 +44,9 @@ class SignUpForm(UserCreationForm): class TFJMUserForm(forms.ModelForm): + """ + Form to update our own information when we are participant. + """ class Meta: model = TFJMUser fields = ('last_name', 'first_name', 'email', 'phone_number', 'gender', 'birth_date', 'address', 'postal_code', @@ -48,6 +55,9 @@ class TFJMUserForm(forms.ModelForm): class CoachUserForm(forms.ModelForm): + """ + Form to update our own information when we are coach. + """ class Meta: model = TFJMUser fields = ('last_name', 'first_name', 'email', 'phone_number', 'gender', 'birth_date', 'address', 'postal_code', @@ -55,6 +65,9 @@ class CoachUserForm(forms.ModelForm): class AdminUserForm(forms.ModelForm): + """ + Form to update our own information when we are organizer or admin. + """ class Meta: model = TFJMUser fields = ('last_name', 'first_name', 'email', 'phone_number', 'description',) diff --git a/apps/member/management/commands/create_su.py b/apps/member/management/commands/create_su.py index a3fac35..93ec091 100644 --- a/apps/member/management/commands/create_su.py +++ b/apps/member/management/commands/create_su.py @@ -7,6 +7,9 @@ from member.models import TFJMUser class Command(BaseCommand): def handle(self, *args, **options): + """ + Little script that generate a superuser. + """ email = input("Email: ") password = "1" confirm_password = "2" diff --git a/apps/member/management/commands/import_olddb.py b/apps/member/management/commands/import_olddb.py index bc6cf25..d3ff94f 100644 --- a/apps/member/management/commands/import_olddb.py +++ b/apps/member/management/commands/import_olddb.py @@ -1,3 +1,5 @@ +import os + from django.core.management import BaseCommand, CommandError from django.db import transaction from member.models import TFJMUser, Document, Solution, Synthesis, Authorization, MotivationLetter @@ -5,6 +7,11 @@ from tournament.models import Team, Tournament class Command(BaseCommand): + """ + Import the old database. + Tables must be found into the import_olddb folder, as CSV files. + """ + def add_arguments(self, parser): parser.add_argument('--tournaments', '-t', action="store", help="Import tournaments") parser.add_argument('--teams', '-T', action="store", help="Import teams") @@ -26,6 +33,9 @@ class Command(BaseCommand): @transaction.atomic def import_tournaments(self): + """ + Import tournaments into the new database. + """ print("Importing tournaments...") with open("import_olddb/tournaments.csv") as f: first_line = True @@ -75,6 +85,9 @@ class Command(BaseCommand): @transaction.atomic def import_teams(self): + """ + Import teams into new database. + """ self.stdout.write("Importing teams...") with open("import_olddb/teams.csv") as f: first_line = True @@ -120,6 +133,10 @@ class Command(BaseCommand): @transaction.atomic def import_users(self): + """ + Import users into the new database. + :return: + """ self.stdout.write("Importing users...") with open("import_olddb/users.csv") as f: first_line = True @@ -159,7 +176,7 @@ class Command(BaseCommand): "team": Team.objects.get(pk=args[19]) if args[19] else None, "year": args[20], "date_joined": args[23], - "is_active": args[18] == "ADMIN", # TODO Replace it with "True" + "is_active": args[18] == "ADMIN" or os.getenv("TFJM_STAGE", "dev") == "prod", "is_staff": args[18] == "ADMIN", "is_superuser": args[18] == "ADMIN", } @@ -168,6 +185,7 @@ class Command(BaseCommand): self.stdout.write(self.style.SUCCESS("Users imported")) self.stdout.write("Importing organizers...") + # We also import the information about the organizers of a tournament. with open("import_olddb/organizers.csv") as f: first_line = True for line in f: @@ -188,6 +206,9 @@ class Command(BaseCommand): @transaction.atomic def import_documents(self): + """ + Import the documents (authorizations, motivation letters, solutions, syntheses) from the old database. + """ self.stdout.write("Importing documents...") with open("import_olddb/documents.csv") as f: first_line = True diff --git a/apps/member/models.py b/apps/member/models.py index aa2f1a9..b05d6a2 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -10,12 +10,16 @@ from tournament.models import Team, Tournament class TFJMUser(AbstractUser): + """ + The model of registered users (organizers/juries/admins/coachs/participants) + """ USERNAME_FIELD = 'email' REQUIRED_FIELDS = [] email = models.EmailField( unique=True, verbose_name=_("email"), + help_text=_("This should be valid and will be controlled."), ) team = models.ForeignKey( @@ -24,6 +28,7 @@ class TFJMUser(AbstractUser): on_delete=models.SET_NULL, related_name="users", verbose_name=_("team"), + help_text=_("Concerns only coaches and participants."), ) birth_date = models.DateField( @@ -141,14 +146,25 @@ class TFJMUser(AbstractUser): @property def participates(self): + """ + Return True iff this user is a participant or a coach, ie. if the user is a member of a team that worked + for the tournament. + """ return self.role == "3participant" or self.role == "2coach" @property def organizes(self): + """ + Return True iff this user is a local or global organizer of the tournament. This includes juries. + """ return self.role == "1volunteer" or self.role == "0admin" @property def admin(self): + """ + Return True iff this user is a global organizer, ie. an administrator. This should be equivalent to be + a superuser. + """ return self.role == "0admin" class Meta: @@ -156,6 +172,7 @@ class TFJMUser(AbstractUser): verbose_name_plural = _("users") def save(self, *args, **kwargs): + # We ensure that the username is the email of the user. self.username = self.email super().save(*args, **kwargs) @@ -164,6 +181,9 @@ class TFJMUser(AbstractUser): class Document(PolymorphicModel): + """ + Abstract model of any saved document (solution, synthesis, motivation letter, authorization) + """ file = models.FileField( unique=True, verbose_name=_("file"), @@ -184,6 +204,9 @@ class Document(PolymorphicModel): class Authorization(Document): + """ + Model for authorization papers (parental consent, photo consent, sanitary plug, ...) + """ user = models.ForeignKey( TFJMUser, on_delete=models.CASCADE, @@ -211,6 +234,9 @@ class Authorization(Document): class MotivationLetter(Document): + """ + Model for motivation letters of a team. + """ team = models.ForeignKey( Team, on_delete=models.CASCADE, @@ -227,6 +253,9 @@ class MotivationLetter(Document): class Solution(Document): + """ + Model for solutions of team for a given problem, for the regional or final tournament. + """ team = models.ForeignKey( Team, on_delete=models.CASCADE, @@ -245,6 +274,11 @@ class Solution(Document): @property def tournament(self): + """ + Get the concerned tournament of a solution. + Generally the local tournament of a team, but it can be the final tournament if this is a solution for the + final tournament. + """ return Tournament.get_final() if self.final else self.team.tournament class Meta: @@ -258,6 +292,9 @@ class Solution(Document): class Synthesis(Document): + """ + Model for syntheses of a team for a given round and for a given role, for the regional or final tournament. + """ team = models.ForeignKey( Team, on_delete=models.CASCADE, @@ -289,6 +326,11 @@ class Synthesis(Document): @property def tournament(self): + """ + Get the concerned tournament of a solution. + Generally the local tournament of a team, but it can be the final tournament if this is a solution for the + final tournament. + """ return Tournament.get_final() if self.final else self.team.tournament class Meta: @@ -303,6 +345,9 @@ class Synthesis(Document): class Config(models.Model): + """ + Dictionary of configuration variables. + """ key = models.CharField( max_length=255, primary_key=True, diff --git a/apps/member/tables.py b/apps/member/tables.py index 6caad0d..779dc47 100644 --- a/apps/member/tables.py +++ b/apps/member/tables.py @@ -1,10 +1,13 @@ import django_tables2 as tables from django_tables2 import A -from member.models import TFJMUser +from .models import TFJMUser class UserTable(tables.Table): + """ + Table of users that are matched with a given queryset. + """ last_name = tables.LinkColumn( "member:information", args=[A("pk")], diff --git a/apps/member/templatetags/getconfig.py b/apps/member/templatetags/getconfig.py index 46b3cfa..0c6d776 100644 --- a/apps/member/templatetags/getconfig.py +++ b/apps/member/templatetags/getconfig.py @@ -6,11 +6,17 @@ from member.models import Config def get_config(value): + """ + Return a value stored into the config table in the database with a given key. + """ config = Config.objects.get_or_create(key=value)[0] return config.value def get_env(value): + """ + Get a specified environment variable. + """ return os.getenv(value) diff --git a/apps/member/views.py b/apps/member/views.py index a9b3913..6b2fbe4 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -12,26 +12,33 @@ from django.utils.translation import gettext_lazy as _ from django.views import View from django.views.generic import CreateView, UpdateView, DetailView, FormView from django_tables2 import SingleTableView - from tournament.forms import TeamForm, JoinTeam from tournament.models import Team from tournament.views import AdminMixin, TeamMixin + from .forms import SignUpForm, TFJMUserForm, AdminUserForm, CoachUserForm from .models import TFJMUser, Document, Solution, MotivationLetter, Synthesis from .tables import UserTable class CreateUserView(CreateView): + """ + Signup form view. + """ model = TFJMUser form_class = SignUpForm template_name = "registration/signup.html" class MyAccountView(LoginRequiredMixin, UpdateView): + """ + Update our personal data. + """ model = TFJMUser template_name = "member/my_account.html" def get_form_class(self): + # The used form can change according to the role of the user. return AdminUserForm if self.request.user.organizes else TFJMUserForm \ if self.request.user.role == "3participant" else CoachUserForm @@ -40,6 +47,10 @@ class MyAccountView(LoginRequiredMixin, UpdateView): class UserDetailView(LoginRequiredMixin, DetailView): + """ + View the personal information of a given user. + Only organizers can see this page, since there are personal data. + """ model = TFJMUser form_class = TFJMUserForm context_object_name = "tfjmuser" @@ -57,7 +68,10 @@ class UserDetailView(LoginRequiredMixin, DetailView): return super().dispatch(request, *args, **kwargs) def post(self, request, *args, **kwargs): - if "view_as" in request.POST: + """ + An administrator can log in through this page as someone else, and act as this other person. + """ + if "view_as" in request.POST and self.request.admin: session = request.session session["admin"] = request.user.pk obj = self.get_object() @@ -74,6 +88,10 @@ class UserDetailView(LoginRequiredMixin, DetailView): class AddTeamView(LoginRequiredMixin, CreateView): + """ + Register a new team. + Users can choose the name, the trigram and a preferred tournament. + """ model = Team form_class = TeamForm @@ -86,6 +104,7 @@ class AddTeamView(LoginRequiredMixin, CreateView): form.add_error('name', _("You are already in a team.")) return self.form_invalid(form) + # Generate a random access code team = form.instance alphabet = "0123456789abcdefghijklmnopqrstuvwxyz0123456789" code = "" @@ -107,6 +126,9 @@ class AddTeamView(LoginRequiredMixin, CreateView): class JoinTeamView(LoginRequiredMixin, FormView): + """ + Join a team with a given access code. + """ model = Team form_class = JoinTeam template_name = "tournament/team_form.html" @@ -122,7 +144,7 @@ class JoinTeamView(LoginRequiredMixin, FormView): form.add_error('access_code', _("You are already in a team.")) return self.form_invalid(form) - if self.request.user.role == '2coach' and len(team.encadrants) == 3: + if self.request.user.role == '2coach' and len(team.coaches) == 3: form.add_error('access_code', _("This team is full of coachs.")) return self.form_invalid(form) @@ -130,6 +152,9 @@ class JoinTeamView(LoginRequiredMixin, FormView): form.add_error('access_code', _("This team is full of participants.")) return self.form_invalid(form) + if not team.invalid: + form.add_error('access_code', _("This team is already validated or waiting for validation.")) + self.request.user.team = team self.request.user.save() return super().form_valid(form) @@ -139,11 +164,24 @@ class JoinTeamView(LoginRequiredMixin, FormView): class MyTeamView(TeamMixin, View): + """ + Redirect to the page of the information of our personal team. + """ + def get(self, request, *args, **kwargs): return redirect("tournament:team_detail", pk=request.user.team.pk) class DocumentView(LoginRequiredMixin, View): + """ + View a PDF document, if we have the right. + + - Everyone can see the documents that concern itself. + - An administrator can see anything. + - An organizer can see documents that are related to its tournament. + - A jury can see solutions and syntheses that are evaluated in their pools. + """ + def get(self, request, *args, **kwargs): doc = Document.objects.get(file=self.kwargs["file"]) @@ -172,6 +210,9 @@ class DocumentView(LoginRequiredMixin, View): class ProfileListView(AdminMixin, SingleTableView): + """ + List all registered profiles. + """ model = TFJMUser queryset = TFJMUser.objects.order_by("role", "last_name", "first_name") table_class = UserTable @@ -180,6 +221,9 @@ class ProfileListView(AdminMixin, SingleTableView): class OrphanedProfileListView(AdminMixin, SingleTableView): + """ + List all orphaned profiles, ie. participants that have no team. + """ model = TFJMUser queryset = TFJMUser.objects.filter((Q(role="2coach") | Q(role="3participant")) & Q(team__isnull=True))\ .order_by("role", "last_name", "first_name") @@ -189,6 +233,9 @@ class OrphanedProfileListView(AdminMixin, SingleTableView): class OrganizersListView(AdminMixin, SingleTableView): + """ + List all organizers. + """ model = TFJMUser queryset = TFJMUser.objects.filter(Q(role="0admin") | Q(role="1volunteer"))\ .order_by("role", "last_name", "first_name") @@ -198,6 +245,10 @@ class OrganizersListView(AdminMixin, SingleTableView): class ResetAdminView(AdminMixin, View): + """ + Return to admin view, clear the session field that let an administrator to log in as someone else. + """ + def dispatch(self, request, *args, **kwargs): if "_fake_user_id" in request.session: del request.session["_fake_user_id"] diff --git a/apps/tournament/admin.py b/apps/tournament/admin.py index cbe128d..c55cc4b 100644 --- a/apps/tournament/admin.py +++ b/apps/tournament/admin.py @@ -1,23 +1,31 @@ from django.contrib.auth.admin import admin -from tournament.models import Team, Tournament, Pool, Payment +from .models import Team, Tournament, Pool, Payment @admin.register(Team) class TeamAdmin(admin.ModelAdmin): - pass + """ + Django admin page for teams. + """ @admin.register(Tournament) class TournamentAdmin(admin.ModelAdmin): - pass + """ + Django admin page for tournaments. + """ @admin.register(Pool) class PoolAdmin(admin.ModelAdmin): - pass + """ + Django admin page for pools. + """ @admin.register(Payment) class PaymentAdmin(admin.ModelAdmin): - pass + """ + Django admin page for payments. + """ diff --git a/apps/tournament/apps.py b/apps/tournament/apps.py index 2df95c1..66a212b 100644 --- a/apps/tournament/apps.py +++ b/apps/tournament/apps.py @@ -3,5 +3,8 @@ from django.utils.translation import gettext_lazy as _ class TournamentConfig(AppConfig): + """ + The tournament app handles all that is related to the tournaments. + """ name = 'tournament' verbose_name = _('tournament') diff --git a/apps/tournament/forms.py b/apps/tournament/forms.py index d46129d..4c4353d 100644 --- a/apps/tournament/forms.py +++ b/apps/tournament/forms.py @@ -12,6 +12,11 @@ from tournament.models import Tournament, Team, Pool class TournamentForm(forms.ModelForm): + """ + Create and update tournaments. + """ + + # Only organizers can organize tournaments. Well, that's pretty normal... organizers = forms.ModelMultipleChoiceField( TFJMUser.objects.filter(Q(role="0admin") | Q(role="1volunteer")).order_by('role'), label=_("Organizers"), @@ -44,6 +49,10 @@ class TournamentForm(forms.ModelForm): class OrganizerForm(forms.ModelForm): + """ + Register an organizer in the website. + """ + class Meta: model = TFJMUser fields = ('last_name', 'first_name', 'email', 'is_superuser',) @@ -64,6 +73,9 @@ class OrganizerForm(forms.ModelForm): class TeamForm(forms.ModelForm): + """ + Add and update a team. + """ tournament = forms.ModelChoiceField( Tournament.objects.filter(date_inscription__gte=timezone.now(), final=False), ) @@ -94,6 +106,10 @@ class TeamForm(forms.ModelForm): class JoinTeam(forms.Form): + """ + Form to join a team with an access code. + """ + access_code = forms.CharField( label=_("Access code"), max_length=6, @@ -117,6 +133,10 @@ class JoinTeam(forms.Form): class SolutionForm(forms.ModelForm): + """ + Form to upload a solution. + """ + problem = forms.ChoiceField( label=_("Problem"), choices=[(str(i), _("Problem #{problem:d}").format(problem=i)) for i in range(1, 9)], @@ -128,12 +148,21 @@ class SolutionForm(forms.ModelForm): class SynthesisForm(forms.ModelForm): + """ + Form to upload a synthesis. + """ + class Meta: model = Synthesis fields = ('file', 'source', 'round',) class PoolForm(forms.ModelForm): + """ + Form to add a pool. + Should not be used: prefer to pass by API and auto-add pools with the results of the draw. + """ + team1 = forms.ModelChoiceField( Team.objects.filter(validation_status="2valid").all(), empty_label=_("Choose a team..."), diff --git a/apps/tournament/models.py b/apps/tournament/models.py index 9a5732e..ae26b7a 100644 --- a/apps/tournament/models.py +++ b/apps/tournament/models.py @@ -9,6 +9,10 @@ from django.utils.translation import gettext_lazy as _ class Tournament(models.Model): + """ + Store the information of a tournament. + """ + name = models.CharField( max_length=255, verbose_name=_("name"), @@ -18,10 +22,12 @@ class Tournament(models.Model): 'member.TFJMUser', related_name="organized_tournaments", verbose_name=_("organizers"), + help_text=_("List of all organizers that can see and manipulate data of the tournament and the teams."), ) size = models.PositiveSmallIntegerField( verbose_name=_("size"), + help_text=_("Number of teams that are allowed to join the tournament."), ) place = models.CharField( @@ -31,6 +37,7 @@ class Tournament(models.Model): price = models.PositiveSmallIntegerField( verbose_name=_("price"), + help_text=_("Price asked to participants. Free with a scholarship."), ) description = models.TextField( @@ -74,6 +81,7 @@ class Tournament(models.Model): final = models.BooleanField( verbose_name=_("final tournament"), + help_text=_("It should be only one final tournament."), ) year = models.PositiveIntegerField( @@ -83,27 +91,43 @@ class Tournament(models.Model): @property def teams(self): + """ + Get all teams that are registered to this tournament, with a distinction for the final tournament. + """ return self._teams if not self.final else Team.objects.filter(selected_for_final=True) @property def linked_organizers(self): + """ + Display a list of the organizers with links to their personal page. + """ return [''.format(url=reverse_lazy("member:information", args=(user.pk,))) + str(user) + '' for user in self.organizers.all()] @property def solutions(self): + """ + Get all sent solutions for this tournament. + """ from member.models import Solution return Solution.objects.filter(final=self.final) if self.final \ - else Solution.objects.filter(team__tournament=self) + else Solution.objects.filter(team__tournament=self, final=False) @property def syntheses(self): + """ + Get all sent syntheses for this tournament. + """ from member.models import Synthesis return Synthesis.objects.filter(final=self.final) if self.final \ - else Synthesis.objects.filter(team__tournament=self) + else Synthesis.objects.filter(team__tournament=self, final=False) @classmethod def get_final(cls): + """ + Get the final tournament. + This should exist and be unique. + """ return cls.objects.get(year=os.getenv("TFJM_YEAR"), final=True) class Meta: @@ -111,6 +135,12 @@ class Tournament(models.Model): verbose_name_plural = _("tournaments") def send_mail_to_organizers(self, template_name, subject="Contact TFJM²", **kwargs): + """ + Send a mail to all organizers of the tournament. + The template of the mail should be found either in templates/mail_templates/.html for the HTML + version and in templates/mail_templates/.txt for the plain text version. + The context of the template contains the tournament and the user. Extra context can be given through the kwargs. + """ context = kwargs context["tournament"] = self for user in self.organizers.all(): @@ -130,6 +160,10 @@ class Tournament(models.Model): class Team(models.Model): + """ + Store information about a registered team. + """ + name = models.CharField( max_length=255, verbose_name=_("name"), @@ -138,6 +172,7 @@ class Team(models.Model): trigram = models.CharField( max_length=3, verbose_name=_("trigram"), + help_text=_("The trigram should be composed of 3 capitalize letters, that is a funny acronym for the team."), ) tournament = models.ForeignKey( @@ -145,6 +180,7 @@ class Team(models.Model): on_delete=models.PROTECT, related_name="_teams", verbose_name=_("tournament"), + help_text=_("The tournament where the team is registered."), ) inscription_date = models.DateTimeField( @@ -191,31 +227,59 @@ class Team(models.Model): return self.validation_status == "0invalid" @property - def encadrants(self): + def coaches(self): + """ + Get all coaches of a team. + """ return self.users.all().filter(role="2coach") @property - def linked_encadrants(self): + def linked_coaches(self): + """ + Get a list of the coaches of a team with html links to their pages. + """ return [''.format(url=reverse_lazy("member:information", args=(user.pk,))) + str(user) + '' - for user in self.encadrants] + for user in self.coaches] @property def participants(self): + """ + Get all particpants of a team, coaches excluded. + """ return self.users.all().filter(role="3participant") @property def linked_participants(self): + """ + Get a list of the participants of a team with html links to their pages. + """ return [''.format(url=reverse_lazy("member:information", args=(user.pk,))) + str(user) + '' for user in self.participants] @property def future_tournament(self): + """ + Get the last tournament where the team is registered. + Only matters if the team is selected for final: if this is the case, we return the final tournament. + Useful for deadlines. + """ return Tournament.get_final() if self.selected_for_final else self.tournament @property def can_validate(self): + """ + Check if a given team is able to ask for validation. + A team can validate if: + * All participants filled the photo consent + * Minor participants filled the parental consent + * Minor participants filled the sanitary plug + * Teams sent their motivation letter + * The team contains at least 4 participants + * The team contains at least 1 coach + """ # TODO In a normal time, team needs a motivation letter and authorizations. - return self.encadrants.exists() and self.participants.count() >= 4 + return self.coaches.exists() and self.participants.count() >= 4\ + and self.tournament.date_inscription <= timezone.now() class Meta: verbose_name = _("team") @@ -223,6 +287,12 @@ class Team(models.Model): unique_together = (('name', 'year',), ('trigram', 'year',),) def send_mail(self, template_name, subject="Contact TFJM²", **kwargs): + """ + Send a mail to all members of a team with a given template. + The template of the mail should be found either in templates/mail_templates/.html for the HTML + version and in templates/mail_templates/.txt for the plain text version. + The context of the template contains the team and the user. Extra context can be given through the kwargs. + """ context = kwargs context["team"] = self for user in self.users.all(): @@ -236,6 +306,12 @@ class Team(models.Model): class Pool(models.Model): + """ + Store information of a pool. + A pool is only a list of accessible solutions to some teams and some juries. + TODO: check that the set of teams is equal to the set of the teams that have a solution in this set. + TODO: Moreover, a team should send only one solution. + """ teams = models.ManyToManyField( Team, related_name="pools", @@ -264,14 +340,24 @@ class Pool(models.Model): @property def problems(self): + """ + Get problem numbers of the sent solutions as a list of integers. + """ return list(d["problem"] for d in self.solutions.values("problem").all()) @property def tournament(self): + """ + Get the concerned tournament. + We assume that the pool is correct, so all solutions belong to the same tournament. + """ return self.solutions.first().tournament @property def syntheses(self): + """ + Get the syntheses of the teams that are in this pool, for the correct round. + """ from member.models import Synthesis return Synthesis.objects.filter(team__in=self.teams.all(), round=self.round, final=self.tournament.final) @@ -281,6 +367,10 @@ class Pool(models.Model): class Payment(models.Model): + """ + Store some information about payments, to recover data. + TODO: handle it... + """ user = models.OneToOneField( 'member.TFJMUser', on_delete=models.CASCADE, diff --git a/apps/tournament/tables.py b/apps/tournament/tables.py index 6fcf95e..33e39a1 100644 --- a/apps/tournament/tables.py +++ b/apps/tournament/tables.py @@ -9,6 +9,10 @@ from .models import Tournament, Team, Pool class TournamentTable(tables.Table): + """ + List all tournaments. + """ + name = tables.LinkColumn( "tournament:detail", args=[A("pk")], @@ -31,6 +35,10 @@ class TournamentTable(tables.Table): class TeamTable(tables.Table): + """ + Table of some teams. Can be filtered with a queryset (for example, teams of a tournament) + """ + name = tables.LinkColumn( "tournament:team_detail", args=[A("pk")], @@ -46,6 +54,10 @@ class TeamTable(tables.Table): class SolutionTable(tables.Table): + """ + Display a table of some solutions. + """ + team = tables.LinkColumn( "tournament:team_detail", args=[A("team.pk")], @@ -81,6 +93,10 @@ class SolutionTable(tables.Table): class SynthesisTable(tables.Table): + """ + Display a table of some syntheses. + """ + team = tables.LinkColumn( "tournament:team_detail", args=[A("team.pk")], @@ -116,6 +132,10 @@ class SynthesisTable(tables.Table): class PoolTable(tables.Table): + """ + Display a table of some pools. + """ + problems = tables.Column( verbose_name=_("Problems"), orderable=False, diff --git a/apps/tournament/views.py b/apps/tournament/views.py index 45611e9..0211364 100644 --- a/apps/tournament/views.py +++ b/apps/tournament/views.py @@ -24,6 +24,10 @@ from .tables import TournamentTable, TeamTable, SolutionTable, SynthesisTable, P class AdminMixin(LoginRequiredMixin): + """ + If a view extends this mixin, then the view will be only accessible to administrators. + """ + def dispatch(self, request, *args, **kwargs): if not request.user.is_authenticated or not request.user.admin: raise PermissionDenied @@ -31,6 +35,10 @@ class AdminMixin(LoginRequiredMixin): class OrgaMixin(LoginRequiredMixin): + """ + If a view extends this mixin, then the view will be only accessible to administrators or organizers. + """ + def dispatch(self, request, *args, **kwargs): if not request.user.is_authenticated or not request.user.organizes: raise PermissionDenied @@ -38,6 +46,10 @@ class OrgaMixin(LoginRequiredMixin): class TeamMixin(LoginRequiredMixin): + """ + If a view extends this mixin, then the view will be only accessible to users that are registered in a team. + """ + def dispatch(self, request, *args, **kwargs): if not request.user.is_authenticated or not request.user.team: raise PermissionDenied @@ -45,6 +57,10 @@ class TeamMixin(LoginRequiredMixin): class TournamentListView(SingleTableView): + """ + Display the list of all tournaments, ordered by start date then name. + """ + model = Tournament table_class = TournamentTable extra_context = dict(title=_("Tournaments list"),) @@ -64,6 +80,10 @@ class TournamentListView(SingleTableView): class TournamentCreateView(AdminMixin, CreateView): + """ + Create a tournament. Only accessible to admins. + """ + model = Tournament form_class = TournamentForm extra_context = dict(title=_("Add tournament"),) @@ -73,6 +93,11 @@ class TournamentCreateView(AdminMixin, CreateView): class TournamentDetailView(DetailView): + """ + Display the detail of a tournament. + Accessible to all, including not authenticated users. + """ + model = Tournament def get_context_data(self, **kwargs): @@ -96,7 +121,20 @@ class TournamentDetailView(DetailView): return context -class TournamentUpdateView(AdminMixin, UpdateView): +class TournamentUpdateView(OrgaMixin, UpdateView): + """ + Update the data of a tournament. + Reserved to admins and organizers of the tournament. + """ + + def dispatch(self, request, *args, **kwargs): + """ + Restrict the view to organizers of tournaments, then process the request. + """ + if self.request.user.role == "1volunteer" and self.request.user not in self.get_object().organizers.all(): + raise PermissionDenied + return super().dispatch(request, *args, **kwargs) + model = Tournament form_class = TournamentForm extra_context = dict(title=_("Update tournament"),) @@ -106,9 +144,16 @@ class TournamentUpdateView(AdminMixin, UpdateView): class TeamDetailView(LoginRequiredMixin, DetailView): + """ + View the detail of a team. + Restricted to this team, admins and organizers of its tournament. + """ model = Team def dispatch(self, request, *args, **kwargs): + """ + Protect the page and process the request. + """ if not request.user.is_authenticated or \ (not request.user.admin and self.request.user not in self.get_object().tournament.organizers.all() and self.get_object() != request.user.team): @@ -116,7 +161,15 @@ class TeamDetailView(LoginRequiredMixin, DetailView): return super().dispatch(request, *args, **kwargs) def post(self, request, *args, **kwargs): - print(request.POST) + """ + Process POST requests. Supported requests: + - get the solutions of the team as a ZIP archive + - a user leaves its team (if the composition is not validated yet) + - the team requests the validation + - Organizers can validate or invalidate the request + - Admins can delete teams + - Admins can select teams for the final tournament + """ team = self.get_object() if "zip" in request.POST: solutions = team.solutions.all() @@ -140,7 +193,7 @@ class TeamDetailView(LoginRequiredMixin, DetailView): if not team.users.exists(): team.delete() return redirect('tournament:detail', pk=team.tournament.pk) - elif "request_validation" in request.POST and request.user.participates: + elif "request_validation" in request.POST and request.user.participates and team.can_validate: team.validation_status = "1waiting" team.save() team.tournament.send_mail_to_organizers("request_validation", "Demande de validation TFJM²", team=team) @@ -159,6 +212,7 @@ class TeamDetailView(LoginRequiredMixin, DetailView): team.delete() return redirect('tournament:detail', pk=team.tournament.pk) elif "select_final" in request.POST and request.user.admin and not team.selected_for_final and team.pools: + # We copy all solutions for solutions for the final for solution in team.solutions.all(): alphabet = "0123456789abcdefghijklmnopqrstuvwxyz0123456789" id = "" @@ -194,6 +248,11 @@ class TeamDetailView(LoginRequiredMixin, DetailView): class TeamUpdateView(LoginRequiredMixin, UpdateView): + """ + Update the information about a team. + Team members, admins and organizers are allowed to do this. + """ + model = Team form_class = TeamForm extra_context = dict(title=_("Update team"),) @@ -206,6 +265,12 @@ class TeamUpdateView(LoginRequiredMixin, UpdateView): class AddOrganizerView(AdminMixin, CreateView): + """ + Add a new organizer account. No password is created, the user should reset its password using the link + sent by mail. Only name and email are requested. + Only admins are granted to do this. + """ + model = TFJMUser form_class = OrganizerForm extra_context = dict(title=_("Add organizer"),) @@ -223,6 +288,10 @@ class AddOrganizerView(AdminMixin, CreateView): class SolutionsView(TeamMixin, BaseFormView, SingleTableView): + """ + Upload and view solutions for a team. + """ + model = Solution table_class = SolutionTable form_class = SolutionForm @@ -288,6 +357,11 @@ class SolutionsView(TeamMixin, BaseFormView, SingleTableView): class SolutionsOrgaListView(OrgaMixin, SingleTableView): + """ + View all solutions sent by teams for the organized tournaments. Juries can view solutions of their pools. + Organizers can download a ZIP archive for each organized tournament. + """ + model = Solution table_class = SolutionTable template_name = "tournament/solutions_orga_list.html" @@ -333,6 +407,9 @@ class SolutionsOrgaListView(OrgaMixin, SingleTableView): class SynthesesView(TeamMixin, BaseFormView, SingleTableView): + """ + Upload and view syntheses for a team. + """ model = Synthesis table_class = SynthesisTable form_class = SynthesisForm @@ -407,6 +484,10 @@ class SynthesesView(TeamMixin, BaseFormView, SingleTableView): class SynthesesOrgaListView(OrgaMixin, SingleTableView): + """ + View all syntheses sent by teams for the organized tournaments. Juries can view syntheses of their pools. + Organizers can download a ZIP archive for each organized tournament. + """ model = Synthesis table_class = SynthesisTable template_name = "tournament/syntheses_orga_list.html" @@ -452,6 +533,10 @@ class SynthesesOrgaListView(OrgaMixin, SingleTableView): class PoolListView(LoginRequiredMixin, SingleTableView): + """ + View the list of visible pools. + Admins see all, juries see their own pools, organizers see the pools of their tournaments. + """ model = Pool table_class = PoolTable extra_context = dict(title=_("Pools")) @@ -469,6 +554,10 @@ class PoolListView(LoginRequiredMixin, SingleTableView): class PoolCreateView(AdminMixin, CreateView): + """ + Create a pool manually. + This page should not be used: prefer send automatically data from the drawing bot. + """ model = Pool form_class = PoolForm extra_context = dict(title=_("Create pool")) @@ -478,6 +567,15 @@ class PoolCreateView(AdminMixin, CreateView): class PoolDetailView(LoginRequiredMixin, DetailView): + """ + See the detail of a pool. + Teams and juries can download here defended solutions of the pool. + If this is the second round, teams can't download solutions of the other teams before the date when they + should be available. + Juries see also syntheses. They see of course solutions immediately. + This is also true for organizers and admins. + All can be downloaded as a ZIP archive. + """ model = Pool extra_context = dict(title=_("Pool detail")) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 1971bdf..4bdbdf1 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -34,10 +34,6 @@ msgstr "Choisir un rôle ..." msgid "Participant" msgstr "Participant" -#: apps/member/forms.py:16 -msgid "Encadrant" -msgstr "Encadrant" - #: apps/member/models.py:18 templates/member/tfjmuser_detail.html:35 msgid "email" msgstr "Adresse électronique" diff --git a/templates/tournament/team_detail.html b/templates/tournament/team_detail.html index 7bf88f9..21dbada 100644 --- a/templates/tournament/team_detail.html +++ b/templates/tournament/team_detail.html @@ -23,7 +23,7 @@ href="{% url "tournament:detail" pk=team.tournament.pk %}">{{ team.tournament }}
{% trans 'coachs'|capfirst %}
-
{% autoescape off %}{{ team.linked_encadrants|join:", " }}{% endautoescape %}
+
{% autoescape off %}{{ team.linked_coaches|join:", " }}{% endautoescape %}
{% trans 'participants'|capfirst %}
diff --git a/tfjm/settings.py b/tfjm/settings.py index 46a2edc..7b143b2 100644 --- a/tfjm/settings.py +++ b/tfjm/settings.py @@ -181,3 +181,6 @@ if os.getenv("TFJM_STAGE", "dev") == "prod": from .settings_prod import * else: from .settings_dev import * + INSTALLED_APPS += [ + "django_extensions" + ]