diff --git a/apps/family/forms.py b/apps/family/forms.py index 8a36d289..dbc26ad3 100644 --- a/apps/family/forms.py +++ b/apps/family/forms.py @@ -8,7 +8,7 @@ from note_kfet.inputs import Autocomplete from .models import Challenge, FamilyMembership, User, Family -class ChallengeUpdateForm(forms.ModelForm): +class ChallengeForm(forms.ModelForm): """ To update a challenge """ @@ -20,6 +20,12 @@ class ChallengeUpdateForm(forms.ModelForm): } +class FamilyForm(forms.ModelForm): + class Meta: + model = Family + fields = ('name', 'description', ) + + class FamilyMembershipForm(forms.ModelForm): class Meta: model = FamilyMembership @@ -36,9 +42,3 @@ class FamilyMembershipForm(forms.ModelForm): }, ) } - - -class FamilyUpdateForm(forms.ModelForm): - class Meta: - model = Family - fields = ('description', ) \ No newline at end of file diff --git a/apps/family/static/family/js/achievements.js b/apps/family/static/family/js/achievements.js new file mode 100644 index 00000000..5794bfe5 --- /dev/null +++ b/apps/family/static/family/js/achievements.js @@ -0,0 +1,263 @@ +// Copyright (C) 2018-2025 by BDE ENS Paris-Saclay +// SPDX-License-Identifier: GPL-3.0-or-later + +// When a transaction is performed, lock the interface to prevent spam clicks. +var LOCK = false + +/** + * Refresh the history table on the consumptions page. + */ +function refreshHistory () { + $('#history').load('/note/consos/ #history') + $('#most_used').load('/note/consos/ #most_used') +} + +$(document).ready(function () { + // If hash of a category in the URL, then select this category + // else select the first one + if (location.hash) { + $("a[href='" + location.hash + "']").tab('show') + } else { + $("a[data-toggle='tab']").first().tab('show') + } + + // When selecting a category, change URL + $(document.body).on('click', "a[data-toggle='tab']", function () { + location.hash = this.getAttribute('href') + }) + + + // Ensure we begin in single consumption. Fix issue with TurboLinks and BootstrapJS + + + document.getElementById("consume_all").addEventListener('click', consumeAll) +}) + +notes = [] +notes_display = [] +buttons = [] + +// When the user searches an alias, we update the auto-completion +autoCompleteNote('note', 'note_list', notes, notes_display, + 'alias', 'note', 'user_note', 'profile_pic', function () { + if (buttons.length > 0 && $('#single_conso').is(':checked')) { + consumeAll() + return false + } + return true + }) + +/** + * Add a transaction from a button. + * @param dest Where the money goes + * @param amount The price of the item + * @param type The type of the transaction (content type id for RecurrentTransaction) + * @param category_id The category identifier + * @param category_name The category name + * @param template_id The identifier of the button + * @param template_name The name of the button + */ +function addConso (dest, amount, type, category_id, category_name, template_id, template_name) { + var button = null + buttons.forEach(function (b) { + if (b.id === template_id) { + b.quantity += 1 + button = b + } + }) + if (button == null) { + button = { + id: template_id, + name: template_name, + dest: dest, + quantity: 1, + amount: amount, + type: type, + category_id: category_id, + category_name: category_name + } + buttons.push(button) + } + + const dc_obj = $('#double_conso') + if (dc_obj.is(':checked') || notes_display.length === 0) { + const list = dc_obj.is(':checked') ? 'consos_list' : 'note_list' + let html = '' + buttons.forEach(function (button) { + html += li('conso_button_' + button.id, button.name + + '' + button.quantity + '') + }) + document.getElementById(list).innerHTML = html + + buttons.forEach((button) => { + document.getElementById(`conso_button_${button.id}`).addEventListener('click', () => { + if (LOCK) { return } + removeNote(button, 'conso_button', buttons, list)() + }) + }) + } else { consumeAll() } +} + +/** + * Reset the page as its initial state. + */ +function reset () { + notes_display.length = 0 + notes.length = 0 + buttons.length = 0 + document.getElementById('note_list').innerHTML = '' + document.getElementById('consos_list').innerHTML = '' + document.getElementById('note').value = '' + document.getElementById('note').dataset.originTitle = '' + $('#note').tooltip('hide') + document.getElementById('profile_pic').src = '/static/member/img/default_picture.png' + document.getElementById('profile_pic_link').href = '#' + refreshHistory() + refreshBalance() + LOCK = false +} + +/** + * Apply all transactions: all notes in `notes` buy each item in `buttons` + */ +function consumeAll () { + if (LOCK) { return } + + LOCK = true + + let error = false + + if (notes_display.length === 0) { + document.getElementById('note').classList.add('is-invalid') + $('#note_list').html(li('', 'Ajoutez des émetteurs.', 'text-danger')) + error = true + } + + if (buttons.length === 0) { + $('#consos_list').html(li('', 'Ajoutez des consommations.', 'text-danger')) + error = true + } + + if (error) { + LOCK = false + return + } + + notes_display.forEach(function (note_display) { + buttons.forEach(function (button) { + consume(note_display.note, note_display.name, button.dest, button.quantity * note_display.quantity, button.amount, + button.name + ' (' + button.category_name + ')', button.type, button.category_id, button.id) + }) + }) +} + +/** + * Create a new transaction from a button through the API. + * @param source The note that paid the item (type: note) + * @param source_alias The alias used for the source (type: str) + * @param dest The note that sold the item (type: int) + * @param quantity The quantity sold (type: int) + * @param amount The price of one item, in cents (type: int) + * @param reason The transaction details (type: str) + * @param type The type of the transaction (content type id for RecurrentTransaction) + * @param category The category id of the button (type: int) + * @param template The button id (type: int) + */ +function consume (source, source_alias, dest, quantity, amount, reason, type, category, template) { + $.post('/api/note/transaction/transaction/', + { + csrfmiddlewaretoken: CSRF_TOKEN, + quantity: quantity, + amount: amount, + reason: reason, + valid: true, + polymorphic_ctype: type, + resourcetype: 'RecurrentTransaction', + source: source.id, + source_alias: source_alias, + destination: dest, + template: template + }) + .done(function () { + if (!isNaN(source.balance)) { + const newBalance = source.balance - quantity * amount + if (newBalance <= -2000) { + addMsg(interpolate(gettext('Warning, the transaction from the note %s succeed, ' + + 'but the emitter note %s is very negative.'), [source_alias, source_alias]), 'danger', 30000) + } else if (newBalance < 0) { + 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()) { + addMsg(interpolate(gettext('Warning, the emitter note %s is no more a BDE member.'), [source_alias]), + 'danger', 30000) + } + } + reset() + }).fail(function (e) { + $.post('/api/note/transaction/transaction/', + { + csrfmiddlewaretoken: CSRF_TOKEN, + quantity: quantity, + amount: amount, + reason: reason, + valid: false, + invalidity_reason: 'Solde insuffisant', + polymorphic_ctype: type, + resourcetype: 'RecurrentTransaction', + source: source.id, + source_alias: source_alias, + destination: dest, + template: template + }).done(function () { + reset() + addMsg(gettext("The transaction couldn't be validated because of insufficient balance."), 'danger', 10000) + }).fail(function () { + reset() + errMsg(e.responseJSON) + }) + }) +} + +var searchbar = document.getElementById("search-input") +var search_results = document.getElementById("search-results") + +var old_pattern = null; +var firstMatch = null; +/** + * Updates the button search tab + * @param force Forces the update even if the pattern didn't change + */ +function updateSearch(force = false) { + let pattern = searchbar.value + if (pattern === "") + firstMatch = null; + if ((pattern === old_pattern || pattern === "") && !force) + return; + firstMatch = null; + const re = new RegExp(pattern, "i"); + Array.from(search_results.children).forEach(function(b) { + if (re.test(b.innerText)) { + b.hidden = false; + if (firstMatch === null) { + firstMatch = b; + } + } else + b.hidden = true; + }); +} + +searchbar.addEventListener("input", function (e) { + debounce(updateSearch)() +}); +searchbar.addEventListener("keyup", function (e) { + if (firstMatch && e.key === "Enter") + firstMatch.click() +}); + +function createshiny() { + const list_btn = document.querySelectorAll('.btn-outline-dark') + const shiny_class = list_btn[Math.floor(Math.random() * list_btn.length)].classList + shiny_class.replace('btn-outline-dark', 'btn-outline-dark-shiny') +} +createshiny() diff --git a/apps/family/tables.py b/apps/family/tables.py index dd3d916c..f7eb2a16 100644 --- a/apps/family/tables.py +++ b/apps/family/tables.py @@ -2,10 +2,9 @@ # SPDX-License-Identifier: GPL-3.0-or-later import django_tables2 as tables -from django_tables2 import A from django.urls import reverse -from .models import Family, Challenge, FamilyMembership +from .models import Family, Challenge, FamilyMembership, Achievement class FamilyTable(tables.Table): @@ -58,3 +57,17 @@ class FamilyMembershipTable(tables.Table): template_name = 'django_tables2/bootstrap4.html' fields = ('user',) model = FamilyMembership + + +class AchievementTable(tables.Table): + """ + List recent achievements. + """ + class Meta: + attrs = { + 'class': 'table table-condensed table-striped table-hover' + } + model = Achievement + fields = ('family', 'challenge', 'obtained_at', ) + template_name = 'django_tables2/bootstrap4.html' + orderable = False diff --git a/apps/family/templates/family/challenge_update.html b/apps/family/templates/family/challenge_form.html similarity index 100% rename from apps/family/templates/family/challenge_update.html rename to apps/family/templates/family/challenge_form.html diff --git a/apps/family/templates/family/challenge_list.html b/apps/family/templates/family/challenge_list.html index c84b80ce..2a1502d0 100644 --- a/apps/family/templates/family/challenge_list.html +++ b/apps/family/templates/family/challenge_list.html @@ -16,11 +16,14 @@ SPDX-License-Identifier: GPL-3.0-or-later {% trans "Challenges" %} + + {% trans "Manage" %} + -<
+

