1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-07-21 16:39:12 +02:00

Compare commits

...

9 Commits

Author SHA1 Message Date
e6839a1079 Manage page (no js yet) 2025-07-18 01:26:30 +02:00
ab9abc8520 Better list tables 2025-07-17 20:07:12 +02:00
249b797d5a Base template and picture 2025-07-17 19:08:34 +02:00
65dd42fc97 Family views 2025-07-17 17:07:47 +02:00
3ebadf34bc Challenge Update and Create View 2025-07-17 16:59:57 +02:00
6f4fbecdd0 Challenge detail View 2025-07-09 16:33:05 +02:00
c7bd733911 Models fixed 2025-07-06 18:11:09 +02:00
f6ad6197de ListViews et templates 2025-07-05 19:47:35 +02:00
6c7d86185a Models 2025-07-03 14:34:04 +02:00
25 changed files with 1625 additions and 3 deletions

0
apps/family/__init__.py Normal file
View File

11
apps/family/apps.py Normal file
View File

@ -0,0 +1,11 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.utils.translation import gettext_lazy as _
from django.apps import AppConfig
class FamilyConfig(AppConfig):
name = 'family'
verbose_name = _('family')

44
apps/family/forms.py Normal file
View File

@ -0,0 +1,44 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django import forms
from django.forms.widgets import NumberInput
from note_kfet.inputs import Autocomplete
from .models import Challenge, FamilyMembership, User, Family
class ChallengeForm(forms.ModelForm):
"""
To update a challenge
"""
class Meta:
model = Challenge
fields = ('name', 'description', 'points',)
widgets = {
"points": NumberInput()
}
class FamilyForm(forms.ModelForm):
class Meta:
model = Family
fields = ('name', 'description', )
class FamilyMembershipForm(forms.ModelForm):
class Meta:
model = FamilyMembership
fields = ('user', )
widgets = {
"user":
Autocomplete(
User,
attrs={
'api_url': '/api/user/',
'name_field': 'username',
'placeholder': 'Nom ...',
},
)
}

View File

@ -0,0 +1,73 @@
# Generated by Django 4.2.21 on 2025-07-06 16:07
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Challenge',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='name')),
('description', models.CharField(max_length=255, verbose_name='description')),
('points', models.PositiveIntegerField(verbose_name='points')),
('obtained', models.PositiveIntegerField(default=0, verbose_name='obtained')),
],
options={
'verbose_name': 'challenge',
'verbose_name_plural': 'challenges',
},
),
migrations.CreateModel(
name='Family',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True, verbose_name='name')),
('description', models.CharField(max_length=255, verbose_name='description')),
('score', models.PositiveIntegerField(default=0, verbose_name='score')),
('rank', models.PositiveIntegerField(verbose_name='rank')),
],
options={
'verbose_name': 'Family',
'verbose_name_plural': 'Families',
},
),
migrations.CreateModel(
name='Achievement',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('obtained_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='obtained at')),
('challenge', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='family.challenge')),
('family', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='family.family', verbose_name='family')),
],
options={
'verbose_name': 'achievement',
'verbose_name_plural': 'achievements',
},
),
migrations.CreateModel(
name='FamilyMembership',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('year', models.PositiveIntegerField(default=2025, verbose_name='year')),
('family', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='members', to='family.family', verbose_name='family')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='family_memberships', to=settings.AUTH_USER_MODEL, verbose_name='user')),
],
options={
'verbose_name': 'family membership',
'verbose_name_plural': 'family memberships',
'unique_together': {('user', 'year')},
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.23 on 2025-07-17 15:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('family', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='family',
name='display_image',
field=models.ImageField(default='pic/default.png', max_length=255, upload_to='pic/', verbose_name='display image'),
),
]

View File

206
apps/family/models.py Normal file
View File

