mirror of
https://gitlab.crans.org/bde/nk20
synced 2025-07-06 15:24:01 +02:00
ListViews et templates
This commit is contained in:
@ -1,3 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
# Register your models here.
|
|
85
apps/family/migrations/0001_initial.py
Normal file
85
apps/family/migrations/0001_initial.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
# Generated by Django 4.2.21 on 2025-07-04 19:05
|
||||||
|
|
||||||
|
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='ChallengeCategory',
|
||||||
|
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')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'challenge category',
|
||||||
|
'verbose_name_plural': 'challenge categories',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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(verbose_name='score')),
|
||||||
|
('rank', models.PositiveIntegerField(verbose_name='rank')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Family',
|
||||||
|
'verbose_name_plural': 'Families',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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(verbose_name='obtained')),
|
||||||
|
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='family.challengecategory', verbose_name='category')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'challenge',
|
||||||
|
'verbose_name_plural': 'challenges',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@ -1,3 +1,6 @@
|
|||||||
|
# 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.db import models, transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
@ -53,6 +56,7 @@ class FamilyMembership(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
unique_together = ('user', 'year',)
|
||||||
verbose_name = _('family membership')
|
verbose_name = _('family membership')
|
||||||
verbose_name_plural = _('family memberships')
|
verbose_name_plural = _('family memberships')
|
||||||
|
|
||||||
@ -96,6 +100,10 @@ class Challenge(models.Model):
|
|||||||
on_delete=models.PROTECT
|
on_delete=models.PROTECT
|
||||||
)
|
)
|
||||||
|
|
||||||
|
obtained = models.PositiveIntegerField(
|
||||||
|
verbose_name=_('obtained')
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _('challenge')
|
verbose_name = _('challenge')
|
||||||
verbose_name_plural = _('challenges')
|
verbose_name_plural = _('challenges')
|
||||||
@ -128,12 +136,27 @@ class Achievement(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return _('Challenge {challenge} carried out by Family {family}').format(challenge=self.challenge.name, family=self.family.name, )
|
return _('Challenge {challenge} carried out by Family {family}').format(challenge=self.challenge.name, family=self.family.name, )
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update_ranking(cls, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Update ranking when adding or removing points
|
||||||
|
"""
|
||||||
|
family_set = cls.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()
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
When saving, also grants points to the family
|
When saving, also grants points to the family
|
||||||
"""
|
"""
|
||||||
self.family = Family.objects.select_for_update().get(pk=self.family_id)
|
self.family = Family.objects.select_for_update().get(pk=self.family_id)
|
||||||
|
self.challenge = Challenge.objects.select_for_update().get(pk=self.challenge_id)
|
||||||
challenge_points = self.challenge.points
|
challenge_points = self.challenge.points
|
||||||
is_new = self.pk is None
|
is_new = self.pk is None
|
||||||
|
|
||||||
@ -146,6 +169,13 @@ class Achievement(models.Model):
|
|||||||
self.family._force_save = True
|
self.family._force_save = True
|
||||||
self.family.save()
|
self.family.save()
|
||||||
|
|
||||||
|
self.challenge.refresh_from_db()
|
||||||
|
self.challenge.obtained += 1
|
||||||
|
self.challenge._force_save = True
|
||||||
|
self.challenge.save()
|
||||||
|
|
||||||
|
self.__class__.update_ranking()
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -163,3 +193,10 @@ class Achievement(models.Model):
|
|||||||
self.family.score -= challenge_points
|
self.family.score -= challenge_points
|
||||||
self.family._force_save = True
|
self.family._force_save = True
|
||||||
self.family.save()
|
self.family.save()
|
||||||
|
|
||||||
|
self.challenge.refresh_from_db()
|
||||||
|
self.challenge.obtained -= 1
|
||||||
|
self.challenge._force_save = True
|
||||||
|
self.challenge.save()
|
||||||
|
|
||||||
|
self.__class__.update_ranking()
|
||||||
|
40
apps/family/tables.py
Normal file
40
apps/family/tables.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import django_tables2 as tables
|
||||||
|
from django_tables2 import A
|
||||||
|
|
||||||
|
from .models import Family, Challenge
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyTable(tables.Table):
|
||||||
|
"""
|
||||||
|
List all families
|
||||||
|
"""
|
||||||
|
name = tables.LinkColumn(
|
||||||
|
"family:family_detail",
|
||||||
|
args=[A("pk")],
|
||||||
|
)
|
||||||
|
|
||||||
|
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',)
|
||||||
|
|
||||||
|
|
||||||
|
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', 'points', 'category',)
|
30
apps/family/templates/family/challenge_list.html
Normal file
30
apps/family/templates/family/challenge_list.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{% 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<<div class="card bg-white mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
{% render_table table %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
30
apps/family/templates/family/family_list.html
Normal file
30
apps/family/templates/family/family_list.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{% 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<<div class="card bg-white mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
{% render_table table %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
12
apps/family/urls.py
Normal file
12
apps/family/urls.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.urls import path
|
||||||
|
|
||||||
|
from .views import FamilyListView, ChallengeListView
|
||||||
|
|
||||||
|
app_name = 'family'
|
||||||
|
urlpatterns = [
|
||||||
|
path('list/', FamilyListView.as_view(), name="family_list"),
|
||||||
|
path('challenge/list/', ChallengeListView.as_view(), name="challenge_list"),
|
||||||
|
]
|
@ -1,3 +1,63 @@
|
|||||||
from django.shortcuts import render
|
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
# Create your views here.
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.views.generic import DetailView, UpdateView
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django_tables2 import SingleTableView
|
||||||
|
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
|
||||||
|
|
||||||
|
from .models import Family, Challenge
|
||||||
|
from .tables import FamilyTable, ChallengeTable
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||||
|
"""
|
||||||
|
Create family
|
||||||
|
"""
|
||||||
|
model = Family
|
||||||
|
extra_context = {"title": _('Create family')}
|
||||||
|
|
||||||
|
def get_sample_object(self):
|
||||||
|
return Family(
|
||||||
|
name="",
|
||||||
|
description="Sample family",
|
||||||
|
score=0,
|
||||||
|
rank=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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')}
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||||
|
"""
|
||||||
|
Update the information of a family.
|
||||||
|
"""
|
||||||
|
model = Family
|
||||||
|
context_object_name = "family"
|
||||||
|
extra_context = {"title": _('Update family')}
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengeListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||||
|
"""
|
||||||
|
List all challenges
|
||||||
|
"""
|
||||||
|
model = Challenge
|
||||||
|
table_class = ChallengeTable
|
||||||
|
extra_context = {"title": _('Challenges list')}
|
||||||
|
@ -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>
|
<a class="nav-link {% if request.path_info == url %}active{% endif %}" href="{{ url }}"><i class="fa fa-exchange"></i> {% trans 'Transfer' %}</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if "auth.user"|model_list_length >= 2 %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
{% url 'member:user_list' as url %}
|
{% url 'member:user_list' as url %}
|
||||||
|
@ -23,6 +23,7 @@ urlpatterns = [
|
|||||||
path('wei/', include('wei.urls')),
|
path('wei/', include('wei.urls')),
|
||||||
path('food/', include('food.urls')),
|
path('food/', include('food.urls')),
|
||||||
path('wrapped/', include('wrapped.urls')),
|
path('wrapped/', include('wrapped.urls')),
|
||||||
|
path('family/', include('family.urls')),
|
||||||
|
|
||||||
# Include Django Contrib and Core routers
|
# Include Django Contrib and Core routers
|
||||||
path('i18n/', include('django.conf.urls.i18n')),
|
path('i18n/', include('django.conf.urls.i18n')),
|
||||||
|
Reference in New Issue
Block a user