{{ title }}

diff --git a/apps/family/templates/family/family_update.html b/apps/family/templates/family/family_form.html similarity index 100% rename from apps/family/templates/family/family_update.html rename to apps/family/templates/family/family_form.html diff --git a/apps/family/templates/family/family_list.html b/apps/family/templates/family/family_list.html index b4b28a89..55feed5e 100644 --- a/apps/family/templates/family/family_list.html +++ b/apps/family/templates/family/family_list.html @@ -16,11 +16,14 @@ SPDX-License-Identifier: GPL-3.0-or-later {% trans "Challenges" %} + + {% trans "Manage" %} +
-<
+

{{ title }}

diff --git a/apps/family/templates/family/manage.html b/apps/family/templates/family/manage.html new file mode 100644 index 00000000..7c8e0dbf --- /dev/null +++ b/apps/family/templates/family/manage.html @@ -0,0 +1,205 @@ +{% extends "base.html" %} +{% comment %} +Copyright (C) 2018-2025 by BDE ENS Paris-Saclay +SPDX-License-Identifier: GPL-3.0-or-later +{% endcomment %} +{% load i18n static django_tables2 %} + +{% block containertype %}container-fluid{% endblock %} + +{% block content %} + + +
+
+
+ {# User details column #} +
+
+ + + +
+ {% trans "Please select a family" %} +
+
+
+ + {# Family selection column #} +
+
+
+

+ {% trans "Families" %} +

+
+
+
    +
+
+ + {# User search with autocompletion #} + +
+
+ + {# Summary of challenges and validate button #} +
+
+
+

+ {% trans "Challenges" %} +

+
+
+
    +
+
+ +
+
+
+ {# Create family/challenge buttons #} +
+

+

+ {% trans "Create a family or challenge" %} +

+

+
+ {% if can_add_family %} + + {% trans "Add a family" %} + + {% endif %} + {% if can_add_challenge %} + + {% trans "Add a challenge" %} + + {% endif %} +
+
+
+ + + {# Buttons column #} +
+ {# Regroup buttons under categories #} + +
+ {# Tabs for list and search #} + + + {# Tabs content #} +
+
+
+
+ {% for challenge in all_challenges %} + + {% endfor %} +
+
+ +
+
+ + {# Mode switch #} + +
+
+
+ + + + + +{# transaction history #} +
+
+

+ {% trans "Recent achievements history" %} +

+
+ {% render_table table %} +
+{% endblock %} + + + +{% block extrajavascript %} + + +{% endblock %} \ No newline at end of file diff --git a/apps/family/urls.py b/apps/family/urls.py index e86bc0b5..094ed505 100644 --- a/apps/family/urls.py +++ b/apps/family/urls.py @@ -3,16 +3,19 @@ from django.urls import path -from .views import FamilyListView, FamilyDetailView, FamilyUpdateView, FamilyPictureUpdateView, FamilyAddMemberView, ChallengeListView, ChallengeDetailView, ChallengeUpdateView +from . import views app_name = 'family' urlpatterns = [ - path('list/', FamilyListView.as_view(), name="family_list"), - path('detail//', FamilyDetailView.as_view(), name="family_detail"), - path('update//', FamilyUpdateView.as_view(), name="family_update"), - path('update_pic//', FamilyPictureUpdateView.as_view(), name="update_pic"), - path('add_member//', FamilyAddMemberView.as_view(), name="family_add_member"), - path('challenge/list/', ChallengeListView.as_view(), name="challenge_list"), - path('challenge/detail//', ChallengeDetailView.as_view(), name="challenge_detail"), - path('challenge/update//', ChallengeUpdateView.as_view(), name="challenge_update"), + path('list/', views.FamilyListView.as_view(), name="family_list"), + path('add-family/', views.FamilyCreateView.as_view(), name="add_family"), + path('detail//', views.FamilyDetailView.as_view(), name="family_detail"), + path('update//', views.FamilyUpdateView.as_view(), name="family_update"), + path('update_pic//', views.FamilyPictureUpdateView.as_view(), name="update_pic"), + path('add_member//', views.FamilyAddMemberView.as_view(), name="family_add_member"), + path('challenge/list/', views.ChallengeListView.as_view(), name="challenge_list"), + path('add-challenge/', views.ChallengeCreateView.as_view(), name="add_challenge"), + path('challenge/detail//', views.ChallengeDetailView.as_view(), name="challenge_detail"), + path('challenge/update//', views.ChallengeUpdateView.as_view(), name="challenge_update"), + path('manage/', views.FamilyManageView.as_view(), name="manage"), ] diff --git a/apps/family/views.py b/apps/family/views.py index 75a82e0c..35f073fb 100644 --- a/apps/family/views.py +++ b/apps/family/views.py @@ -12,13 +12,12 @@ from django_tables2 import SingleTableView from permission.backends import PermissionBackend from permission.views import ProtectQuerysetMixin, ProtectedCreateView from django.urls import reverse_lazy - -from .models import Family, Challenge, FamilyMembership, User -from .tables import FamilyTable, ChallengeTable, FamilyMembershipTable -from .forms import ChallengeUpdateForm, FamilyMembershipForm, FamilyUpdateForm -from member.forms import ImageForm from member.views import PictureUpdateView +from .models import Family, Challenge, FamilyMembership, User, Achievement +from .tables import FamilyTable, ChallengeTable, FamilyMembershipTable, AchievementTable +from .forms import ChallengeForm, FamilyMembershipForm, FamilyForm + class FamilyCreateView(ProtectQuerysetMixin, ProtectedCreateView): """ @@ -26,6 +25,7 @@ class FamilyCreateView(ProtectQuerysetMixin, ProtectedCreateView): """ model = Family extra_context = {"title": _('Create family')} + form_class = FamilyForm def get_sample_object(self): return Family( @@ -35,6 +35,10 @@ class FamilyCreateView(ProtectQuerysetMixin, ProtectedCreateView): rank=0, ) + def get_success_url(self): + self.object.refresh_from_db() + return reverse_lazy("family:manage") + class FamilyListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): """ @@ -92,8 +96,7 @@ class FamilyUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): """ model = Family context_object_name = "family" - form_class = FamilyUpdateForm - template_name = 'family/family_update.html' + form_class = FamilyForm extra_context = {"title": _('Update family')} def get_success_url(self): @@ -152,11 +155,10 @@ class FamilyAddMemberView(ProtectQuerysetMixin, ProtectedCreateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - form = context['form'] family = Family.objects.filter(PermissionBackend.filter_queryset(self.request, Family, "view"))\ .get(pk=self.kwargs['family_pk']) - + context['family'] = family return context @@ -167,7 +169,7 @@ class FamilyAddMemberView(ProtectQuerysetMixin, ProtectedCreateView): Create family membership, check that everythinf is good """ family = Family.objects.filter(PermissionBackend.filter_queryset(self.request, Family, "view")) \ - .get(pk=self.kwargs["family_pk"]) + .get(pk=self.kwargs["family_pk"]) form.instance.family = family @@ -183,6 +185,7 @@ class ChallengeCreateView(ProtectQuerysetMixin, ProtectedCreateView): """ model = Challenge extra_context = {"title": _('Create challenge')} + form_class = ChallengeForm def get_sample_object(self): return Challenge( @@ -234,9 +237,43 @@ class ChallengeUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): model = Challenge context_object_name = "challenge" extra_context = {"title": _('Update challenge')} - template_name = 'family/challenge_update.html' - form_class = ChallengeUpdateForm + form_class = ChallengeForm def get_success_url(self, **kwargs): self.object.refresh_from_db() return reverse_lazy('family:challenge_detail', kwargs={'pk': self.object.pk}) + + +class FamilyManageView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): + """ + Manage families and challenges + """ + model = Achievement + template_name = 'family/manage.html' + table_class = AchievementTable + extra_context = {'title': _('Manage families and challenges')} + + def dispatch(self, request, *args, **kwargs): + # Check that the user is authenticated + if not request.user.is_authenticated: + return self.handle_no_permission() + + return super().dispatch(request, *args, **kwargs) + + def get_queryset(self, **kwargs): + # retrieves only Transaction that user has the right to see. + return Achievement.objects.filter( + PermissionBackend.filter_queryset(self.request, Achievement, "view") + ).order_by("-obtained_at").all()[:20] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['all_challenges'] = Challenge.objects.filter( + PermissionBackend.filter_queryset(self.request, Challenge, "view") + ).order_by('name') + + context["can_add_family"] = PermissionBackend.check_perm(self.request, "family.add_family") + context["can_add_challenge"] = PermissionBackend.check_perm(self.request, "family.add_challenge") + + return context