diff --git a/api/permissions.py b/api/permissions.py index f371910b..38377c98 100644 --- a/api/permissions.py +++ b/api/permissions.py @@ -46,7 +46,7 @@ from django.http import HttpRequest from ninja_extra import ControllerBase from ninja_extra.permissions import BasePermission -from counter.models import Counter +from counter.utils import is_logged_in_counter class IsInGroup(BasePermission): @@ -186,12 +186,7 @@ class IsLoggedInCounter(BasePermission): """Check that a user is logged in a counter.""" def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool: - if "/counter/" not in request.META.get("HTTP_REFERER", ""): - return False - token = request.session.get("counter_token") - if not token: - return False - return Counter.objects.filter(token=token).exists() + return is_logged_in_counter(request) CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup") diff --git a/club/forms.py b/club/forms.py index 7c524f56..2f9db670 100644 --- a/club/forms.py +++ b/club/forms.py @@ -21,10 +21,13 @@ # Place - Suite 330, Boston, MA 02111-1307, USA. # # +import itertools +from operator import attrgetter from django import forms from django.db.models import Exists, OuterRef, Q, QuerySet from django.db.models.functions import Lower +from django.forms.models import ModelChoiceField, ModelChoiceIterator from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ @@ -46,6 +49,37 @@ from counter.models import Counter, Selling from counter.schemas import SaleFilterSchema +class ClubRoleChoiceIterator(ModelChoiceIterator): + """Custom `ModelChoiceIterator` for `ClubRoleChoiceField`""" + + def __iter__(self): + if self.field.empty_label is not None: + yield "", self.field.empty_label + queryset = self.queryset.select_related("club").order_by("club", "order") + groups = [ + (club, [self.choice(role) for role in roles]) + for club, roles in itertools.groupby(queryset, key=attrgetter("club")) + ] + if len(groups) == 1: + # there is only one club involved, no need to have optgroups + yield from groups[0][1] + else: + # there are multiple clubs, optgroups are necessary to differentiate + # roles having the same name + yield from groups + + +class ClubRoleChoiceField(ModelChoiceField): + """Custom `ModelChoiceField` for `[ClubRole][club.models.ClubRole]`. + + If only one club is involved, behave like the base `ModelChoiceField`. + If dealing with the roles of multiple clubs, group the roles + into a different `optgroup` for each club. + """ + + iterator = ClubRoleChoiceIterator + + class ClubLinkForm(forms.ModelForm): error_css_class = "error" required_css_class = "required" @@ -392,6 +426,30 @@ class ClubRoleForm(forms.ModelForm): self.instance.order = cleaned_data["ORDER"] - 1 return cleaned_data + def save(self, commit=True): # noqa: FBT002 + instance: ClubRole = super().save(commit=commit) + if commit and "is_board" in self.changed_data: + # if the role was moved from board to simple member, + # remove all users with that role from the club board group. + # If the role became a board role, add users with + # that role to the club board group. + group_id = instance.club.board_group_id + if self.cleaned_data["is_board"]: + User.groups.through.objects.bulk_create( + [ + User.groups.through(user_id=u, group_id=group_id) + for u in Membership.objects.ongoing() + .filter(role=instance) + .values_list("user_id", flat=True) + ], + ignore_conflicts=True, + ) + else: + User.groups.through.objects.filter( + user__memberships__role=instance, group_id=group_id + ).delete() + return instance + class ClubRoleCreateForm(forms.ModelForm): """Form to create a club role. diff --git a/club/migrations/0017_linktype_clublink.py b/club/migrations/0017_linktype_clublink.py index 097e77f3..3e6d53ca 100644 --- a/club/migrations/0017_linktype_clublink.py +++ b/club/migrations/0017_linktype_clublink.py @@ -25,8 +25,7 @@ class Migration(migrations.Migration): "url_base", models.URLField( help_text=( - "The base url that links with this type " - "must respect (e.g. `https://www.instagram.com`)" + "The base url that links with this type must respect" ), unique=True, verbose_name="url base", diff --git a/club/models.py b/club/models.py index 6e98848e..a226cbcd 100644 --- a/club/models.py +++ b/club/models.py @@ -793,10 +793,7 @@ class LinkType(models.Model): url_base = models.URLField( "url base", unique=True, - help_text=_( - "The base url that links with this type must respect (e.g. `%(url)s`)" - ) - % {"url": "https://www.instagram.com"}, + help_text=_("The base url that links with this type must respect"), ) icon = models.CharField( _("icon"), diff --git a/club/tests/test_clubrole.py b/club/tests/test_clubrole.py index 0173c4d6..f6d61e5b 100644 --- a/club/tests/test_clubrole.py +++ b/club/tests/test_clubrole.py @@ -4,6 +4,7 @@ import pytest from django.contrib.auth.models import Permission from django.test import Client, TestCase from django.urls import reverse +from django.utils.timezone import now from model_bakery import baker, seq from model_bakery.recipe import Recipe from pytest_django.asserts import assertRedirects @@ -239,7 +240,7 @@ class TestClubRoleUpdate(TestCase): def test_president_moves_itself_out_of_the_presidency(self): """Test that if the user moves its own role out of the presidency, - then it's redirected to another page and loses access to the update page.""" + then it loses access to the update page.""" self.payload["roles-0-is_presidency"] = False self.client.force_login(self.user) res = self.client.post(self.url, data=self.payload) @@ -251,3 +252,29 @@ class TestClubRoleUpdate(TestCase): res = self.client.get(self.url) assert res.status_code == 403 + + def test_role_stops_being_board(self): + """Test that if a role stops being a board role, + its users lose the club board group.""" + self.payload["roles-0-is_board"] = False + self.payload["roles-0-is_presidency"] = False + self.payload["roles-1-is_board"] = False + formset = ClubRoleFormSet(data=self.payload, instance=self.club) + assert formset.is_valid() + formset.save() + assert not self.user.groups.contains(self.club.board_group) + + def test_role_becomes_board(self): + """Test that if a role becomes a board role, + its active users get the club board group""" + members = [ + baker.make(Membership, club=self.club, role=self.roles[0], end_date=None), + baker.make(Membership, club=self.club, role=self.roles[0], end_date=now()), + ] + self.payload["roles-2-is_board"] = True + formset = ClubRoleFormSet(data=self.payload, instance=self.club) + assert formset.is_valid() + formset.save() + # the second membership is finished, so its user shouldn't get the role + assert members[0].user.groups.contains(self.club.board_group) + assert not members[1].user.groups.contains(self.club.board_group) diff --git a/com/views.py b/com/views.py index 1d29d6ce..56126dcf 100644 --- a/com/views.py +++ b/com/views.py @@ -170,7 +170,7 @@ class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView): form_class = NewsForm template_name = "com/news_edit.jinja" pk_url_kwarg = "news_id" - permission_required = "com.edit_news" + permission_required = "com.change_news" def form_valid(self, form): response = super().form_valid(form) # Does the saving part diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 38da5e95..05a650d0 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -20,7 +20,7 @@ # Place - Suite 330, Boston, MA 02111-1307, USA. # # -from datetime import date, timedelta +from datetime import date, datetime, timedelta from io import StringIO from pathlib import Path from typing import ClassVar, NamedTuple @@ -33,7 +33,8 @@ from django.core.management.base import BaseCommand from django.db import connection from django.db.models import Q from django.utils import timezone -from django.utils.timezone import localdate +from django.utils.lorem_ipsum import paragraphs +from django.utils.timezone import localdate, now from PIL import Image from club.models import Club, ClubLink, ClubRole, LinkType, Membership @@ -43,13 +44,14 @@ from core.models import BanGroup, Group, Page, PageRev, SithFile, User from core.utils import resize_image from counter.models import ( Counter, + CounterSellers, Price, Product, ProductType, ReturnableProduct, StudentCard, ) -from election.models import Candidature, Election, ElectionList, Role +from election.models import Candidature, Election, ElectionList, Role, Vote from forum.models import Forum from pedagogy.models import UE from sas.models import Album, PeoplePictureRelation, Picture @@ -364,62 +366,15 @@ class Command(BaseCommand): Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE") # Add barman to counter - Counter.sellers.through.objects.bulk_create( + CounterSellers.objects.bulk_create( [ - Counter.sellers.through(counter_id=1, user=skia), # MDE - Counter.sellers.through(counter_id=2, user=krophil), # Foyer + CounterSellers(counter_id=1, user=skia, is_regular=True), # MDE + CounterSellers(counter_id=2, user=krophil, is_regular=True), # Foyer ] ) # Create an election - el = Election.objects.create( - title="Élection 2017", - description="La roue tourne", - start_candidature="1942-06-12 10:28:45+01", - end_candidature="2042-06-12 10:28:45+01", - start_date="1942-06-12 10:28:45+01", - end_date="7942-06-12 10:28:45+01", - ) - el.view_groups.add(groups.public) - el.edit_groups.add(clubs.ae.board_group) - el.candidature_groups.add(groups.subscribers) - el.vote_groups.add(groups.subscribers) - liste = ElectionList.objects.create(title="Candidature Libre", election=el) - listeT = ElectionList.objects.create(title="Troll", election=el) - pres = Role.objects.create( - election=el, title="Président AE", description="Roi de l'AE" - ) - resp = Role.objects.create( - election=el, title="Co Respo Info", max_choice=2, description="Ghetto++" - ) - Candidature.objects.bulk_create( - [ - Candidature( - role=resp, - user=skia, - election_list=liste, - program="Refesons le site AE", - ), - Candidature( - role=resp, - user=sli, - election_list=liste, - program="Vasy je deviens mon propre adjoint", - ), - Candidature( - role=resp, - user=krophil, - election_list=listeT, - program="Le Pôle Troll !", - ), - Candidature( - role=pres, - user=sli, - election_list=listeT, - program="En fait j'aime pas l'info, je voulais faire GMC", - ), - ] - ) + self._create_elections(groups, clubs, skia, sli, krophil) # Forum room = Forum.objects.create( @@ -1010,3 +965,132 @@ class Command(BaseCommand): BanGroup.objects.create(name="Banned from buying alcohol", description="") BanGroup.objects.create(name="Banned from counters", description="") BanGroup.objects.create(name="Banned to subscribe", description="") + + def _create_elections( + self, + groups: PopulatedGroups, + clubs: PopulatedClubs, + skia: User, + sli: User, + krophil: User, + ): + """Populate elections. + + 4 elections are created : + + - one that has not started yet, + - one on the candidature period + - one on the vote period + - one that is finished + + All elections have two lists, are linked to the AE and Troll clubs, + and have one role for each board role of thos two clubs, plus + an additional role linked to no club roles. + + The ongoing vote and finished elections have candidates. + + The finished election has 10 voters. + """ + + def election_factory(title: str, start_candidature: datetime): + return Election( + title=title, + description="", + start_candidature=start_candidature, + end_candidature=start_candidature + timedelta(days=7), + start_date=start_candidature + timedelta(days=7), + end_date=start_candidature + timedelta(days=14), + ) + + # create the elections + elections = Election.objects.bulk_create( + [ + election_factory("Election terminée", now() - timedelta(days=14)), + election_factory("Votes en cours", now() - timedelta(days=7)), + election_factory("Candidatures en cours", now()), + election_factory("Election à venir", now() + timedelta(days=7)), + ] + ) + finished, ongoing_vote, _ongoing_candidature, _not_started = elections + + # set the groups (all elections have the same groups) + groups.public.viewable_elections.set(elections) + clubs.ae.board_group.editable_elections.set(elections) + groups.subscribers.candidate_elections.set(elections) + groups.subscribers.votable_elections.set(elections) + + # link elections to clubs (AE and Troll for all elections) + Election.clubs.through.objects.bulk_create( + [ + *[Election.clubs.through(club=clubs.ae, election=e) for e in elections], + *[ + Election.clubs.through(club=clubs.troll, election=e) + for e in elections + ], + ] + ) + + # Create lists (all elections have two lists) + ElectionList.objects.bulk_create( + [ + *[ElectionList(title="Candidat libre", election=e) for e in elections], + *[ElectionList(title="Troll", election=e) for e in elections], + ] + ) + + # Create roles. + # Elections have a role for each board club role of AE and Troll, + # +an additional role linked to no club role + club_roles = list( + ClubRole.objects.filter(club__in=[clubs.ae, clubs.troll], is_board=True) + .select_related("club") + .order_by("club_id", "order") + ) + Role.objects.bulk_create( + [ + *[ + Role(election=e, title=f"{r.name} {r.club.name}", club_role=r) + for r in club_roles + for e in elections + ], + *[Role(election=e, title="Rôle libre") for e in elections], + ] + ) + + # create candidatures for ongoing_vote and finished elections + candidatures = [] + lipsum = "\n\n".join(paragraphs(2)) + for election in ongoing_vote, finished: + lists = list(election.election_lists.order_by("id")) + roles = list(election.roles.order_by("order")[:3]) + candidatures.extend( + [ + Candidature( + role=roles[0], user=skia, election_list=lists[0], program=lipsum + ), + Candidature( + role=roles[1], user=sli, election_list=lists[0], program=lipsum + ), + Candidature( + role=roles[2], user=krophil, election_list=lists[1], program="" + ), + Candidature( + role=roles[2], user=sli, election_list=lists[0], program=lipsum + ), + ] + ) + candidatures = Candidature.objects.bulk_create(candidatures) + + skia, sli_vp, krophil, sli_treso = candidatures[4:] # candidates of finished + votes = Vote.objects.bulk_create( + [ + *[Vote(role=skia.role) for _ in range(6)], + *[Vote(role=sli_vp.role) for _ in range(8)], + *[Vote(role=krophil.role) for _ in range(9)], + ] + ) + skia.votes.set(votes[:6]) + sli_vp.votes.set(votes[6:14]) + krophil.votes.set(votes[14:20]) + sli_treso.votes.set(votes[20:23]) + finished.voters.set(list(User.objects.all()[:10])) diff --git a/core/static/core/accordion.scss b/core/static/core/accordion.scss index 28a7f75b..23bb2fa6 100644 --- a/core/static/core/accordion.scss +++ b/core/static/core/accordion.scss @@ -46,6 +46,10 @@ details.accordion>.accordion-content { border-bottom-right-radius: 3px; border-bottom-left-radius: 3px; overflow: hidden; + + @media screen and (max-width: 600px) { + padding: .75em 1.5em; + } } @mixin animation($selector) { diff --git a/core/static/core/components/card.scss b/core/static/core/components/card.scss index 941b32a5..2fe346a2 100644 --- a/core/static/core/components/card.scss +++ b/core/static/core/components/card.scss @@ -29,7 +29,12 @@ align-items: center; gap: 20px; - &.clickable:hover { + &:disabled { + background-color: darken($primary-neutral-light-color, 5%); + opacity: 65%; + } + + &.clickable:not(:disabled):hover { background-color: darken($primary-neutral-light-color, 5%); } diff --git a/core/static/core/forms.scss b/core/static/core/forms.scss index 2abffbba..511190b6 100644 --- a/core/static/core/forms.scss +++ b/core/static/core/forms.scss @@ -23,7 +23,7 @@ border-radius: 5px; color: black; - &:hover { + &:not(.link-like):not(:disabled):hover { background: hsl(0, 0%, 83%); } } @@ -141,7 +141,6 @@ form { display: block; margin: calc(var(--nf-input-size) * 1.5) auto 10px; line-height: 1; - white-space: nowrap; .fields-centered { padding: 10px 10px 0; diff --git a/core/static/core/header.scss b/core/static/core/header.scss index 01e70403..e935a27b 100644 --- a/core/static/core/header.scss +++ b/core/static/core/header.scss @@ -123,7 +123,7 @@ $background-color-hovered: #283747; justify-content: center; } - >.button { + a.button { box-sizing: border-box; height: 35px; background-color: transparent; @@ -139,7 +139,7 @@ $background-color-hovered: #283747; font-size: .9em; width: 120px; - &:hover { + &:not(.link-like):not(:disabled):hover { background-color: $background-color-hovered; } } diff --git a/core/templates/core/base/header.jinja b/core/templates/core/base/header.jinja index de0169b9..5735f099 100644 --- a/core/templates/core/base/header.jinja +++ b/core/templates/core/base/header.jinja @@ -22,14 +22,9 @@