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" %} + -<
+ {% trans "Families" %} +
++ {% trans "Challenges" %} +
++ {% trans "Create a family or challenge" %} +
+ ++ {% trans "Recent achievements history" %} +
+