@ -0,0 +1,206 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.db import models, transaction
from django.utils import timezone
from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _
class Family(models.Model):
name = models.CharField(
max_length=255,
verbose_name=_('name'),
unique=True,
)
description = models.CharField(
max_length=255,
verbose_name=_('description'),
)
score = models.PositiveIntegerField(
verbose_name=_('score'),
default=0,
)
rank = models.PositiveIntegerField(
verbose_name=_('rank'),
)
display_image = models.ImageField(
verbose_name=_('display image'),
max_length=255,
blank=False,
null=False,
upload_to='pic/',
default='pic/default.png'
)
class Meta:
verbose_name = _('Family')
verbose_name_plural = _('Families')
def __str__(self):
return self.name
def update_score(self, *args, **kwargs):
challenge_set = Challenge.objects.select_for_update().filter(achievement__family=self)
points_sum = challenge_set.aggregate(models.Sum("points"))
self.score = points_sum["points__sum"]
self.save()
self.update_ranking()
@staticmethod
def update_ranking(*args, **kwargs):
"""
Update ranking when adding or removing points
"""
family_set = Family.objects.select_for_update().all().order_by("-score")
for i in range(family_set.count()):
if i == 0 or family_set[i].score != family_set[i - 1].score:
new_rank = i + 1
family = family_set[i]
family.rank = new_rank
family._force_save = True
family.save()
def save(self, *args, **kwargs):
if self.rank is None:
last_family = Family.objects.order_by("rank").last()
if last_family is None or last_family.score > self.score:
self.rank = Family.objects.count() + 1
else:
self.rank = last_family.rank
super().save(*args, **kwargs)
class FamilyMembership(models.Model):
user = models.OneToOneField(
User,
on_delete=models.PROTECT,
related_name=_('family_memberships'),
verbose_name=_('user'),
)
family = models.ForeignKey(
Family,
on_delete=models.PROTECT,
related_name=_('members'),
verbose_name=_('family'),
)
year = models.PositiveIntegerField(
verbose_name=_('year'),
default=timezone.now().year,
)
class Meta:
unique_together = ('user', 'year',)
verbose_name = _('family membership')
verbose_name_plural = _('family memberships')
def __str__(self):
return _('Family membership of {user} to {family}').format(user=self.user.username, family=self.family.name, )
class Challenge(models.Model):
name = models.CharField(
max_length=255,
verbose_name=_('name'),
)
description = models.CharField(
max_length=255,
verbose_name=_('description'),
)
points = models.PositiveIntegerField(
verbose_name=_('points'),
)
obtained = models.PositiveIntegerField(
verbose_name=_('obtained'),
default=0,
)
@transaction.atomic
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# Update families who already obtained this challenge
achievements = Achievement.objects.filter(challenge=self)
for achievement in achievements:
achievement.save()
class Meta:
verbose_name = _('challenge')
verbose_name_plural = _('challenges')
def __str__(self):
return self.name
class Achievement(models.Model):
challenge = models.ForeignKey(
Challenge,
on_delete=models.PROTECT,
)
family = models.ForeignKey(
Family,
on_delete=models.PROTECT,
verbose_name=_('family'),
)
obtained_at = models.DateTimeField(
verbose_name=_('obtained at'),
default=timezone.now,
)
class Meta:
verbose_name = _('achievement')
verbose_name_plural = _('achievements')
def __str__(self):
return _('Challenge {challenge} carried out by Family {family}').format(challenge=self.challenge.name, family=self.family.name, )
@transaction.atomic
def save(self, *args, **kwargs):
"""
When saving, also grants points to the family
"""
self.family = Family.objects.select_for_update().get(pk=self.family_id)
self.challenge = Challenge.objects.select_for_update().get(pk=self.challenge_id)
is_new = self.pk is None
super().save(*args, **kwargs)
self.family.refresh_from_db()
self.family.update_score()
# Count only when getting a new achievement
if is_new:
self.challenge.refresh_from_db()
self.challenge.obtained += 1
self.challenge._force_save = True
self.challenge.save()
@transaction.atomic
def delete(self, *args, **kwargs):
"""
When deleting, also removes points from the family
"""
# Get the family and challenge before deletion
self.family = Family.objects.select_for_update().get(pk=self.family_id)
# Delete the achievement
super().delete(*args, **kwargs)
# Remove points from the family
self.family.refresh_from_db()
self.family.update_score()
self.challenge.refresh_from_db()
self.challenge.obtained -= 1
self.challenge._force_save = True
self.challenge.save()

View File

