diff --git a/src/chigame/api/filters.py b/src/chigame/api/filters.py index d28db9303..c333c7e5b 100644 --- a/src/chigame/api/filters.py +++ b/src/chigame/api/filters.py @@ -7,9 +7,52 @@ class GameFilter(django_filters.FilterSet): # https://www.w3schools.com/django/ref_lookups_icontains.php # icontains: case-insensitive containment test name = django_filters.CharFilter(lookup_expr="icontains") + + year_published = django_filters.NumberFilter(lookup_expr="exact") + year_published__gte = django_filters.NumberFilter(field_name="year_published", lookup_expr="gte") + year_published__lte = django_filters.NumberFilter(field_name="year_published", lookup_expr="lte") + + suggested_age = django_filters.NumberFilter(lookup_expr="exact") + suggested_age__gte = django_filters.NumberFilter(field_name="suggested_age", lookup_expr="gte") + suggested_age__lte = django_filters.NumberFilter(field_name="suggested_age", lookup_expr="lte") + + expected_playtime = django_filters.NumberFilter(lookup_expr="exact") + expected_playtime__gte = django_filters.NumberFilter(field_name="expected_playtime", lookup_expr="gte") + expected_playtime__lte = django_filters.NumberFilter(field_name="expected_playtime", lookup_expr="lte") + + min_playtime = django_filters.NumberFilter(lookup_expr="exact") + min_playtime__gte = django_filters.NumberFilter(field_name="min_playtime", lookup_expr="gte") + min_playtime__lte = django_filters.NumberFilter(field_name="min_playtime", lookup_expr="lte") + + max_playtime = django_filters.NumberFilter(lookup_expr="exact") + max_playtime__gte = django_filters.NumberFilter(field_name="max_playtime", lookup_expr="gte") + max_playtime__lte = django_filters.NumberFilter(field_name="max_playtime", lookup_expr="lte") + min_players = django_filters.NumberFilter(lookup_expr="exact") + min_players__gte = django_filters.NumberFilter(field_name="min_players", lookup_expr="gte") + min_players__lte = django_filters.NumberFilter(field_name="min_players", lookup_expr="lte") + max_players = django_filters.NumberFilter(lookup_expr="exact") + max_players__gte = django_filters.NumberFilter(field_name="max_players", lookup_expr="gte") + max_players__lte = django_filters.NumberFilter(field_name="max_players", lookup_expr="lte") + + complexity = django_filters.NumberFilter(lookup_expr="exact") + complexity__gte = django_filters.NumberFilter(field_name="complexity", lookup_expr="gte") + complexity__lte = django_filters.NumberFilter(field_name="complexity", lookup_expr="lte") + + BGG_id = django_filters.NumberFilter(lookup_expr="exact") class Meta: model = Game - fields = ["name", "min_players", "max_players"] + fields = [ + "name", + "year_published", + "suggested_age", + "expected_playtime", + "min_playtime", + "max_playtime", + "complexity", + "min_players", + "max_players", + "BGG_id", + ] diff --git a/src/chigame/games/models.py b/src/chigame/games/models.py index 2913d104f..870af9f99 100644 --- a/src/chigame/games/models.py +++ b/src/chigame/games/models.py @@ -254,7 +254,7 @@ class Tournament(models.Model): num_winner = models.PositiveIntegerField(default=1) # number of possible winners for the tournament archived = models.BooleanField(default=False) # whether the tournament is archived by the admin - matches = models.ManyToManyField(Match, related_name="matches", blank=True) + matches = models.ManyToManyField(Match, related_name="tournament", blank=True) winners = models.ManyToManyField(User, related_name="won_tournaments", blank=True) # allow multiple winners players = models.ManyToManyField(User, related_name="joined_tournaments", blank=True) @@ -263,17 +263,21 @@ def status(self): """ Returns the status of the tournament. """ - if self.registration_start_date > timezone.now(): + if ( + self.registration_start_date > timezone.now() + ): # the period when the information of the tournament is displayed return "preparing" - elif self.registration_end_date > timezone.now(): # the registration period has started but not ended yet + elif ( + self.registration_end_date > timezone.now() + ): # the registration period has started, users can register for the tournament return "registration open" elif ( self.tournament_start_date > timezone.now() - ): # the registration period has ended but the tournament has not started yet + ): # the period when the registration period has ended but the tournament has not started yet return "registration closed" - elif self.tournament_end_date > timezone.now(): # the tournament has started but not ended yet + elif self.tournament_end_date > timezone.now(): # the tournament has started, matches are being played return "tournament in progress" - else: # the tournament has ended + else: # all matches have finished. The tournament has ended (any matches that have not finished are forfeited) return "tournament ended" def clean(self): # restriction @@ -290,11 +294,7 @@ def clean(self): # restriction raise ValidationError("The number of winners should be greater than 0.") # the number of players should be less than or equal to the maximum number of players - if self.pk is not None: # the tournament is being updated - if self.players.count() > self.max_players: - raise ValidationError( - "The number of players should be less than or equal to the maximum number of players." - ) + # Note: this is checked when the tournament is created and updated # the winners should also be players if self.pk is not None: # the tournament is being updated @@ -328,15 +328,15 @@ def clean(self): # restriction raise ValidationError("The tournament end date should be in the future.") # the registration start date should be earlier than the registration end date - if self.registration_start_date > self.registration_end_date: + if self.registration_start_date >= self.registration_end_date: raise ValidationError("The registration start date should be earlier than the registration end date.") # the tournament start date should be earlier than the tournament end date - if self.tournament_start_date > self.tournament_end_date: + if self.tournament_start_date >= self.tournament_end_date: raise ValidationError("The tournament start date should be earlier than the tournament end date.") # the registration end date should be earlier than the tournament start date - if self.registration_end_date > self.tournament_start_date: + if self.registration_end_date >= self.tournament_start_date: raise ValidationError("The registration end date should be earlier than the tournament start date.") # Section: archived @@ -368,6 +368,24 @@ def set_archive(self, archive): self.archived = archive self.save() + def check_and_end_tournament(self): + """ + Checks if the tournament end date has reached and ends the tournament if it has. + Any matches that have not finished are forfeited. + """ + if self.status == "tournament ended": + brackets = self.matches.all() + for bracket in brackets: + assert isinstance(bracket, Match) + bracket_users = bracket.players.all() + bracket_players = [Player.objects.get(user=user, match=bracket) for user in bracket_users] + bracket_with_outcome = any(player.outcome is not None for player in bracket_players) + if not bracket_with_outcome: # the match has not finished + for player in bracket_players: + player.outcome = Player.WITHDRAWAL # forfeit + player.save() + self.end_tournament() + def __str__(self): # may be changed later return ( "Tournament " @@ -427,13 +445,15 @@ def next_round_tournaments_brackets(self) -> list[Match]: # get the winners of the previous round for bracket in brackets: - bracket_players = bracket.players.all() + assert isinstance(bracket, Match) + bracket_users = bracket.players.all() + bracket_players = [Player.objects.get(user=user, match=bracket) for user in bracket_users] bracket_winners = [ player for player in bracket_players if player.outcome == Player.WIN ] # allow multiple winners # currently only players who win instead of draw can advance to the next round for winner in bracket_winners: - players.append(winner) + players.append(winner.user) # check if the number of players is small enough to end the tournament if len(players) <= self.num_winner: @@ -482,15 +502,18 @@ def end_tournament(self) -> None: brackets = self.matches.all() for bracket in brackets: # the matches of the previous round # get the winners of the previous round - bracket_players = bracket.players.all() + assert isinstance(bracket, Match) + bracket_users = bracket.players.all() + bracket_players = [Player.objects.get(user=user, match=bracket) for user in bracket_users] bracket_winners = [ - player for player in bracket_players if player.outcome == Player.WIN + player.user for player in bracket_players if player.outcome == Player.WIN ] # allow multiple winners # currently only players who win instead of draw can advance to the next round for winner in bracket_winners: winners.append(winner) self.winners.set(winners) + self.matches.clear() self.save() # Note: we don't delete the tournament because we want to keep it in the database @@ -506,9 +529,13 @@ def tournament_sign_up(self, user: User) -> int: Returns: int: 0 if the user has successfully signed up for the tournament, 1 if the user has already joined the tournament, - 2 if the tournament is full, 3 if the tournament has already started, - 4 if the tournament has already ended + 2 if the tournament is full, + 3 if the registration period of tournament has already ended """ + if self.status != "registration open": + # The registration period has ended (the join and withdraw buttons only appear + # during the registration period) + return 3 if user in self.players.all(): # The user has already joined the tournament return 1 @@ -533,7 +560,12 @@ def tournament_withdraw( Returns: int: 0 if the user has successfully withdrawn from the tournament, 1 if the user has not joined the tournament + 3 if the registration period of tournament has already ended """ + if self.status != "registration open": + # The registration period has ended (the join and withdraw buttons only appear + # during the registration period) + return 3 if user not in self.players.all(): # The user has not joined the tournament return 1 diff --git a/src/chigame/games/views.py b/src/chigame/games/views.py index 4f7bdcb32..ef3844e4f 100644 --- a/src/chigame/games/views.py +++ b/src/chigame/games/views.py @@ -381,6 +381,12 @@ def get_context_data(self, **kwargs): # Additional context can be added if needed return context + def get(self, request, *args, **kwargs): + super().get(request, *args, **kwargs) + for tournament in self.object_list: + tournament.check_and_end_tournament() # check if the tournament has ended + return self.render_to_response(self.get_context_data()) + def post(self, request, *args, **kwargs): # This method is called when the user clicks the "Join Tournament" or # "Withdraw" button @@ -396,6 +402,9 @@ def post(self, request, *args, **kwargs): elif success == 2: messages.error(request, "This tournament is full") return redirect(reverse_lazy("tournament-list")) + elif success == 3: + messages.error(request, "The registration period for this tournament has ended") + return redirect(reverse_lazy("tournament-list")) else: raise Exception("Invalid return value") @@ -407,6 +416,9 @@ def post(self, request, *args, **kwargs): elif success == 1: messages.error(request, "You have not joined this tournament") return redirect(reverse_lazy("tournament-list")) + elif success == 3: + messages.error(request, "The registration period for this tournament has ended") + return redirect(reverse_lazy("tournament-list")) else: raise Exception("Invalid return value") else: @@ -422,6 +434,17 @@ class TournamentDetailView(DetailView): template_name = "tournaments/tournament_detail.html" context_object_name = "tournament" + def get(self, request, *args, **kwargs): + super().get(request, *args, **kwargs) + tournament = Tournament.objects.get(id=self.kwargs["pk"]) + if tournament.matches.count() == 0 and ( + tournament.status == "registration closed" or tournament.status == "tournament in progress" + ): + # if the tournament matches have not been created + tournament.create_tournaments_brackets() + tournament.check_and_end_tournament() # check if the tournament has ended + return self.render_to_response(self.get_context_data()) + def post(self, request, *args, **kwargs): # This method is called when the user clicks the "Join Tournament" or # "Withdraw" button @@ -437,6 +460,9 @@ def post(self, request, *args, **kwargs): elif success == 2: messages.error(request, "This tournament is full") return redirect(reverse_lazy("tournament-detail", kwargs={"pk": tournament.pk})) + elif success == 3: + messages.error(request, "The registration period for this tournament has ended") + return redirect(reverse_lazy("tournament-detail", kwargs={"pk": tournament.pk})) else: raise Exception("Invalid return value") @@ -448,8 +474,20 @@ def post(self, request, *args, **kwargs): elif success == 1: messages.error(request, "You have not joined this tournament") return redirect(reverse_lazy("tournament-detail", kwargs={"pk": tournament.pk})) + elif success == 3: + messages.error(request, "The registration period for this tournament has ended") + return redirect(reverse_lazy("tournament-detail", kwargs={"pk": tournament.pk})) else: raise Exception("Invalid return value") + + elif request.POST.get("action") == "join_match": + pass # allow players to join their own matches + return redirect(reverse_lazy("tournament-detail", kwargs={"pk": tournament.pk})) + + elif request.POST.get("action") == "spectate": + pass # allow anyone to spectate + return redirect(reverse_lazy("tournament-detail", kwargs={"pk": tournament.pk})) + else: raise ValueError("Invalid action") @@ -483,10 +521,9 @@ class TournamentCreateView(CreateView): # overrides the default behavior of the CreateView class. def form_valid(self, form): response = super().form_valid(form) - self.object.create_tournaments_brackets() # This should be changed later - # because the brackets should not be created right after the tournament - # is created. Instead, the brackets should be created when the registration - # deadline is reached. But for now, we keep it this way for testing. + if form.cleaned_data["players"].count() > form.cleaned_data["max_players"]: + messages.error(self.request, "The number of players cannot exceed the maximum number of players") + return redirect(reverse_lazy("tournament-create")) # Do something with brackets if needed return response @@ -532,17 +569,27 @@ def form_valid(self, form): # Check if the 'players' field has been modified form_players = set(form.cleaned_data["players"]) current_players = set(current_tournament.players.all()) + + if form.cleaned_data["players"].count() > form.cleaned_data["max_players"]: + messages.error(self.request, "The number of players cannot exceed the maximum number of players") + return redirect(reverse_lazy("tournament-update", kwargs={"pk": self.kwargs["pk"]})) + if len(form_players - current_players) > 0: # if the players have been added - raise PermissionDenied("You cannot add new players to the tournament after it has started.") - elif len(current_players - form_players) > 0: # if the players have been removed + if current_tournament.status != "registration open": + raise PermissionDenied( + "You cannot add new players to the tournament when it is not in the registration period." + ) + elif ( + len(current_players - form_players) > 0 and current_tournament.status == "tournament in progress" + ): # if the players have been removed removed_players = current_players - form_players # get the players that have been removed for player in removed_players: related_match = current_tournament.matches.get( players__in=[player] ) # get the match that the player is in + assert isinstance(related_match, Match) related_match.players.remove(player) - if related_match.players.count() == 0: # if the match is empty, delete it - related_match.delete() + # if the match is empty, the match will be displayed as forfeited # The super class's form_valid method will save the form data to the database return super().form_valid(form) diff --git a/src/chigame/users/models.py b/src/chigame/users/models.py index d29e8b7b1..9eebc08ee 100644 --- a/src/chigame/users/models.py +++ b/src/chigame/users/models.py @@ -138,10 +138,10 @@ def get_by_actor(self, actor, include_deleted=False, **kwargs): try: actor_content_type = ContentType.objects.get(model=actor._meta.model_name) actor_object_id = actor.pk - queryset = self.get(actor_content_type=actor_content_type, actor_object_id=actor_object_id, **kwargs) - if not include_deleted: - queryset = queryset.is_not_deleted() - return queryset + notification = self.get(actor_content_type=actor_content_type, actor_object_id=actor_object_id, **kwargs) + if not include_deleted and not notification.visible: + raise Notification.DoesNotExist + return notification except ContentType.DoesNotExist: raise ValueError(f"The model {actor.label} is not registered in content type") diff --git a/src/chigame/users/tables.py b/src/chigame/users/tables.py index 4ec356f6c..96edbc1b1 100644 --- a/src/chigame/users/tables.py +++ b/src/chigame/users/tables.py @@ -1,15 +1,19 @@ import django_tables2 as tables -from .models import User, UserProfile +from .models import User class FriendsTable(tables.Table): - name = tables.Column(verbose_name="Friend's Name") + email = tables.Column( + verbose_name="Email", + accessor="email", # Access display_name through the User relationship + linkify=("users:user-profile", {"pk": tables.A("pk")}), + ) class Meta: - model = UserProfile # Referencing the UserProfile model + model = User # Referencing the UserProfile model template_name = "django_tables2/bootstrap.html" - fields = [""] # Adjust fields to show relevant information from the UserProfile model + fields = ["email"] # Adjust fields to show relevant information from the UserProfile model class UserTable(tables.Table): @@ -18,6 +22,6 @@ class UserTable(tables.Table): class Meta: model = User template_name = "django_tables2/bootstrap.html" - fields = ["name", "email"] + fields = ["name", "first_name", "last_name", "email"] # Add information about top ranking users, total points collected, etc. diff --git a/src/chigame/users/tests/test_models.py b/src/chigame/users/tests/test_models.py index 6091865a1..ef4be6d71 100644 --- a/src/chigame/users/tests/test_models.py +++ b/src/chigame/users/tests/test_models.py @@ -75,7 +75,12 @@ def test_notificationqueryset_get_by_actor(): Notification.objects.get_by_actor(friendinvitation1) Notification.objects.get_by_actor(friendinvitation3) - assert len(Notification.objects.filter_by_actor(friendinvitation2)) == 1 + notification = Notification.objects.get_by_actor(friendinvitation2) + assert notification.actor == friendinvitation2 + + notification.delete() + with pytest.raises(Notification.DoesNotExist): + Notification.objects.get_by_actor(friendinvitation2) @pytest.mark.django_db diff --git a/src/chigame/users/urls.py b/src/chigame/users/urls.py index 4f0da37ce..3545cc73b 100644 --- a/src/chigame/users/urls.py +++ b/src/chigame/users/urls.py @@ -5,6 +5,7 @@ act_on_inbox_notification, cancel_friend_invitation, decline_friend_invitation, + friend_list_view, notification_detail, send_friend_invitation, user_detail_view, @@ -31,6 +32,7 @@ path("user_history/", views.user_history, name="user-history"), path("search-results", view=user_search_results, name="user-search-results"), path("inbox/", view=user_inbox_view, name="user-inbox"), + path("profile//friends", view=friend_list_view, name="friend-list"), path("inbox//deleted_notifications", views.deleted_notifications_view, name="deleted-notifications"), path("notification_detail/", view=notification_detail, name="notification-detail"), path( diff --git a/src/chigame/users/views.py b/src/chigame/users/views.py index 05220af15..d5e9b48c2 100644 --- a/src/chigame/users/views.py +++ b/src/chigame/users/views.py @@ -12,7 +12,7 @@ from rest_framework.response import Response from .models import FriendInvitation, Notification, UserProfile -from .tables import UserTable +from .tables import FriendsTable, UserTable User = get_user_model() @@ -211,6 +211,19 @@ def user_inbox_view(request, pk): @login_required +def friend_list_view(request, pk): + user = request.user + profile = get_object_or_404(UserProfile, user__pk=pk) + friends = profile.friends.all() + table = FriendsTable(friends) + context = {"table": table} + if pk == user.id: + return render(request, "users/user_friend_list.html", context) + else: + messages.error(request, "Not your friend list!") + return redirect(reverse("users:user-profile", kwargs={"pk": request.user.pk})) + + def deleted_notifications_view(request, pk): user = request.user notifications = Notification.objects.filter_by_receiver(user, deleted=True) diff --git a/src/templates/tournaments/tournament_detail.html b/src/templates/tournaments/tournament_detail.html index b26daedc1..992a01d06 100644 --- a/src/templates/tournaments/tournament_detail.html +++ b/src/templates/tournaments/tournament_detail.html @@ -82,6 +82,8 @@

