mirror of
				https://gitlab.crans.org/bde/nk20
				synced 2025-10-26 13:33:19 +01:00 
			
		
		
		
	Compare commits
	
		
			36 Commits
		
	
	
		
			2a638e7b32
			...
			oauth2
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | d2cc1b902d | ||
|  | 4c40566513 | ||
|  | 7c45b59298 | ||
|  | 418268db27 | ||
|  | 73045586a3 | ||
|  | 22d668a75c | ||
|  | 5dfa12fad2 | ||
|  | 5af69f719d | ||
|  | 4f6b1d5b6c | ||
|  | d4cb464169 | ||
|  | 27a1f36183 | ||
|  | 83c8b9a3d0 | ||
|  | cb3b34f874 | ||
|  | 0962a3735e | ||
|  | 9907cfbd86 | ||
|  | ad90887691 | ||
|  | 47d2476b51 | ||
|  | 5d8720cf46 | ||
|  | 8700144dea | ||
|  | d17ab26f2f | ||
|  | 297f289d7e | ||
|  | 034ad9a4ce | ||
|  | 897d37f74d | ||
|  | 42fb0aa2d6 | ||
|  | 4bc43ec3cb | ||
|  | 00737da69f | ||
|  | 6eb192b823 | ||
|  | 0934b8fa34 | ||
|  | bcd6444ff2 | ||
|  | 7633c9ab4b | ||
|  | bb06206a9b | ||
|  | 55be3c9836 | ||
|  | 2ac19ab7be | ||
|  | 7d359dec13 | ||
|  | 1015a5dba1 | ||
|  | 8f9f650826 | 
| @@ -37,6 +37,11 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|     <div id="guests_table"> | ||||
|         {% render_table guests %} | ||||
|     </div> | ||||
|     <div class="card-footer text-center"> | ||||
|         <button class="btn btn-block btn-primary mb-3" onclick="window.location.href='?_export=1&table=guests'"> | ||||
|             {% trans "Export to CSV" %} | ||||
|         </button> | ||||
|     </div> | ||||
| </div> | ||||
| {% endif %} | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| {% extends "base.html" %} | ||||
| {% extends "base_search.html" %} | ||||
| {% comment %} | ||||
| SPDX-License-Identifier: GPL-3.0-or-later | ||||
| {% endcomment %} | ||||
| @@ -44,6 +44,8 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|     <h3 class="card-header text-center"> | ||||
|         {% trans "All activities" %} | ||||
|     </h3> | ||||
|     {% render_table table %} | ||||
|     {% render_table all %} | ||||
| </div> | ||||
|  | ||||
| {{ block.super }} | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| {% comment %} | ||||
| SPDX-License-Identifier: GPL-3.0-or-later | ||||
| {% endcomment %} | ||||
| {% load i18n perms pretty_money %} | ||||
| {% load i18n perms pretty_money dict_get %} | ||||
| {% url 'activity:activity_detail' activity.pk as activity_detail_url %} | ||||
|  | ||||
| <div id="activity_info" class="card bg-light shadow mb-3"> | ||||
| @@ -53,6 +53,12 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|             <dt class="col-xl-6">{% trans 'opened'|capfirst %}</dt> | ||||
|             <dd class="col-xl-6">{{ activity.open|yesno }}</dd> | ||||
|         </dl> | ||||
|         {% if show_entries|dict_get:activity %} | ||||
|             <h2 class="text-center"> | ||||
|                 {{ entries_count|dict_get:activity }} | ||||
|                 {% if entries_count|dict_get:activity >= 2 %}{% trans "entries" %}{% else %}{% trans "entry" %}{% endif %} | ||||
|             </h2> | ||||
|         {% endif %} | ||||
|     </div> | ||||
|  | ||||
|     <div class="card-footer text-center"> | ||||
|   | ||||
							
								
								
									
										0
									
								
								apps/activity/templatetags/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								apps/activity/templatetags/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										12
									
								
								apps/activity/templatetags/dict_get.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								apps/activity/templatetags/dict_get.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay | ||||