@ -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 +
'<span class="badge badge-dark badge-pill">' + button.quantity + '</span>')
})
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('', '<strong>Ajoutez des émetteurs.</strong>', 'text-danger'))
error = true
}
if (buttons.length === 0) {
$('#consos_list').html(li('', '<strong>Ajoutez des consommations.</strong>', '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()

73
apps/family/tables.py Normal file
View File

@ -0,0 +1,73 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import django_tables2 as tables
from django.urls import reverse
from .models import Family, Challenge, FamilyMembership, Achievement
class FamilyTable(tables.Table):
"""
List all families
"""
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
model = Family
template_name = 'django_tables2/bootstrap4.html'
fields = ('name', 'score', 'rank',)
order_by = ('rank',)
row_attrs = {
'class': 'table-row',
'data-href': lambda record: reverse('family:family_detail', args=[record.pk]),
'style': 'cursor:pointer',
}
class ChallengeTable(tables.Table):
"""
List all challenges
"""
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
order_by = ('id',)
model = Challenge
template_name = 'django_tables2/bootstrap4.html'
fields = ('name', 'description', 'points',)
row_attrs = {
'class': 'table-row',
'data-href': lambda record: reverse('family:challenge_detail', args=[record.pk]),
'style': 'cursor:pointer',
}
class FamilyMembershipTable(tables.Table):
"""
List all family memberships.
"""
class Meta:
attrs = {
'class': 'table table-condensed table-striped',
'style': 'table-layout: fixed;'
}
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

View File

@ -0,0 +1,60 @@
{% extends "family/base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load crispy_forms_tags i18n pretty_money %}
{% block profile_content %}
<div class="card bg-light">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body">
<form method="post" action="">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
</form>
</div>
</div>
{% endblock %}
{% block extrajavascript %}
<script>
function autocompleted(user) {
$("#id_last_name").val(user.last_name);
$("#id_first_name").val(user.first_name);
$.getJSON("/api/members/profile/" + user.id + "/", function (profile) {
let fee = profile.paid ? "{{ club.membership_fee_paid }}" : "{{ club.membership_fee_unpaid }}";
$("#id_credit_amount").val((Number(fee) / 100).toFixed(2));
});
}
soge_field = $("#id_soge");
function fillFields() {
let checked = soge_field.is(':checked');
if (!checked) {
$("input").attr('disabled', false);
$("#id_user").attr('disabled', true);
$("select").attr('disabled', false);
return;
}
let credit_type = $("#id_credit_type");
credit_type.attr('disabled', true);
credit_type.val(4);
let credit_amount = $("#id_credit_amount");
credit_amount.attr('disabled', true);
credit_amount.val('{{ total_fee }}');
let bank = $("#id_bank");
bank.attr('disabled', true);
bank.val('Société générale');
}
soge_field.change(fillFields);
</script>
{% endblock %}

View File

@ -0,0 +1,52 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n perms %}
{# Use a fluid-width container #}
{% block containertype %}container-fluid{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-xl-4">
{% block profile_info %}
<div class="card bg-light" id="card-infos">
<h4 class="card-header text-center">
{{ family.name }}
</h4>
<div class="text-center">
<a href="{% url 'family:update_pic' family.pk %}">
<img src="{{ family.display_image.url }}" class="img-thumbnail mt-2">
</a>
</div>
<div class="card-body" id="profile_infos">
{% include "family/family_info.html" %}
</div>
<div class="card-footer">
{% if can_add_members %}
<a class="btn btn-sm btn-success" href="{% url 'family:family_add_member' family_pk=family.pk %}"
data-turbolinks="false"> {% trans "Add member" %}</a>
{% endif %}
{% if ".change_"|has_perm:family %}
<a class="btn btn-sm btn-secondary" href="{% url 'family:family_update' pk=family.pk %}"
data-turbolinks="false">
<i class="fa fa-edit"></i> {% trans 'Update Profile' %}
</a>
{% endif %}
{% url 'family:family_detail' family.pk as family_detail_url %}
{% if request.path_info != family_detail_url %}
<a class="btn btn-sm btn-primary" href="{{ family_detail_url }}">{% trans 'View Profile' %}</a>
{% endif %}
<a class="btn btn-sm btn-primary" href="{% url "family:family_list" %}">
{% trans "Return to the family list" %}
</a>
</div>
</div>
{% endblock %}
</div>
<div class="col-xl-8">
{% block profile_content %}{% endblock %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,36 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }} {{ challenge.name }}
</h3>
<div class="card-body">
<ul>
{% for field, value in fields %}
<li> {{ field }} : {{ value }}</li>
{% endfor %}
<li> {% trans "Obtained by " %} {{obtained}}
{% if obtained > 1 %}
{% trans "families" %}
{% else %}
{% trans "family" %}
{% endif %}
</li>
</ul>
<a class="btn btn-sm btn-primary" href="{% url "family:challenge_list" %}">
{% trans "Return to the challenge list" %}
</a>
{% if update %}
<a class="btn btn-sm btn-secondary" href="{% url "family:challenge_update" pk=challenge.pk %}">
{% trans "Update" %}
</a>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,21 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body" id="form">
<form method="post">
{% csrf_token %}
{{ form | crispy }}
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,40 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col-xl-12">
<div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0">
<a href="{% url "family:family_list" %}" class="btn btn-sm btn-outline-primary">
{% trans "Families" %}
</a>
<a href="#" class="btn btn-sm btn-outline-primary active">
{% trans "Challenges" %}
</a>
<a href="{% url "family:manage" %}" class="btn btn-sm btn-outline-primary">
{% trans "Manage" %}
</a>
</div>
</div>
</div>
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
{% render_table table %}
</div>
{% endblock %}
{% block extrajavascript %}
<script type="text/javascript">
$(".table-row").click(function () {
window.document.location = $(this).data("href");
});
</script>
{% endblock %}

View File

@ -0,0 +1,16 @@
{% extends "family/base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load i18n perms %}
{% block profile_content %}
<div class="card">
<div class="card-header position-relative" id="clubListHeading">
<i class="fa fa-users"></i> {% trans "Family members" %}
</div>
{% render_table member_list %}
</div>
{% endblock %}

View File

@ -0,0 +1,21 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body" id="form">
<form method="post">
{% csrf_token %}
{{ form | crispy }}
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,15 @@
{% load i18n pretty_money perms %}
<dl class="row">
<dt class="col-xl-6">{% trans 'name'|capfirst %}</dt>
<dd class="col-xl-6">{{ family.name }}</dd>
<dt class="col-xl-6">{% trans 'description'|capfirst %}</dt>
<dd class="col-xl-6">{{ family.description }}</dd>
<dt class="col-xl-6">{% trans 'score'|capfirst %}</dt>
<dd class="col-xl-6">{{ family.score }}</dd>
<dt class="col-xl-6">{% trans 'rank'|capfirst %}</dt>
<dd class="col-xl-6">{{ family.rank }}</dd>
</dl>

View File

@ -0,0 +1,41 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col-xl-12">
<div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0">
<a href="#" class="btn btn-sm btn-outline-primary active">
{% trans "Families" %}
</a>
<a href="{% url "family:challenge_list" %}" class="btn btn-sm btn-outline-primary">
{% trans "Challenges" %}
</a>
<a href="{% url "family:manage" %}" class="btn btn-sm btn-outline-primary">
{% trans "Manage" %}
</a>
</div>
</div>
</div>
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
{% render_table table %}
</div>
{% endblock %}
{% block extrajavascript %}
<script type="text/javascript">
$(".table-row").click(function () {
window.document.location = $(this).data("href");
});
</script>
{% endblock %}

View File

@ -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 %}
<div class="row">
<div class="col-xl-12">
<div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0">
<a href="{% url "family:family_list" %}" class="btn btn-sm btn-outline-primary">
{% trans "Families" %}
</a>
<a href="{% url "family:challenge_list" %}" class="btn btn-sm btn-outline-primary">
{% trans "Challenges" %}
</a>
<a href="#" class="btn btn-sm btn-outline-primary active">
{% trans "Manage" %}
</a>
</div>
</div>
</div>
<div class="row mb-3">
<div class='col-sm-5 col-xl-6' id="infos_div">
<div class="row justify-content-center justify-content-md-end">
{# User details column #}
<div class="col picture-col">
<div class="card bg-light mb-4 text-center">
<a id="profile_pic_link" href="#">
<img src="{% static "member/img/default_picture.png" %}"
id="profile_pic" alt="" class="card-img-top d-none d-sm-block">
</a>
<div class="card-body text-center text-break p-2">
<span id="user_note"><i class="small">{% trans "Please select a family" %}</i></span>
</div>
</div>
</div>
{# Family selection column #}
<div class="col-xl" id="user_select_div">
<div class="card bg-light border-success mb-4">
<div class="card-header">
<p class="card-text font-weight-bold">
{% trans "Families" %}
</p>
</div>
<div class="card-body p-0" style="min-height:125px;">
<ul class="list-group list-group-flush" id="note_list">
</ul>
</div>
{# User search with autocompletion #}
<div class="card-footer">
<input class="form-control mx-auto d-block"
placeholder="{% trans "Name" %}" type="text" id="note" autofocus />
</div>
</div>
</div>
{# Summary of challenges and validate button #}
<div class="col-xl-5" id="consos_list_div">
<div class="card bg-light border-info mb-4">
<div class="card-header">
<p class="card-text font-weight-bold">
{% trans "Challenges" %}
</p>
</div>
<div class="card-body p-0" style="min-height:125px;">
<ul class="list-group list-group-flush" id="consos_list">
</ul>
</div>
<div class="card-footer text-center">
<span id="consume_all" class="btn btn-primary">
{% trans "Validate!" %}
</span>
</div>
</div>
</div>
</div>
{# Create family/challenge buttons #}
<div class="card bg-light border-success mb-4">
<h3 class="card-header">
<p class="card-text font-weight-bold">
{% trans "Create a family or challenge" %}
</p>
</h3>
<div class="card-body">
{% if can_add_family %}
<a class="btn btn-sm btn-primary" href="{% url "family:add_family" %}">
{% trans "Add a family" %}
</a>
{% endif %}
{% if can_add_challenge %}
<a class="btn btn-sm btn-primary" href="{% url "family:add_challenge" %}">
{% trans "Add a challenge" %}
</a>
{% endif %}
</div>
</div>
</div>
{# Buttons column #}
<div class="col">
{# Regroup buttons under categories #}
<div class="card bg-light border-primary text-center mb-4">
{# Tabs for list and search #}
<div class="card-header">
<ul class="nav nav-tabs nav-fill card-header-tabs">
<li class="nav-item">
<a class="nav-link font-weight-bold" data-toggle="tab" href="#list">
{% trans "List" %}
</a>
</li>
<li class="nav-item">
<a class="nav-link font-weight-bold" data-toggle="tab" href="#search">
{% trans "Search" %}
</a>
</li>
</ul>
</div>
{# Tabs content #}
<div class="card-body">
<div class="tab-content">
<div class="tab-pane" id="list">
<div class="d-inline-flex flex-wrap justify-content-center">
{% for challenge in all_challenges %}
<button class="btn btn-outline-dark rounded-0 flex-fill"
id="challenge{{ challenge.id }}" name="button" value="{{ challenge.name }}">
{{ challenge.name }} ({{ challenge.points }} {% trans "points" %})
</button>
{% endfor %}
</div>
</div>
<div class="tab-pane" id="search">
<input class="form-control mx-auto d-block mb-3"
placeholder="{% trans "Search challenge..." %}" type="search" id="search-input"/>
<div class="d-inline-flex flex-wrap justify-content-center" id="search-results">
{% for challenge in all_challenges %}
<button class="btn btn-outline-dark rounded-0 flex-fill" hidden
id="search_challenge{{ challenge.id }}" name="button" value="{{ challenge.name }}">
{{ challenge.name }} ({{ challenge.points }} {% trans "points" %})
</button>
{% endfor %}
</div>
</div>
</div>
</div>
{# Mode switch #}
<div class="card-footer border-primary">
<a class="btn btn-sm btn-secondary float-left" href="{% url 'note:template_list' %}">
<i class="fa fa-edit"></i> {% trans "Edit" %}
</a>
</div>
</div>
</div>
</div>
{# transaction history #}
<div class="card mb-4" id="history">
<div class="card-header">
<p class="card-text font-weight-bold">
{% trans "Recent achievements history" %}
</p>
</div>
{% render_table table %}
</div>
{% endblock %}
{% block extrajavascript %}
<script type="text/javascript" src="{% static "family/js/consos.js" %}"></script>
<script type="text/javascript">
{% for button in all_challenges %}
document.getElementById("button{{ button.id }}").addEventListener("click", function() {
addConso({{ button.destination_id }}, {{ button.amount }},
{{ polymorphic_ctype }}, {{ button.category_id }}, "{{ button.category.name|escapejs }}",
{{ button.id }}, "{{ button.name|escapejs }}");
});
{% endfor %}
{% for button in all_challenges %}
{% if button.display %}
document.getElementById("search_button{{ button.id }}").addEventListener("click", function() {
addConso({{ button.destination_id }}, {{ button.amount }},
{{ polymorphic_ctype }}, {{ button.category_id }}, "{{ button.category.name|escapejs }}",
{{ button.id }}, "{{ button.name|escapejs }}");
});
{% endif %}
{% endfor %}
</script>
{% endblock %}

View File

@ -0,0 +1,118 @@
{% extends "family/base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block profile_content %}
<div class="card bg-light">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body">
<div class="text-center">
<form method="post" enctype="multipart/form-data" id="formUpload">
{% csrf_token %}
{{ form |crispy }}
{% if user.note.display_image != "pic/default.png" %}
<input type="submit" class="btn btn-primary" value="{% trans "Remove" %}">
{% endif %}
</form>
</div>
<!-- MODAL TO CROP THE IMAGE -->
<div class="modal fade" id="modalCrop" data-backdrop="static">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body-wrapper" style="width: 500px; height: 500px; padding: 16px;">
<div class="modal-body" style="width: 100%; height: 100%; padding: 0">
<img src="" id="modal-image" style="display: block; max-width: 100%;">
</div>
</div>
<div class="modal-footer">
<div class="btn-group pull-left" role="group">
<button type="button" class="btn btn-default" id="js-zoom-in">
<span class="glyphicon glyphicon-zoom-in"></span>
</button>
<button type="button" class="btn btn-default js-zoom-out">
<span class="glyphicon glyphicon-zoom-out"></span>
</button>
</div>
<button type="button" class="btn btn-default" data-dismiss="modal">{% trans "Nevermind" %}</button>
<button type="button" class="btn btn-primary js-crop-and-upload">{% trans "Crop and upload" %}</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extracss %}
<link href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.6/cropper.min.css" rel="stylesheet">
{% endblock %}
{% block extrajavascript%}
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.6/cropper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery-cropper@1.0.1/dist/jquery-cropper.min.js"></script>
<script>
$(function () {
/* SCRIPT TO OPEN THE MODAL WITH THE PREVIEW */
$("#id_image").change(function (e) {
if (this.files && this.files[0]) {
// Check the image size
if (this.files[0].size > 2*1024*1024) {
alert("Ce fichier est trop volumineux.")
} else {
// Read the selected image file
var reader = new FileReader();
reader.onload = function (e) {
$("#modal-image").attr("src", e.target.result);
$("#modalCrop").modal("show");
}
reader.readAsDataURL(this.files[0]);
}
}
});
/* SCRIPTS TO HANDLE THE CROPPER BOX */
var $image = $("#modal-image");
var cropBoxData;
var canvasData;
$("#modalCrop").on("shown.bs.modal", function () {
$image.cropper({
viewMode: 1,
aspectRatio: 1 / 1,
minCropBoxWidth: 200,
minCropBoxHeight: 200,
ready: function () {
$image.cropper("setCanvasData", canvasData);
$image.cropper("setCropBoxData", cropBoxData);
}
});
}).on("hidden.bs.modal", function () {
cropBoxData = $image.cropper("getCropBoxData");
canvasData = $image.cropper("getCanvasData");
$image.cropper("destroy");
});
$(".js-zoom-in").click(function () {
$image.cropper("zoom", 0.1);
});
$(".js-zoom-out").click(function () {
$image.cropper("zoom", -0.1);
});
/* SCRIPT TO COLLECT THE DATA AND POST TO THE SERVER */
$(".js-crop-and-upload").click(function () {
var cropData = $image.cropper("getData");
$("#id_x").val(cropData["x"]);
$("#id_y").val(cropData["y"]);
$("#id_height").val(cropData["height"]);
$("#id_width").val(cropData["width"]);
$("#formUpload").submit();
});
});
</script>
{% endblock %}

21
apps/family/urls.py Normal file
View File

@ -0,0 +1,21 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path
from . import views
app_name = 'family'
urlpatterns = [
path('list/', views.FamilyListView.as_view(), name="family_list"),
path('add-family/', views.FamilyCreateView.as_view(), name="add_family"),
path('detail/<int:pk>/', views.FamilyDetailView.as_view(), name="family_detail"),
path('update/<int:pk>/', views.FamilyUpdateView.as_view(), name="family_update"),
path('update_pic/<int:pk>/', views.FamilyPictureUpdateView.as_view(), name="update_pic"),
path('add_member/<int:family_pk>/', 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/<int:pk>/', views.ChallengeDetailView.as_view(), name="challenge_detail"),
path('challenge/update/<int:pk>/', views.ChallengeUpdateView.as_view(), name="challenge_update"),
path('manage/', views.FamilyManageView.as_view(), name="manage"),
]

279
apps/family/views.py Normal file
View File

@ -0,0 +1,279 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import date
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import transaction
from django.views.generic import DetailView, UpdateView
from django.utils.translation import gettext_lazy as _
from django_tables2 import SingleTableView
from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from django.urls import reverse_lazy
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):
"""
Create family
"""
model = Family
extra_context = {"title": _('Create family')}
form_class = FamilyForm
def get_sample_object(self):
return Family(
name="",
description="Sample family",
score=0,
rank=0,
)
def get_success_url(self):
self.object.refresh_from_db()
return reverse_lazy("family:manage")
class FamilyListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
List existing Families
"""
model = Family
table_class = FamilyTable
extra_context = {"title": _('Families list')}
class FamilyDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
Display details of a family
"""
model = Family
context_object_name = "family"
extra_context = {"title": _('Family detail')}
def get_context_data(self, **kwargs):
"""
Add members list
"""
context = super().get_context_data(**kwargs)
family = self.object
# member list
family_member = FamilyMembership.objects.filter(
family=family,
year=date.today().year,
).filter(PermissionBackend.filter_queryset(self.request, FamilyMembership, "view"))\
.order_by("user__username")
family_member = family_member.distinct("user__username")\
if settings.DATABASES["default"]["ENGINE"] == 'django.db.backends.postgresql' else family_member
membership_table = FamilyMembershipTable(data=family_member, prefix="membership-")
membership_table.paginate(per_page=5, page=self.request.GET.get('membership-page', 1))
context['member_list'] = membership_table
# Check if the user has the right to create a membership, to display the button.
empty_membership = FamilyMembership(
family=family,
user=User.objects.first(),
year=date.today().year,
)
context["can_add_members"] = PermissionBackend()\
.has_perm(self.request.user, "family.add_membership", empty_membership)
return context
class FamilyUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
Update the information of a family.
"""
model = Family
context_object_name = "family"
form_class = FamilyForm
extra_context = {"title": _('Update family')}
def get_success_url(self):
return reverse_lazy('family:family_detail', kwargs={'pk': self.object.pk})
class FamilyPictureUpdateView(PictureUpdateView):
"""
Update profile picture of the family
"""
model = Family
extra_context = {"title": _("Update family picture")}
template_name = 'family/picture_update.html'
def get_success_url(self):
"""Redirect to family page after upload"""
return reverse_lazy('family:family_detail', kwargs={'pk': self.object.id})
@transaction.atomic
def form_valid(self, form):
"""
Save the image
"""
image = form.cleaned_data['image']
if image is None:
image = "pic/default.png"
else:
# Rename as PNG or GIF
extension = image.name.split(".")[-1]
if extension == "gif":
image.name = "{}_pic.gif".format(self.object.pk)
else:
image.name = "{}_pic.png".format(self.object.pk)
class FamilyAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
"""
Add a membership to a family
"""
model = FamilyMembership
form_class = FamilyMembershipForm
template_name = 'family/add_member.html'
extra_context = {"title": _("Add a new member to the family")}
def get_sample_object(self):
if "family_pk" in self.kwargs:
family = Family.objects.get(pk=self.kwargs["family_pk"])
else:
family = FamilyMembership.objects.get(pk=self.kwargs["pk"]).family
return FamilyMembership(
user=self.request.user,
family=family,
year=date.today().year,
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
family = Family.objects.filter(PermissionBackend.filter_queryset(self.request, Family, "view"))\
.get(pk=self.kwargs['family_pk'])
context['family'] = family
return context
@transaction.atomic
def form_valid(self, form):
"""
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"])
form.instance.family = family
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy('family:family_detail', kwargs={'pk': self.object.family.id})
class ChallengeCreateView(ProtectQuerysetMixin, ProtectedCreateView):
"""
Create challenge
"""
model = Challenge
extra_context = {"title": _('Create challenge')}
form_class = ChallengeForm
def get_sample_object(self):
return Challenge(
name="",
description="Sample challenge",
points=0,
)
def get_success_url(self):
return reverse_lazy('family:challenge_list')
class ChallengeListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
List all challenges
"""
model = Challenge
table_class = ChallengeTable
extra_context = {"title": _('Challenges list')}
class ChallengeDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
Display details of a challenge
"""
model = Challenge
context_object_name = "challenge"
extra_context = {"title": _('Details of:')}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
fields = ["name", "description", "points",]
fields = dict([(field, getattr(self.object, field)) for field in fields])
context["fields"] = [(
Challenge._meta.get_field(field).verbose_name.capitalize(),
value) for field, value in fields.items()]
context["obtained"] = self.object.obtained
context["update"] = PermissionBackend.check_perm(self.request, "family.change_challenge")
return context
class ChallengeUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
Update the information of a challenge
"""
model = Challenge
context_object_name = "challenge"
extra_context = {"title": _('Update challenge')}
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

View File

@ -70,6 +70,7 @@ INSTALLED_APPS = [
# Note apps
'api',
'activity',
'family',
'food',
'logs',
'member',
@ -270,7 +271,7 @@ OAUTH2_PROVIDER = {
'PKCE_REQUIRED': False, # PKCE (fix a breaking change of django-oauth-toolkit 2.0.0)
'OIDC_ENABLED': True,
'OIDC_RSA_PRIVATE_KEY':
os.getenv('OIDC_RSA_PRIVATE_KEY', '/var/secrets/oidc.key'),
os.getenv('OIDC_RSA_PRIVATE_KEY', 'CHANGE_ME_IN_ENV_SETTINGS').replace('\\n', '\n'), # for multilines
'SCOPES': { 'openid': "OpenID Connect scope" },
}

View File

@ -78,6 +78,13 @@ SPDX-License-Identifier: GPL-3.0-or-later
<a class="nav-link {% if request.path_info == url %}active{% endif %}" href="{{ url }}"><i class="fa fa-exchange"></i> {% trans 'Transfer' %}</a>
</li>
{% endif %}
{% if user.is_authenticated %}
<li class="nav-item">
{% url 'family:family_list' as url %}
<a class="nav-link {% if request.path_info == url %}active{% endif %}" href="{{ url }}"><i class="fa fa-users"></i> {% trans 'Families' %}</a>
</li>
{% endif %}
{% if "auth.user"|model_list_length >= 2 %}
<li class="nav-item">
{% url 'member:user_list' as url %}

View File

@ -21,8 +21,9 @@ urlpatterns = [
path('activity/', include('activity.urls')),
path('treasury/', include('treasury.urls')),
path('wei/', include('wei.urls')),
path('food/',include('food.urls')),
path('wrapped/',include('wrapped.urls')),
path('food/', include('food.urls')),
path('wrapped/', include('wrapped.urls')),
path('family/', include('family.urls')),
# Include Django Contrib and Core routers
path('i18n/', include('django.conf.urls.i18n')),