{{ player.email }} {% endif %} {% if not forloop.last %}vs.{% endif %} + {% empty %} + Forfeited {% endfor %} )

@@ -92,7 +94,7 @@

{% else %}

No matches yet.

{% endif %} - {% if user.is_authenticated %} + {% if user.is_authenticated and tournament.status == "registration open" %}
{% csrf_token %} @@ -106,6 +108,22 @@

{% endif %} + {% if tournament.status == "tournament in progress" %} + {% if user.is_authenticated and user in tournament.get_all_players %} +
+ {% csrf_token %} + + + +
+ {% endif %} +
+ {% csrf_token %} + + + +
+ {% endif %} {% if user.is_staff %}

diff --git a/src/templates/tournaments/tournament_list.html b/src/templates/tournaments/tournament_list.html index bad10e776..487902682 100644 --- a/src/templates/tournaments/tournament_list.html +++ b/src/templates/tournaments/tournament_list.html @@ -71,7 +71,7 @@

{% else %}

No player yet.

{% endif %} - {% if user.is_authenticated %} + {% if user.is_authenticated and tournament.status == "registration open" %}
{% csrf_token %} diff --git a/src/templates/users/user_friend_list.html b/src/templates/users/user_friend_list.html new file mode 100644 index 000000000..9f186ae9a --- /dev/null +++ b/src/templates/users/user_friend_list.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} + +{% load render_table from django_tables2 %} + +{% block content %} + {% render_table table %} +{% endblock content %} diff --git a/src/templates/users/userprofile_detail.html b/src/templates/users/userprofile_detail.html index 71ef5be7b..63342e12f 100644 --- a/src/templates/users/userprofile_detail.html +++ b/src/templates/users/userprofile_detail.html @@ -18,6 +18,8 @@

Bio: {{ object.bio }}

{% if object.user == request.user %} Edit Profile + Friend List {% elif is_friend %} Remove Friend {% elif friendship_request %}