| # SPDX-License-Identifier: GPL-3.0-or-later | ||||
|  | ||||
| from django import template | ||||
|  | ||||
|  | ||||
| def dict_get(d, key): | ||||
|     return d.get(key) | ||||
|  | ||||
|  | ||||
| register = template.Library() | ||||
| register.filter('dict_get', dict_get) | ||||
| @@ -67,32 +67,65 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin | ||||
|     tables = [ | ||||
|         lambda data: ActivityTable(data, prefix="all-"), | ||||
|         lambda data: ActivityTable(data, prefix="upcoming-"), | ||||
|         lambda data: ActivityTable(data, prefix="search-"), | ||||
|     ] | ||||
|     extra_context = {"title": _("Activities")} | ||||
|  | ||||
|     def get_queryset(self, **kwargs): | ||||
|         return super().get_queryset(**kwargs).distinct() | ||||
|         """ | ||||
|         Filter the user list with the given pattern. | ||||
|         """ | ||||
|         return super().get_queryset().distinct() | ||||
|  | ||||
|     def get_tables_data(self): | ||||
|         # first table = all activities, second table = upcoming | ||||
|         # first table = all activities, second table = upcoming, third table = search | ||||
|  | ||||
|         # table search | ||||
|         qs = self.get_queryset().order_by('-date_start') | ||||
|         if "search" in self.request.GET and self.request.GET['search']: | ||||
|             pattern = self.request.GET['search'] | ||||
|  | ||||
|             # check regex | ||||
|             valid_regex = is_regex(pattern) | ||||
|             suffix = '__iregex' if valid_regex else '__istartswith' | ||||
|             prefix = '^' if valid_regex else '' | ||||
|             qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern}) | ||||
|                            | Q(**{f'organizer__name{suffix}': prefix + pattern}) | ||||
|                            | Q(**{f'organizer__note__alias__name{suffix}': prefix + pattern})) | ||||
|         else: | ||||
|             qs = qs.none() | ||||
|         search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Activity, 'view')) | ||||
|  | ||||
|         return [ | ||||
|             self.get_queryset().order_by("-date_start"), | ||||
|             Activity.objects.filter(date_end__gt=timezone.now()) | ||||
|                             .filter(PermissionBackend.filter_queryset(self.request, Activity, "view")) | ||||
|                             .distinct() | ||||
|                             .order_by("date_start") | ||||
|                             .order_by("date_start"), | ||||
|             search_table, | ||||
|         ] | ||||
|  | ||||
|     def get_context_data(self, **kwargs): | ||||
|         context = super().get_context_data(**kwargs) | ||||
|  | ||||
|         tables = context["tables"] | ||||
|         for name, table in zip(["table", "upcoming"], tables): | ||||
|         for name, table in zip(["all", "upcoming", "table"], tables): | ||||
|             context[name] = table | ||||
|  | ||||
|         started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all() | ||||
|         context["started_activities"] = started_activities | ||||
|  | ||||
|         entries_count = {} | ||||
|         show_entries = {} | ||||
|         for activity in started_activities: | ||||
|             if activity.activity_type.manage_entries: | ||||
|                 entries = Entry.objects.filter(activity=activity) | ||||
|                 entries_count[activity] = entries.count() | ||||
|  | ||||
|                 show_entries[activity] = True | ||||
|         context["entries_count"] = entries_count | ||||
|         context["show_entries"] = show_entries | ||||
|  | ||||
|         return context | ||||
|  | ||||
|  | ||||
| @@ -103,12 +136,19 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix | ||||
|     model = Activity | ||||
|     context_object_name = "activity" | ||||
|     extra_context = {"title": _("Activity detail")} | ||||
|     export_formats = ["csv"] | ||||
|  | ||||
|     tables = [ | ||||
|         lambda data: GuestTable(data, prefix="guests-"), | ||||
|         lambda data: OpenerTable(data, prefix="opener-"), | ||||
|         GuestTable, | ||||
|         OpenerTable, | ||||
|     ] | ||||
|  | ||||
|     def get_tables(self): | ||||
|         tables = super().get_tables() | ||||
|         tables[0].prefix = "guests" | ||||
|         tables[1].prefix = "opener" | ||||
|         return tables | ||||
|  | ||||
|     def get_tables_data(self): | ||||
|         return [ | ||||
|             Guest.objects.filter(activity=self.object) | ||||
| @@ -117,6 +157,51 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix | ||||
|                               .filter(PermissionBackend.filter_queryset(self.request, Opener, "view")), | ||||
|         ] | ||||
|  | ||||
|     def render_to_response(self, context, **response_kwargs): | ||||
|         """ | ||||
|         Gère l'export CSV manuel pour MultiTableMixin. | ||||
|         """ | ||||
|         if "_export" in self.request.GET: | ||||
|             import tablib | ||||
|             table_name = self.request.GET.get("table") | ||||
|             if table_name: | ||||
|                 tables = self.get_tables() | ||||
|                 data_list = self.get_tables_data() | ||||
|  | ||||
|                 for t, d in zip(tables, data_list): | ||||
|                     if t.prefix == table_name: | ||||
|                         # Préparer le CSV | ||||
|                         dataset = tablib.Dataset() | ||||
|                         columns = list(t.base_columns)  # noms des colonnes | ||||
|                         dataset.headers = columns | ||||
|  | ||||
|                         for row in d: | ||||
|                             values = [] | ||||
|                             for col in columns: | ||||
|                                 try: | ||||
|                                     val = getattr(row, col, "") | ||||
|                                     # Gestion spéciale pour la colonne 'entry' | ||||
|                                     if col == "entry": | ||||
|                                         if getattr(row, "has_entry", False): | ||||
|                                             val = timezone.localtime(row.entry.time).strftime("%Y-%m-%d %H:%M:%S") | ||||
|                                         else: | ||||
|                                             val = "" | ||||
|                                     values.append(str(val) if val is not None else "") | ||||
|                                 except Exception:  # RelatedObjectDoesNotExist ou autre | ||||
|                                     values.append("") | ||||
|                             dataset.append(values) | ||||
|  | ||||
|                         csv_bytes = dataset.export("csv") | ||||
|                         if isinstance(csv_bytes, str): | ||||
|                             csv_bytes = csv_bytes.encode("utf-8") | ||||
|  | ||||
|                         response = HttpResponse(csv_bytes, content_type="text/csv") | ||||
|                         response["Content-Disposition"] = f'attachment; filename="{table_name}.csv"' | ||||
|                         return response | ||||
|  | ||||
|         # Sinon rendu normal | ||||
|         return super().render_to_response(context, **response_kwargs) | ||||
|  | ||||
|     def get_context_data(self, **kwargs): | ||||
|         context = super().get_context_data() | ||||
|  | ||||
| @@ -137,6 +222,14 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix | ||||
|                 "placeholder": "" | ||||
|             } | ||||
|         } | ||||
|         if self.object.activity_type.manage_entries: | ||||
|             entries = Entry.objects.filter(activity=self.object) | ||||
|             context["entries_count"] = {self.object: entries.count()} | ||||
|  | ||||
|             context["show_entries"] = {self.object: timezone.now() > timezone.localtime(self.object.date_start)} | ||||
|         else: | ||||
|             context["entries_count"] = {self.object: 0} | ||||
|             context["show_entries"] = {self.object: False} | ||||
|  | ||||
|         return context | ||||
|  | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| # SPDX-License-Identifier: GPL-3.0-or-later | ||||
|  | ||||
| import django_tables2 as tables | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| from .models import Food | ||||
|  | ||||
| @@ -10,10 +11,25 @@ class FoodTable(tables.Table): | ||||
|     """ | ||||
|     List all foods. | ||||
|     """ | ||||
|     qr_code_numbers = tables.Column(empty_values=(), verbose_name=_("QR Codes"), orderable=False) | ||||
|  | ||||
|     date = tables.Column(empty_values=(), verbose_name=_("Arrival/creation date"), orderable=False) | ||||
|  | ||||
|     def render_date(self, record): | ||||
|         if record.__class__.__name__ == "BasicFood": | ||||
|             return record.arrival_date.strftime("%d/%m/%Y %H:%M") | ||||
|         elif record.__class__.__name__ == "TransformedFood": | ||||
|             return record.creation_date.strftime("%d/%m/%Y %H:%M") | ||||
|         else: | ||||
|             return "--" | ||||
|  | ||||
|     def render_qr_code_numbers(self, record): | ||||
|         return ", ".join(str(q.qr_code_number) for q in record.QR_code.all()) | ||||
|  | ||||
|     class Meta: | ||||
|         model = Food | ||||
|         template_name = 'django_tables2/bootstrap4.html' | ||||
|         fields = ('name', 'owner', 'allergens', 'expiry_date') | ||||
|         fields = ('name', 'owner', 'qr_code_numbers', 'allergens', 'date', 'expiry_date') | ||||
|         row_attrs = { | ||||
|             'class': 'table-row', | ||||
|             'data-href': lambda record: 'detail/' + str(record.pk), | ||||
|   | ||||
| @@ -34,6 +34,12 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="card-body"> | ||||
|     <div class="form-check"> | ||||
|       <label for="stock_only" class="form-check-label"> | ||||
|           <input id="stock_only" name="stock_only" type="checkbox" class="checkboxinput form-check-input" checked> | ||||
|           {% trans "Filter with only food in stock" %} | ||||
|       </label> | ||||
|     </div> | ||||
|       <input id="searchbar" type="text" class="form-control" | ||||
|           placeholder="{% trans "Search by attribute such as name..." %}"> | ||||
|   </div> | ||||
| @@ -114,7 +120,26 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|   {% endif %} | ||||
| </div> | ||||
|  | ||||
| <script type="text/javascript"> | ||||
|  | ||||
|     let old_pattern = null; | ||||
|     let searchbar_obj = $("#searchbar"); | ||||
|     let stock_only_obj = $("#stock_only"); | ||||
|  | ||||
|     function reloadTable() { | ||||
|         let pattern = searchbar_obj.val(); | ||||
|  | ||||
|         $("#dynamic-table").load(location.pathname + "?search=" + pattern.replace(" ", "%20") + ( | ||||
|             stock_only_obj.is(':checked') ? "" : "&stock=1") + " #dynamic-table"); | ||||
|     } | ||||
|  | ||||
|     searchbar_obj.keyup(reloadTable); | ||||
|     stock_only_obj.change(reloadTable); | ||||
|  | ||||
|     $(document).on("click", ".table-row", function () { | ||||
|         window.document.location = $(this).data("href"); | ||||
|     }); | ||||
| </script> | ||||
| <script> | ||||
|   document.addEventListener('DOMContentLoaded', function() { | ||||
|       document.getElementById('goButton').addEventListener('click', function(event) { | ||||
|   | ||||
| @@ -65,16 +65,24 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li | ||||
|             suffix = '__iregex' if valid_regex else '__istartswith' | ||||
|             prefix = '^' if valid_regex else '' | ||||
|             qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern}) | ||||
|                            | Q(**{f'owner__name{suffix}': prefix + pattern})) | ||||
|                            | Q(**{f'owner__name{suffix}': prefix + pattern}) | ||||
|                            | Q(**{f'owner__note__alias__name{suffix}': prefix + pattern})) | ||||
|         else: | ||||
|             qs = qs.none() | ||||
|         if "stock" not in self.request.GET or not self.request.GET["stock"] == '1': | ||||
|             qs = qs.filter(end_of_life='') | ||||
|  | ||||
|         search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Food, 'view')) | ||||
|         # table open | ||||
|         open_table = self.get_queryset().order_by('expiry_date').filter( | ||||
|         open_table = self.get_queryset().filter( | ||||
|             Q(polymorphic_ctype__model='transformedfood') | ||||
|             | Q(polymorphic_ctype__model='basicfood', basicfood__date_type='DLC')).filter( | ||||
|                 expiry_date__lt=timezone.now(), end_of_life='').filter( | ||||
|                     PermissionBackend.filter_queryset(self.request, Food, 'view')) | ||||
|         open_table = open_table.union(self.get_queryset().filter( | ||||
|             Q(end_of_life='', order__iexact='open') | ||||
|         ).filter( | ||||
|             PermissionBackend.filter_queryset(self.request, Food, 'view'))).order_by('expiry_date') | ||||
|         # table served | ||||
|         served_table = self.get_queryset().order_by('-pk').filter( | ||||
|             end_of_life='', is_ready=True).exclude( | ||||
| @@ -95,6 +103,7 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li | ||||
|                 owner=club, end_of_life='').filter( | ||||
|                     PermissionBackend.filter_queryset(self.request, Food, 'view') | ||||
|             )) | ||||
|  | ||||
|         return [search_table, open_table, served_table] + club_table | ||||
|  | ||||
|     def get_context_data(self, **kwargs): | ||||
| @@ -218,7 +227,7 @@ class BasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView): | ||||
|         copy = self.request.GET.get('copy', None) | ||||
|         if copy is not None: | ||||
|             food = BasicFood.objects.get(pk=copy) | ||||
|             print(context['form'].fields) | ||||
|  | ||||
|             for field in context['form'].fields: | ||||
|                 if field == 'allergens': | ||||
|                     context['form'].fields[field].initial = getattr(food, field).all() | ||||
|   | ||||
| @@ -10,6 +10,7 @@ from django.contrib.auth.forms import AuthenticationForm | ||||
| from django.contrib.auth.models import User | ||||
| from django.db import transaction | ||||
| from django.forms import CheckboxSelectMultiple | ||||
| from phonenumber_field.formfields import PhoneNumberField | ||||
| from django.utils import timezone | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from note.models import NoteSpecial, Alias | ||||
| @@ -45,6 +46,11 @@ class ProfileForm(forms.ModelForm): | ||||
|     A form for the extras field provided by the :model:`member.Profile` model. | ||||
|     """ | ||||
|     # Remove widget=forms.HiddenInput() if you want to use report frequency. | ||||
|     phone_number = PhoneNumberField( | ||||
|         widget=forms.TextInput(attrs={"type": "tel", "class": "form-control"}), | ||||
|         required=False | ||||
|     ) | ||||
|  | ||||
|     report_frequency = forms.IntegerField(required=False, initial=0, label=_("Report frequency")) | ||||
|  | ||||
|     last_report = forms.DateTimeField(required=False, disabled=True, label=_("Last report date")) | ||||
| @@ -72,7 +78,12 @@ class ProfileForm(forms.ModelForm): | ||||
|         if not self.instance.section or (("department" in self.changed_data | ||||
|                                          or "promotion" in self.changed_data) and "section" not in self.changed_data): | ||||
|             self.instance.section = self.instance.section_generated | ||||
|         return super().save(commit) | ||||
|         instance = super().save(commit=False) | ||||
|         if instance.phone_number: | ||||
|             instance.phone_number = instance.phone_number.as_e164 | ||||
|         if commit: | ||||
|             instance.save() | ||||
|         return instance | ||||
|  | ||||
|     class Meta: | ||||
|         model = Profile | ||||
|   | ||||
| @@ -417,7 +417,7 @@ class Membership(models.Model): | ||||
|         A membership is valid if today is between the start and the end date. | ||||
|         """ | ||||
|         if self.date_end is not None: | ||||
|             return self.date_start.toordinal() <= datetime.datetime.now().toordinal() < self.date_end.toordinal() | ||||
|             return self.date_start.toordinal() <= datetime.datetime.now().toordinal() <= self.date_end.toordinal() | ||||
|         else: | ||||
|             return self.date_start.toordinal() <= datetime.datetime.now().toordinal() | ||||
|  | ||||
|   | ||||
| @@ -92,6 +92,20 @@ class MembershipTable(tables.Table): | ||||
|         } | ||||
|     ) | ||||
|  | ||||
|     user_email = tables.Column( | ||||
|         verbose_name="Email", | ||||
|         accessor="user.email", | ||||
|         orderable=False, | ||||
|         visible=False, | ||||
|     ) | ||||
|  | ||||
|     user_full_name = tables.Column( | ||||
|         verbose_name=_("Full name"), | ||||
|         accessor="user.get_full_name", | ||||
|         orderable=False, | ||||
|         visible=False, | ||||
|     ) | ||||
|  | ||||
|     def render_user(self, value): | ||||
|         # If the user has the right, link the displayed user with the page of its detail. | ||||
|         s = value.username | ||||
| @@ -149,6 +163,16 @@ class MembershipTable(tables.Table): | ||||
|                             + "'>" + s + "</a>") | ||||
|         return s | ||||
|  | ||||
|     def value_user(self, record): | ||||
|         return record.user.username if record.user else "" | ||||
|  | ||||
|     def value_club(self, record): | ||||
|         return record.club.name if record.club else "" | ||||
|  | ||||
|     def value_roles(self, record): | ||||
|         roles = record.roles.all() | ||||
|         return ", ".join(str(role) for role in roles) | ||||
|  | ||||
|     class Meta: | ||||
|         attrs = { | ||||
|             'class': 'table table-condensed table-striped', | ||||
|   | ||||
| @@ -36,7 +36,13 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|             {% trans "There is no membership found with this pattern." %} | ||||
|         </div> | ||||
|         {% endif %} | ||||
|         <div class="card-footer text-center"> | ||||
|             <button class="btn btn-block btn-primary mb-3" onclick="window.location.href='?_export=csv'"> | ||||
|                 {% trans "Export to CSV" %} | ||||
|             </button> | ||||
|         </div> | ||||
|     </div> | ||||
|      | ||||
| </div> | ||||
| {% endblock %} | ||||
|  | ||||
|   | ||||
| @@ -11,9 +11,8 @@ | ||||
|     <dt class="col-xl-6">{% trans 'family'|capfirst %}</dt> | ||||
|     <dd class="col-xl-6"> | ||||
|         {% if families %} | ||||
|             test | ||||
|             {% for fam in families %} | ||||
|             <a href="{% url 'family:family_detail' fam.pk %}">asfafs{{ fam.name }}</a>{% if not forloop.last %}, {% endif %} | ||||
|             <a href="{% url 'family:family_detail' fam.pk %}">{{ fam.name }}</a>{% if not forloop.last %}, {% endif %} | ||||
|             {% endfor %} | ||||
|         {% else %} | ||||
|             <span class="text-muted">Aucune</span> | ||||
|   | ||||
| @@ -10,7 +10,7 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|         {{ title }} | ||||
|     </h3> | ||||
|     <div class="card-body"> | ||||
|         <form method="post"> | ||||
|         <form method="post" id="profile-form"> | ||||
|             {% csrf_token %} | ||||
|             {{ form | crispy }} | ||||
|             {{ profile_form | crispy }} | ||||
| @@ -20,4 +20,46 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|         </form> | ||||
|     </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block extrajavascript %} | ||||
| <!-- intl-tel-input CSS/JS --> | ||||
| <script> | ||||
| (() => { | ||||
|     const input = document.querySelector("input[name='phone_number']"); | ||||
|     const form = document.querySelector("#profile-form"); | ||||
|  | ||||
|     if (!input || !form || input.type === "hidden" || input.disabled || input.readOnly) { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     const iti = window.intlTelInput(input, { | ||||
|         initialCountry: "auto", | ||||
|         nationalMode: false, | ||||
|         autoPlaceholder: "off", | ||||
|         geoIpLookup: callback => { | ||||
|             fetch("https://ipapi.co/json") | ||||
|                 .then(res => res.json()) | ||||
|                 .then(data => callback(data.country_code)) | ||||
|                 .catch(() => callback("fr")); | ||||
|         }, | ||||
|         loadUtils: () => import("https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/js/utils.js"), | ||||
|     }); | ||||
|  | ||||
|     form.addEventListener("submit", function(e){ | ||||
|         if (!input.value.trim()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const number = iti.getNumber(intlTelInput.utils.numberFormat.E164); | ||||
|         if (number) { | ||||
|             input.value = number; | ||||
|             form.submit(); | ||||
|         } else { | ||||
|             e.preventDefault(); | ||||
|             input.focus(); | ||||
|         } | ||||
|     }); | ||||
| })(); | ||||
| </script> | ||||
| {% endblock %} | ||||
| @@ -17,6 +17,7 @@ from django.utils.translation import gettext_lazy as _ | ||||
| from django.views.generic import DetailView, UpdateView, TemplateView | ||||
| from django.views.generic.edit import FormMixin | ||||
| from django_tables2.views import MultiTableMixin, SingleTableMixin, SingleTableView | ||||
| from django_tables2.export.views import ExportMixin | ||||
| from rest_framework.authtoken.models import Token | ||||
| from api.viewsets import is_regex | ||||
| from note.models import Alias, NoteClub, NoteUser, Trust | ||||
| @@ -950,11 +951,12 @@ class ClubManageRolesView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): | ||||
|         return reverse_lazy('member:user_detail', kwargs={'pk': self.object.user.id}) | ||||
|  | ||||
|  | ||||
| class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): | ||||
| class ClubMembersListView(ExportMixin, ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): | ||||
|     model = Membership | ||||
|     table_class = MembershipTable | ||||
|     template_name = "member/club_members.html" | ||||
|     extra_context = {"title": _("Members of the club")} | ||||
|     export_formats = ["csv"] | ||||
|  | ||||
|     def get_queryset(self, **kwargs): | ||||
|         qs = super().get_queryset().filter(club_id=self.kwargs["pk"]) | ||||
| @@ -986,6 +988,14 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV | ||||
|  | ||||
|         return qs.distinct() | ||||
|  | ||||
|     def get_export_filename(self, export_format): | ||||
|         return "members.csv" | ||||
|  | ||||
|     def get_export_content_type(self, export_format): | ||||
|         if export_format == "csv": | ||||
|             return "text/csv" | ||||
|         return super().get_export_content_type(export_format) | ||||
|  | ||||
|     def get_context_data(self, **kwargs): | ||||
|         context = super().get_context_data(**kwargs) | ||||
|         club = Club.objects.filter( | ||||
|   | ||||
| @@ -228,7 +228,7 @@ function consume (source, source_alias, dest, quantity, amount, reason, type, ca | ||||
|           addMsg(interpolate(gettext('Warning, the transaction from the note %s succeed, ' + | ||||
|               'but the emitter note %s is negative.'), [source_alias, source_alias]), 'warning', 30000) | ||||
|         } | ||||
|         if (source.membership && source.membership.date_end < new Date().toISOString()) { | ||||
|         if (source.membership && source.membership.date_end <= new Date().toISOString()) { | ||||
|           addMsg(interpolate(gettext('Warning, the emitter note %s is no more a BDE member.'), [source_alias]), | ||||
|               'danger', 30000) | ||||
|         } | ||||
|   | ||||
| @@ -66,6 +66,8 @@ $(document).ready(function () { | ||||
|       arr.push(last) | ||||
|  | ||||
|       last.quantity = 1 | ||||
|        | ||||
|        | ||||
|  | ||||
|       if (last.note.club) { | ||||
|         $('#last_name').val(last.note.name) | ||||
| @@ -111,7 +113,8 @@ $(document).ready(function () { | ||||
|     dest.removeClass('d-none') | ||||
|     $('#dest_note_list').removeClass('d-none') | ||||
|     $('#debit_type').addClass('d-none') | ||||
|  | ||||
|     $('#reason').val('') | ||||
|    | ||||
|     $('#source_note_label').text(select_emitters_label) | ||||
|     $('#dest_note_label').text(select_receveirs_label) | ||||
|  | ||||
| @@ -134,6 +137,7 @@ $(document).ready(function () { | ||||
|     dest.val('') | ||||
|     dest.tooltip('hide') | ||||
|     $('#debit_type').addClass('d-none') | ||||
|     $('#reason').val('Rechargement note') | ||||
|  | ||||
|     $('#source_note_label').text(transfer_type_label) | ||||
|     $('#dest_note_label').text(select_receveir_label) | ||||
| @@ -162,6 +166,7 @@ $(document).ready(function () { | ||||
|     dest.addClass('d-none') | ||||
|     dest.tooltip('hide') | ||||
|     $('#debit_type').removeClass('d-none') | ||||
|     $('#reason').val('') | ||||
|  | ||||
|     $('#source_note_label').text(select_emitter_label) | ||||
|     $('#dest_note_label').text(transfer_type_label) | ||||
| @@ -305,10 +310,10 @@ $('#btn_transfer').click(function () { | ||||
|             destination: dest.note.id, | ||||
|             destination_alias: dest.name | ||||
|           }).done(function () { | ||||
|           if (source.note.membership && source.note.membership.date_end < new Date().toISOString()) { | ||||
|           if (source.note.membership && source.note.membership.date_end <= new Date().toISOString()) { | ||||
|             addMsg(interpolate(gettext('Warning, the emitter note %s is no more a BDE member.'), [source.name]), 'danger', 30000) | ||||
|           } | ||||
|           if (dest.note.membership && dest.note.membership.date_end < new Date().toISOString()) { | ||||
|           if (dest.note.membership && dest.note.membership.date_end <= new Date().toISOString()) { | ||||
|             addMsg(interpolate(gettext('Warning, the destination note %s is no more a BDE member.'), [dest.name]), 'danger', 30000) | ||||
|           } | ||||
|  | ||||
| @@ -409,7 +414,7 @@ $('#btn_transfer').click(function () { | ||||
|         bank: $('#bank').val() | ||||
|       }).done(function () { | ||||
|       addMsg(gettext('Credit/debit succeed!'), 'success', 10000) | ||||
|       if (user_note.membership && user_note.membership.date_end < new Date().toISOString()) { addMsg(gettext('Warning, the emitter note %s is no more a BDE member.'), 'danger', 10000) } | ||||
|       if (user_note.membership && user_note.membership.date_end <= new Date().toISOString()) { addMsg(gettext('Warning, the emitter note %s is no more a BDE member.'), 'danger', 10000) } | ||||
|       reset() | ||||
|     }).fail(function (err) { | ||||
|       const errObj = JSON.parse(err.responseText) | ||||
|   | ||||
| @@ -39,7 +39,15 @@ class PermissionBackend(ModelBackend): | ||||
|  | ||||
|             def permission_filter(membership_obj): | ||||
|                 query = Q(pk=-1) | ||||
|                 if 'mask' in request.GET: | ||||
|                     try: | ||||
|                         rank = int(request.GET['mask']) | ||||
|                     except: | ||||
|                         rank = 42 | ||||
|                     query &= Q(mask__rank__lte=rank) | ||||
|                 for scope in request.auth.scope.split(' '): | ||||
|                     if scope == "openid": | ||||
|                         continue | ||||
|                     permission_id, club_id = scope.split('_') | ||||
|                     if int(club_id) == membership_obj.club_id: | ||||
|                         query |= Q(pk=permission_id) | ||||
|   | ||||
| @@ -4430,6 +4430,22 @@ | ||||
|             "description": "Modifier le type de caution de mon inscription WEI tant qu'elle n'est pas validée" | ||||
|         } | ||||
|     }, | ||||
|     { | ||||
|         "model": "permission.permission", | ||||
|         "pk": 298, | ||||
|         "fields": { | ||||
|             "model": [ | ||||
|                 "wei", | ||||
|                 "bus" | ||||
|             ], | ||||
|             "query": "{\"pk\": [\"membership\", \"weimembership\", \"bus\", \"pk\"], \"wei__date_end__gte\": [\"today\"]}", | ||||
|             "type": "change", | ||||
|             "mask": 2, | ||||
|             "field": "information_json", | ||||
|             "permanent": false, | ||||
|             "description": "Modifier les informations du bus" | ||||
|         } | ||||
|     }, | ||||
|     { | ||||
|         "model": "permission.permission", | ||||
|         "pk": 311, | ||||
| @@ -4686,6 +4702,22 @@ | ||||
|             "description": "Supprimer un succès" | ||||
|         } | ||||
|     }, | ||||
|     { | ||||
|         "model": "permission.permission", | ||||
|         "pk": 330, | ||||
|         "fields": { | ||||
|             "model": [ | ||||
|                 "auth", | ||||
|                 "user" | ||||
|             ], | ||||
|             "query": "{\"memberships__club\": [\"club\"]}", | ||||
|             "type": "view", | ||||
|             "mask": 2, | ||||
|             "field": "email", | ||||
|             "permanent": false, | ||||
|             "description": "Voir l'adresse mail des membres de son club" | ||||
|         } | ||||
|     }, | ||||
|     { | ||||
|         "model": "permission.role", | ||||
|         "pk": 1, | ||||
| @@ -4833,7 +4865,11 @@ | ||||
|                 221, | ||||
|                 247, | ||||
|                 258, | ||||
|                 259 | ||||
|                 259, | ||||
|                 260, | ||||
|                 263, | ||||
|                 265, | ||||
|                 330 | ||||
|             ] | ||||
|         } | ||||
|     }, | ||||
| @@ -4845,7 +4881,6 @@ | ||||
|             "name": "Pr\u00e9sident\u22c5e de club", | ||||
|             "permissions": [ | ||||
|                 62, | ||||
|                 135, | ||||
|                 142 | ||||
|             ] | ||||
|         } | ||||
| @@ -5122,7 +5157,8 @@ | ||||
|                 289, | ||||
|                 290, | ||||
|                 291, | ||||
|                 293 | ||||
|                 293, | ||||
|                 298 | ||||
|             ] | ||||
|         } | ||||
|     }, | ||||
| @@ -5182,6 +5218,7 @@ | ||||
|             "permissions": [ | ||||
|                 37, | ||||
|                 41, | ||||
|                 42, | ||||
|                 53, | ||||
|                 54, | ||||
|                 55, | ||||
| @@ -5233,7 +5270,9 @@ | ||||
|                 168, | ||||
|                 176, | ||||
|                 177, | ||||
|                 197 | ||||
|                 197, | ||||
|                 311, | ||||
|                 319 | ||||
|             ] | ||||
|         } | ||||
|     }, | ||||
| @@ -5313,7 +5352,8 @@ | ||||
|                 289, | ||||
|                 290, | ||||
|                 291, | ||||
|                 293 | ||||
|                 293, | ||||
|                 298 | ||||
|             ] | ||||
|         } | ||||
|     }, | ||||
|   | ||||
| @@ -10,6 +10,7 @@ from note_kfet.middlewares import get_current_request | ||||
| from .backends import PermissionBackend | ||||
| from .models import Permission | ||||
|  | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| class PermissionScopes(BaseScopes): | ||||
|     """ | ||||
| @@ -32,7 +33,7 @@ class PermissionScopes(BaseScopes): | ||||
|  | ||||
|         scopes = {f"{p.id}_{club.id}": f"{p.description} (club {club.name})" | ||||
|                   for p in Permission.objects.all() for club in Club.objects.all()} | ||||
|         scopes['openid'] = "OpenID Connect" | ||||
|         scopes['openid'] = _("OpenID Connect (username and email)") | ||||
|         return scopes | ||||
|  | ||||
|     def get_available_scopes(self, application=None, request=None, *args, **kwargs): | ||||
|   | ||||
							
								
								
									
										18
									
								
								apps/treasury/migrations/0011_sogecredit_valid.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								apps/treasury/migrations/0011_sogecredit_valid.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| # Generated by Django 5.2.6 on 2025-09-28 20:12 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('treasury', '0010_alter_invoice_bde'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='sogecredit', | ||||
|             name='valid', | ||||
|             field=models.BooleanField(blank=True, default=False, verbose_name='Valid'), | ||||
|         ), | ||||
|     ] | ||||
| @@ -308,6 +308,12 @@ class SogeCredit(models.Model): | ||||
|         null=True, | ||||
|     ) | ||||
|  | ||||
|     valid = models.BooleanField( | ||||
|         default=False, | ||||
|         verbose_name=_("Valid"), | ||||
|         blank=True, | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("Credit from the Société générale") | ||||
|         verbose_name_plural = _("Credits from the Société générale") | ||||
| @@ -338,7 +344,7 @@ class SogeCredit(models.Model): | ||||
|             credit_transaction.save() | ||||
|             credit_transaction.refresh_from_db() | ||||
|             self.credit_transaction = credit_transaction | ||||
|         elif not self.valid: | ||||
|         elif not self.valid_legacy: | ||||
|             self.credit_transaction.amount = self.amount | ||||
|             self.credit_transaction._force_save = True | ||||
|             self.credit_transaction.save() | ||||
| @@ -346,12 +352,12 @@ class SogeCredit(models.Model): | ||||
|         return super().save(*args, **kwargs) | ||||
|  | ||||
|     @property | ||||
|     def valid(self): | ||||
|     def valid_legacy(self): | ||||
|         return self.credit_transaction and self.credit_transaction.valid | ||||
|  | ||||
|     @property | ||||
|     def amount(self): | ||||
|         if self.valid: | ||||
|         if self.valid_legacy: | ||||
|             return self.credit_transaction.total | ||||
|         amount = 0 | ||||
|         transactions_wei = self.transactions.filter(membership__club__weiclub__isnull=False) | ||||
| @@ -365,7 +371,7 @@ class SogeCredit(models.Model): | ||||
|         The Sogé credit may be created after the user already paid its memberships. | ||||
|         We query transactions and update the credit, if it is unvalid. | ||||
|         """ | ||||
|         if self.valid or not self.pk: | ||||
|         if self.valid_legacy or not self.pk: | ||||
|             return | ||||
|  | ||||
| # Soge do not pay BDE and kfet memberships since 2022 | ||||
| @@ -405,7 +411,7 @@ class SogeCredit(models.Model): | ||||
|         Invalidating a Société générale delete the transaction of the bank if it was already created. | ||||
|         Treasurers must know what they do, With Great Power Comes Great Responsibility... | ||||
|         """ | ||||
|         if self.valid: | ||||
|         if self.valid_legacy: | ||||
|             self.credit_transaction.valid = False | ||||
|             self.credit_transaction.save() | ||||
|         for tr in self.transactions.all(): | ||||
| @@ -414,7 +420,7 @@ class SogeCredit(models.Model): | ||||
|             tr.save() | ||||
|  | ||||
|     def validate(self, force=False): | ||||
|         if self.valid and not force: | ||||
|         if self.valid_legacy and not force: | ||||
|             # The credit is already done | ||||
|             return | ||||
|  | ||||
|   | ||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 104 KiB | 
| @@ -359,7 +359,7 @@ class TestSogeCredits(TestCase): | ||||
|         )) | ||||
|         self.assertRedirects(response, reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)), 302, 200) | ||||
|         soge_credit.refresh_from_db() | ||||
|         self.assertTrue(soge_credit.valid) | ||||
|         self.assertTrue(soge_credit.valid_legacy) | ||||
|         self.user.note.refresh_from_db() | ||||
|         self.assertEqual( | ||||
|             Transaction.objects.filter(Q(source=self.user.note) | Q(destination=self.user.note)).count(), 3) | ||||
|   | ||||
| @@ -17,7 +17,7 @@ from ...models import WEIMembership, Bus | ||||
|  | ||||
| WORDS = { | ||||
|     'list': [ | ||||
|         'Fiesta', 'Graillance', 'Move it move it', 'Calme', 'Nert et geek', 'Jeux de rôles et danse rock', | ||||
|         'Fiesta', 'Graillance', 'Move it move it', 'Calme', 'Nerd et geek', 'Jeux de rôles et danse rock', | ||||
|         'Strass et paillettes', 'Spectaculaire', 'Splendide', 'Flow inégalable', 'Rap', 'Battles légendaires', | ||||
|         'Techno', 'Alcool', 'Kiffeur·euse', 'Rugby', 'Médiéval', 'Festif', | ||||
|         'Stylé', 'Chipie', 'Rétro', 'Vache', 'Farfadet', 'Fanfare', | ||||
| @@ -27,41 +27,41 @@ WORDS = { | ||||
|             """Sur une échelle allant de 0 (= 0 alcool ou très peu) à 5 (= la fontaine de jouvence alcoolique), | ||||
|             quel niveau de consommation d’alcool souhaiterais-tu ?""", | ||||
|             { | ||||
|                 42: "", | ||||
|                 47: "", | ||||
|                 48: "", | ||||
|                 45: "", | ||||
|                 44: "", | ||||
|                 46: "", | ||||
|                 43: "", | ||||
|                 49: "" | ||||
|                 42: 4, | ||||
|                 47: 1, | ||||
|                 48: 3, | ||||
|                 45: 3.5, | ||||
|                 44: 4, | ||||
|                 46: 5, | ||||
|                 43: 3, | ||||
|                 49: 3 | ||||
|             } | ||||
|         ], | ||||
|         "voie_post_bac": [ | ||||
|             """Si la DA du bus de ton choix correspondait à une voie post-bac, laquelle serait-elle ?""", | ||||
|             { | ||||
|                 42: "", | ||||
|                 47: "", | ||||
|                 48: "", | ||||
|                 45: "", | ||||
|                 44: "", | ||||
|                 46: "", | ||||
|                 43: "", | ||||
|                 49: "" | ||||
|                 42: "Double licence cuisine/arts du cirque option burger", | ||||
|                 47: "BTS Exploration de donjon", | ||||
|                 48: "Ecole des stars en herbe", | ||||
|                 45: "Déscolarisation précoce", | ||||
|                 44: "Rattrapage pour excès de kiff", | ||||
|                 46: "Double cursus STAPS / Licence d’histoire", | ||||
|                 43: "Recherche active d’un sugar daddy/d’un sugar mommy", | ||||
|                 49: "Licence de musicologie" | ||||
|             } | ||||
|         ], | ||||
|         "boite": [ | ||||
|             """Tu es seul·e sur une île déserte et devant toi il y a une sombre boîte de taille raisonnable. | ||||
|             Qu’y a-t-il à l’intérieur ?""", | ||||
|             { | ||||
|                 42: "", | ||||
|                 47: "", | ||||
|                 48: "", | ||||
|                 45: "", | ||||
|                 44: "", | ||||
|                 46: "", | ||||
|                 43: "", | ||||
|                 49: "" | ||||
|                 42: "Un burgouzz de valouzz", | ||||
|                 47: "Un ocarina (pour me téléporter hors de ce bourbier)", | ||||
|                 48: "Des paillettes, un micro de karaoké et une enceinte bluetooth", | ||||
|                 45: "Un kebab", | ||||
|                 44: "Une 86 et un caisson pour taper du pied", | ||||
|                 46: "Une épée, un ballon et une tireuse", | ||||
|                 43: "Des lunettes de soleil", | ||||
|                 49: "Mon instrument de musique" | ||||
|             } | ||||
|         ], | ||||
|         "tardif": [ | ||||
| @@ -69,42 +69,42 @@ WORDS = { | ||||
|             qu’après tout, il n’y a plus personne sur la plage à cette heure-ci. Tu n’habites pas loin mais t’enchaînes | ||||
|             demain avec une journée similaire avec un autre groupe d’amis parce que t’es trop #busy. Que fais-tu ?""", | ||||
|             { | ||||
|                 42: "", | ||||
|                 47: "", | ||||
|                 48: "", | ||||
|                 45: "", | ||||
|                 44: "", | ||||
|                 46: "", | ||||
|                 43: "", | ||||
|                 49: "" | ||||
|                 42: "On veut se déchaîner toute la nuit !!", | ||||
|                 47: "Je prends une glace et chill un moment avant d’aller dormir", | ||||
|                 48: "J’enfile mes boogie shoes pour enflammer le dancefloor avec elleux et lancer un concours de slay, le perdant finit la bouteille de rhum", | ||||
|                 45: "La fête continuuuuue", | ||||
|                 44: "Soirée sangria plage → boîte → lever de soleil sur la plage", | ||||
|                 46: "Minuit ? C’est l’heure du genepi. On commence les alcools forts !!", | ||||
|                 43: "T’enchaînes direct (faut pas les priver de ta présence)", | ||||
|                 49: "On continue en mode chill (soirée potins)" | ||||
|             } | ||||
|         ], | ||||
|         "cohesion": [ | ||||
|             """C’est la rentrée de Seconde et tu découvres ta classe, tes camarades et ta prof principale!!! | ||||
|             qui vous propose une activité de cohésion. Laquelle est-elle ?""", | ||||
|             { | ||||
|                 42: "", | ||||
|                 47: "", | ||||
|                 48: "", | ||||
|                 45: "", | ||||
|                 44: "", | ||||
|                 46: "", | ||||
|                 43: "", | ||||
|                 49: "" | ||||
|                 42: "Un relais cubi en ventriglisse", | ||||
|                 47: "Un jeu de rôle", | ||||
|                 48: "Organiser la soirée de l’année dans le lycée. Le thème : SLAY (Spotlight, Love, Amaze/All-night, Yeah), paillettes, disco", | ||||
|                 45: "La prof de français propose un slam parce qu'elle pense que c'est du rap littéraire qui fera plaisir aux élèves", | ||||
|                 44: "P’tit escape game + apéro", | ||||
|                 46: "Joute avec des boucliers Gilbert", | ||||
|                 43: "Tournage d’un clip de confessions nocturnes de Diam’s", | ||||
|                 49: "Je sais pas j’ai raté mon BAFA" | ||||
|             } | ||||
|         ], | ||||
|         "artiste": [ | ||||
|             """C’est l’été et la saison des festivals a commencé. Tu regardes la programmation du festival | ||||
|             pas loin de chez toi et tu découvres avec joie la présence d’un·e artiste. De qui s’agit-il ?""", | ||||
|             { | ||||
|                 42: "", | ||||
|                 47: "", | ||||
|                 48: "", | ||||
|                 45: "", | ||||
|                 44: "", | ||||
|                 46: "", | ||||
|                 43: "", | ||||
|                 49: "" | ||||
|                 42: "Moto-Moto (il chantera son fameux tube “je les aime grosses, je les aime bombées”)", | ||||
|                 47: "Hatsune Miku", | ||||
|                 48: "Rihanna", | ||||
|                 45: "Vald", | ||||
|                 44: "Qui connaît vraiment les noms des artistes de tech ?", | ||||
|                 46: "Perceval", | ||||
|                 43: "Fatal bazooka", | ||||
|                 49: "Måneskin" | ||||
|             } | ||||
|         ], | ||||
|         "annonce_noel": [ | ||||
| @@ -112,61 +112,103 @@ WORDS = { | ||||
|             D’un coup, tu te lèves, tapotes de manière pompeuse sur ton verre avec un de tes couverts. | ||||
|             Qu’annonces-tu ?""", | ||||
|             { | ||||
|                 42: "", | ||||
|                 47: "", | ||||
|                 48: "", | ||||
|                 45: "", | ||||
|                 44: "", | ||||
|                 46: "", | ||||
|                 43: "", | ||||
|                 49: "" | ||||
|                 42: """« Chère famille. Je sais bien que nous avions dit : pas de politique à table. | ||||
|                     Je ne peux toutefois me retenir de vous annoncer une grande nouvelle… | ||||
|                     j’ai décidé de quitter la ville pour consacrer ma vie au culte du Roi Julian. | ||||
|                     A moi la jungle luxuriante, là où le soleil chaud caresse les palmiers, | ||||
|                     où les lémuriens dansent avec frénésie et où chaque repas est une ode au burger sauvage. | ||||
|                     Longue vie à Sa Majesté le Roi Julian ! »""", | ||||
|                 47: "« J’ai perdu »", | ||||
|                 48: "« Mes chers parents je pars, j’arrête l’ENS pour devenir DJ slay à Ibiza »", | ||||
|                 45: "J’interromps le repas pour rapper les 6min de bande organisée", | ||||
|                 44: "« Digestif ? Pétanque ? Les deux ? »", | ||||
|                 46: "« Montjoie St Denis à bas la Macronie »", | ||||
|                 43: "« Je suis enceinte » (c’est faux j’ai juste besoin d’attention)", | ||||
|                 49: """Discours de remerciement : | ||||
|                     je lance un powerpoint de 65 slides et sors une feuille A4 blanche (je fais semblant de lire mon discours dessus)""" | ||||
|             } | ||||
|         ], | ||||
|         "vacances": [ | ||||
|             """Les vacances sont là et t’aimerais bien partir quelque part, mais où ?""", | ||||
|             { | ||||
|                 42: "", | ||||
|                 47: "", | ||||
|                 48: "", | ||||
|                 45: "", | ||||
|                 44: "", | ||||
|                 46: "", | ||||
|                 43: "", | ||||
|                 49: "" | ||||
|                 42: "A Madagascar, à bord d’un bus conduit par des pingouins", | ||||
|                 47: "Dans ma chambre", | ||||
|                 48: "Rio de Janeiro", | ||||
|                 45: "N'importe où tant qu'on peut sortir tous les soirs", | ||||
|                 44: "Tu suis les plans du club ski ou de piratens", | ||||
|                 46: "Carcassonne", | ||||
|                 43: "Coachella", | ||||
|                 49: "Dans les montagnes de la république populaire d’Auvergne-Rhônes-Alpes pour profiter de la fraîcheur, de la nature et de mes ami·e·s" | ||||
|             } | ||||
|         ], | ||||
|         "loisir": [ | ||||
|             """T’as fini ta journée de cours et tu t’apprêtes à profiter d’une activité/hobby/loisir de ton choix. | ||||
|             Laquelle est-ce ?""", | ||||
|             { | ||||
|                 42: "", | ||||
|                 47: "", | ||||
|                 48: "", | ||||
|                 45: "", | ||||
|                 44: "", | ||||
|                 46: "", | ||||
|                 43: "", | ||||
|                 49: "" | ||||
|                 42: "Cueillir des noix de coco", | ||||
|                 47: "Essayer de travailler puis chill avec des potes autour d’un jeu en buvant du thé", | ||||
|                 48: "Repet du nouveau spectacle de mon club, before (potins) puis sortie avec les potes jusqu’au bout de la night", | ||||
|                 45: "Zoner avec les copaings jusqu’à pas d’heure", | ||||
|                 44: "Go Kfet pour se faire traquenard jusqu’à 3h du mat", | ||||
|                 46: "Déterminer ce qui est le plus solide entre mon crâne et une ecocup", | ||||
|                 43: "Revoir pour la 6e fois gossip girl au fond de ton lit", | ||||
|                 49: "Jouer de mon instrument préféré avec les copains/copines pour préparer le prochain concert #solidays" | ||||
|             } | ||||
|         ], | ||||
|         "plan": [ | ||||
|             """Tu reçois un message sur la conversation de groupe que tu partages avec tes potes : | ||||
|             vous êtes chaud·e·s pour vous retrouver. Quel plan t’attire le plus ?""", | ||||
|             { | ||||
|                 42: "", | ||||
|                 47: "", | ||||
|                 48: "", | ||||
|                 45: "", | ||||
|                 44: "", | ||||
|                 46: "", | ||||
|                 43: "", | ||||
|                 49: "" | ||||
|                 42: """Après-midi piscine, puis before arrosé de mojito, | ||||
|                     avant d’aller s’éclater en pot avec toute la savane et de finir sur un after spécial pina colada""", | ||||
|                 47: """(matin) : Ptit jeu de rôle | ||||
|                     (repas) : le traditionnel poké-tacos | ||||
|                     (juste après le repas) : combat avec des épées en mousse avec les copains! | ||||
|                     (16h00) : pause thé | ||||
|                     (fin d’après midi) : initiation à la danse rock | ||||
|                     (soirée) : découverte d’un jeu de société avec des règles obscures | ||||
|                     """, | ||||
|                 48: "Soirée champagne and chic : spectacle et dîner au moulin rouge puis soirée sur les champs", | ||||
|                 45: "Se regrouper pour une soirée, même si il n’est encore que 10h", | ||||
|                 44: "P’tit poké qui termine en koin koin avec after poker", | ||||
|                 46: "Une dégustation de bière, un rugby et toute autre activité joviale", | ||||
|                 43: "Un brunch de pour papoter puis friperies", | ||||
|                 49: "Soirée raclette !" | ||||
|             } | ||||
|         ] | ||||
|     } | ||||
|     }, | ||||
|     'stats': [ | ||||
|         { | ||||
|             "question": """Le WEI est structuré par bus, et au sein de chaque bus, par équipes. | ||||
|                          Pour toi, être dans une équipe où tout le monde reste sobre (primo-entrants comme encadrants) c'est :""", | ||||
|             "answers": [ | ||||
|                 (1, "Inenvisageable"), | ||||
|                 (2, "À contre cœur"), | ||||
|                 (3, "Pourquoi pas"), | ||||
|                 (4, "Souhaitable"), | ||||
|                 (5, "Nécessaire"), | ||||
|             ], | ||||
|             "help_text": "(De toute façon aucun alcool n'est consommé pendant les trajets du bus, ni aller, ni retour.)", | ||||
|         }, | ||||
|         { | ||||
|             "question": "Faire partie d'un bus qui n'apporte pas de boisson alcoolisée pour ses membres, pour toi c'est :", | ||||
|             "answers": [ | ||||
|                 (1, "Inenvisageable"), | ||||
|                 (2, "À contre cœur"), | ||||
|                 (3, "Pourquoi pas"), | ||||
|                 (4, "Souhaitable"), | ||||
|                 (5, "Nécessaire"), | ||||
|             ], | ||||
|             "help_text": """(Tout les bus apportent de l'alcool cette année, cette question sert à l'organisation pour l'année prochaine. | ||||
|                          De plus il y aura de toute façon de l'alcool commun au WEI et aucun alcool n'est consommé pendant les trajets en bus.)""", | ||||
|         }, | ||||
|     ] | ||||
| } | ||||
|  | ||||
| IMAGES = { | ||||
|     "vacances": { | ||||
|         49: "/static/wei/img/logo_auvergne_rhone_alpes.jpg", | ||||
|     } | ||||
| } | ||||
|  | ||||
| NB_WORDS = 5 | ||||
| @@ -219,7 +261,7 @@ class WEISurveyForm2025(forms.Form): | ||||
|             all_preferred_words = WORDS['list'] | ||||
|             rng.shuffle(all_preferred_words) | ||||
|             self.fields["words"].choices = [(w, w) for w in all_preferred_words] | ||||
|         else: | ||||
|         elif information.step <= len(WORDS['questions']): | ||||
|             questions = list(WORDS['questions'].items()) | ||||
|             idx = information.step - 1 | ||||
|             if idx < len(questions): | ||||
| @@ -235,6 +277,15 @@ class WEISurveyForm2025(forms.Form): | ||||
|                     widget=OptionalImageRadioSelect(images=IMAGES.get(q, {})), | ||||
|                     required=True, | ||||
|                 ) | ||||
|         elif information.step == len(WORDS['questions']) + 1: | ||||
|             for i, v in enumerate(WORDS['stats']): | ||||
|                 self.fields[f'stat_{i}'] = forms.ChoiceField( | ||||
|                     label=v['question'], | ||||
|                     choices=v['answers'], | ||||
|                     widget=forms.RadioSelect(), | ||||
|                     required=False, | ||||
|                     help_text=_(v.get('help_text', '')) | ||||
|                 ) | ||||
|  | ||||
|     def clean_words(self): | ||||
|         data = self.cleaned_data['words'] | ||||
| @@ -361,7 +412,7 @@ class WEISurvey2025(WEISurvey): | ||||
|                 setattr(self.information, "word" + str(i), word) | ||||
|             self.information.step += 1 | ||||
|             self.save() | ||||
|         else: | ||||
|         elif 1 <= self.information.step <= len(WORDS['questions']): | ||||
|             questions = list(WORDS['questions'].keys()) | ||||
|             idx = self.information.step - 1 | ||||
|             if idx < len(questions): | ||||
| @@ -369,6 +420,13 @@ class WEISurvey2025(WEISurvey): | ||||
|                 setattr(self.information, q, form.cleaned_data[q]) | ||||
|                 self.information.step += 1 | ||||
|                 self.save() | ||||
|         else: | ||||
|             for i, __ in enumerate(WORDS['stats']): | ||||
|                 ans = form.cleaned_data.get(f'stat_{i}') | ||||
|                 if ans is not None: | ||||
|                     setattr(self.information, f'stat_{i}', ans) | ||||
|             self.information.step += 1 | ||||
|             self.save() | ||||
|  | ||||
|     @classmethod | ||||
|     def get_algorithm_class(cls): | ||||
| @@ -378,7 +436,7 @@ class WEISurvey2025(WEISurvey): | ||||
|         """ | ||||
|         The survey is complete once the bus is chosen. | ||||
|         """ | ||||
|         return self.information.step > len(WORDS['questions']) | ||||
|         return self.information.step > len(WORDS['questions']) + 1 | ||||
|  | ||||
|     @classmethod | ||||
|     @lru_cache() | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								apps/wei/static/wei/img/logo_auvergne_rhone_alpes.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								apps/wei/static/wei/img/logo_auvergne_rhone_alpes.jpg
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 34 KiB | 
| @@ -37,11 +37,13 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|         </a> | ||||
|         {% endif %} | ||||
|         {% else %} | ||||
|         {% if registration.validated %} | ||||
|         <a class="btn btn-warning" href="{% url "wei:wei_update_registration" pk=my_registration.pk %}" | ||||
|             data-turbolinks="false"> | ||||
|             {% trans "Update my registration" %} | ||||
|         </a> | ||||
|         {% if not not_first_year %} | ||||
|         {% endif %} | ||||
|         {% if my_registration.first_year %} | ||||
|         {% if not survey_complete %} | ||||
|         <a class="btn btn-warning" href="{% url "wei:wei_survey" pk=my_registration.pk %}" data-turbolinks="false"> | ||||
|             {% trans "Continue survey" %} | ||||
|   | ||||
| @@ -11,7 +11,7 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|         {{ title }} | ||||
|     </h3> | ||||
|     <div class="card-body"> | ||||
|         <form method="post"> | ||||
|         <form id="registration-form" method="post"> | ||||
|             {% csrf_token %} | ||||
|             {{ form|crispy }} | ||||
|             {{ membership_form|crispy }} | ||||
| @@ -22,6 +22,46 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
| {% endblock %} | ||||
|  | ||||
| {% block extrajavascript %} | ||||
| <!-- intl-tel-input CSS/JS --> | ||||
| <script> | ||||
| (() => { | ||||
|     const input = document.querySelector("input[name='emergency_contact_phone']"); | ||||
|     const form = document.querySelector("#registration-form"); | ||||
|  | ||||
|     if (!input || !form || input.type === "hidden" || input.disabled || input.readOnly) { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     const iti = window.intlTelInput(input, { | ||||
|         initialCountry: "auto", | ||||
|         nationalMode: false, | ||||
|         autoPlaceholder: "off", | ||||
|         geoIpLookup: callback => { | ||||
|             fetch("https://ipapi.co/json") | ||||
|                 .then(res => res.json()) | ||||
|                 .then(data => callback(data.country_code)) | ||||
|                 .catch(() => callback("fr")); | ||||
|         }, | ||||
|         loadUtils: () => import("https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/js/utils.js"), | ||||
|     }); | ||||
|  | ||||
|     form.addEventListener("submit", function(e){ | ||||
|         if (!input.value.trim()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const number = iti.getNumber(intlTelInput.utils.numberFormat.E164); | ||||
|         if (number) { | ||||
|             input.value = number; | ||||
|             form.submit(); | ||||
|         } else { | ||||
|             e.preventDefault(); | ||||
|             input.focus(); | ||||
|         } | ||||
|     }); | ||||
| })(); | ||||
| </script> | ||||
|  | ||||
| {% if not object.membership %} | ||||
| <script> | ||||
|     $(document).ready(function () { | ||||
|   | ||||
| @@ -53,9 +53,11 @@ class TestWEIAlgorithm(TestCase): | ||||
|                 birth_date='2000-01-01', | ||||
|             ) | ||||
|             information = WEISurveyInformation2025(registration) | ||||
|             for j in range(1, 21): | ||||
|             for j in range(1, 1 + NB_WORDS): | ||||
|                 setattr(information, f'word{j}', random.choice(WORDS['list'])) | ||||
|             information.step = 20 | ||||
|             for q in WORDS['questions']: | ||||
|                 setattr(information, q, random.choice(list(WORDS['questions'][q][1].keys()))) | ||||
|             information.step = len(WORDS['questions']) + 2 | ||||
|             information.save(registration) | ||||
|             registration.save() | ||||
|  | ||||
| @@ -87,7 +89,7 @@ class TestWEIAlgorithm(TestCase): | ||||
|                 setattr(information, f'word{j}', random.choice(WORDS['list'])) | ||||
|             for q in WORDS['questions']: | ||||
|                 setattr(information, q, random.choice(list(WORDS['questions'][q][1].keys()))) | ||||
|             information.step = len(WORDS['questions']) + 1 | ||||
|             information.step = len(WORDS['questions']) + 2 | ||||
|             information.save(registration) | ||||
|             registration.save() | ||||
|             survey = WEISurvey2025(registration) | ||||
|   | ||||
| @@ -214,6 +214,8 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, D | ||||
|  | ||||
|         context["not_first_year"] = WEIMembership.objects.filter(user=self.request.user).exists() | ||||
|  | ||||
|         context["registration_validated"] = WEIMembership.objects.filter(registration=my_registration).exists() if my_registration else False | ||||
|  | ||||
|         qs = WEIMembership.objects.filter(club=club, registration__first_year=True, bus__isnull=True) | ||||
|         context["can_validate_1a"] = PermissionBackend.check_perm( | ||||
|             self.request, "wei.change_weimembership_bus", qs.first()) if qs.exists() else False | ||||
|   | ||||
| @@ -770,7 +770,7 @@ msgstr "Créer une famille ou un défi" | ||||
|  | ||||
| #: apps/family/templates/family/manage.html:96 | ||||
| msgid "Add a family" | ||||
| msgstr "Ajouter une famille" | ||||
| msgstr "Fonder une famille" | ||||
|  | ||||
| #: apps/family/templates/family/manage.html:101 | ||||
| msgid "Add a challenge" | ||||
| @@ -3368,12 +3368,12 @@ msgstr "Choisissez un mot :" | ||||
| #, python-brace-format | ||||
| msgid "" | ||||
| "Select {NB_WORDS} words that describe the WEI experience you want to have." | ||||
| msgstr "" | ||||
| msgstr "Sélectionne {NB_WORDS} mots qui décrivent l’expérience WEI que tu souhaites vivre." | ||||
|  | ||||
| #: apps/wei/forms/surveys/wei2025.py:242 | ||||
| #, python-brace-format | ||||
| msgid "Please choose exactly {NB_WORDS} words" | ||||
| msgstr "" | ||||
| msgstr "Choisis exactement {NB_WORDS} mots" | ||||
|  | ||||
| #: apps/wei/forms/surveys/wei2025.py:288 | ||||
| msgid "Rate between 0 and 5." | ||||
|   | ||||
| @@ -306,8 +306,8 @@ PIC_WIDTH = 200 | ||||
| PIC_RATIO = 1 | ||||
|  | ||||
| # Custom phone number format | ||||
| PHONENUMBER_DB_FORMAT = 'NATIONAL' | ||||
| PHONENUMBER_DEFAULT_REGION = 'FR' | ||||
| PHONENUMBER_DB_FORMAT = 'E164' | ||||
| PHONENUMBER_DEFAULT_REGION = None | ||||
|  | ||||
| # We add custom information to CAS, in order to give a normalized name to other services | ||||
| CAS_AUTH_CLASS = 'member.auth.CustomAuthUser' | ||||
|   | ||||
| @@ -29,6 +29,8 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|     <link rel="stylesheet" href="{% static "bootstrap4/css/bootstrap.min.css" %}"> | ||||
|     <link rel="stylesheet" href="{% static "font-awesome/css/font-awesome.min.css" %}"> | ||||
|     <link rel="stylesheet" href="{% static "css/custom.css" %}"> | ||||
|      | ||||
|     <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/css/intlTelInput.css"> | ||||
|  | ||||
|     {# JQuery, Bootstrap and Turbolinks JavaScript #} | ||||
|     <script src="{% static "jquery/jquery.min.js" %}"></script> | ||||
| @@ -41,6 +43,8 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|     {# Translation in javascript files #} | ||||
|     <script src="{% static "js/jsi18n/"|add:LANGUAGE_CODE|add:".js" %}"></script> | ||||
|  | ||||
|     <script src="https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/js/intlTelInput.min.js"></script> | ||||
|      | ||||
|     {# If extra ressources are needed for a form, load here #} | ||||
|     {% if form.media %} | ||||
|         {{ form.media }} | ||||
|   | ||||
| @@ -19,7 +19,7 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|             {% endblocktrans %} | ||||
|         </div> | ||||
|  | ||||
|         <form method="post"> | ||||
|         <form method="post" id="profile_form"> | ||||
|             {% csrf_token %} | ||||
|             {{ form|crispy }} | ||||
|             {{ profile_form|crispy }} | ||||
| @@ -31,3 +31,45 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|     </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block extrajavascript %} | ||||
| <!-- intl-tel-input CSS/JS --> | ||||
| <script> | ||||
| (() => { | ||||
|     const input = document.querySelector("input[name='phone_number']"); | ||||
|     const form = document.querySelector("#profile_form"); | ||||
|  | ||||
|     if (!input || !form || input.type === "hidden" || input.disabled || input.readOnly) { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     const iti = window.intlTelInput(input, { | ||||
|         initialCountry: "auto", | ||||
|         nationalMode: false, | ||||
|         autoPlaceholder: "off", | ||||
|         geoIpLookup: callback => { | ||||
|             fetch("https://ipapi.co/json") | ||||
|                 .then(res => res.json()) | ||||
|                 .then(data => callback(data.country_code)) | ||||
|                 .catch(() => callback("fr")); | ||||
|         }, | ||||
|         loadUtils: () => import("https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/js/utils.js"), | ||||
|     }); | ||||
|  | ||||
|     form.addEventListener("submit", function(e){ | ||||
|         if (!input.value.trim()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const number = iti.getNumber(intlTelInput.utils.numberFormat.E164); | ||||
|         if (number) { | ||||
|             input.value = number; | ||||
|             form.submit(); | ||||
|         } else { | ||||
|             e.preventDefault(); | ||||
|             input.focus(); | ||||
|         } | ||||
|     }); | ||||
| })(); | ||||
| </script> | ||||
| {% endblock %} | ||||
| @@ -12,10 +12,11 @@ django-filter~=25.1 | ||||
| django-mailer~=2.3.2 | ||||
| django-oauth-toolkit~=3.0.1 | ||||
| django-phonenumber-field~=8.1.0 | ||||
| django-polymorphic~=3.1.0 | ||||
| django-polymorphic~=4.1.0 | ||||
| djangorestframework~=3.16.0 | ||||
| django-rest-polymorphic~=0.1.10 | ||||
| django-tables2~=2.7.5 | ||||
| python-memcached~=1.62 | ||||
| phonenumbers~=9.0.8 | ||||
| tablib~=3.8.0 | ||||
| Pillow>=11.3.0 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user