mirror of
https://gitlab.crans.org/bde/nk20
synced 2024-11-26 18:37:12 +00:00
Merge branch 'master' into logging
This commit is contained in:
commit
079e423343
@ -11,7 +11,7 @@ On supposera pour la suite que vous utiliser debian/ubuntu sur un serveur tout n
|
||||
1. Paquets nécessaires
|
||||
|
||||
$ sudo apt install nginx python3 python3-pip python3-dev uwsgi
|
||||
$ sudo apt install uwsgi-plugin-python3 python3-virtualenv git
|
||||
$ sudo apt install uwsgi-plugin-python3 python3-venv git acl
|
||||
|
||||
2. Clonage du dépot
|
||||
|
||||
@ -29,8 +29,8 @@ On supposera pour la suite que vous utiliser debian/ubuntu sur un serveur tout n
|
||||
|
||||
À la racine du projet:
|
||||
|
||||
$ virtualenv env
|
||||
$ source /env/bin/activate
|
||||
$ python3 -m venv env
|
||||
$ source env/bin/activate
|
||||
(env)$ pip3 install -r requirements.txt
|
||||
(env)$ deactivate
|
||||
|
||||
|
@ -14,6 +14,11 @@ from crispy_forms.layout import Layout
|
||||
|
||||
|
||||
class SignUpForm(UserCreationForm):
|
||||
def __init__(self,*args,**kwargs):
|
||||
super().__init__(*args,**kwargs)
|
||||
self.fields['username'].widget.attrs.pop("autofocus", None)
|
||||
self.fields['first_name'].widget.attrs.update({"autofocus":"autofocus"})
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['first_name', 'last_name', 'username', 'email']
|
||||
|
27
apps/member/hashers.py
Normal file
27
apps/member/hashers.py
Normal file
@ -0,0 +1,27 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import hashlib
|
||||
|
||||
from django.contrib.auth.hashers import PBKDF2PasswordHasher
|
||||
from django.utils.crypto import constant_time_compare
|
||||
|
||||
|
||||
class CustomNK15Hasher(PBKDF2PasswordHasher):
|
||||
"""
|
||||
Permet d'importer les mots de passe depuis la Note KFet 2015.
|
||||
Si un hash de mot de passe est de la forme :
|
||||
`custom_nk15$<NB>$<ENCODED>`
|
||||
où <NB> est un entier quelconque (symbolisant normalement un nombre d'itérations)
|
||||
et <ENCODED> le hash du mot de passe dans la Note Kfet 2015,
|
||||
alors ce hasher va vérifier le mot de passe.
|
||||
N'ayant pas la priorité (cf note_kfet/settings/base.py), le mot de passe sera
|
||||
converti automatiquement avec l'algorithme PBKDF2.
|
||||
"""
|
||||
algorithm = "custom_nk15"
|
||||
|
||||
def verify(self, password, encoded):
|
||||
if '|' in encoded:
|
||||
salt, db_hashed_pass = encoded.split('$')[2].split('|')
|
||||
return constant_time_compare(hashlib.sha256((salt + password).encode("utf-8")).hexdigest(), db_hashed_pass)
|
||||
return super().verify(password, encoded)
|
@ -114,12 +114,13 @@ class UserDetailView(LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
Affiche les informations sur un utilisateur, sa note, ses clubs...
|
||||
"""
|
||||
model = Profile
|
||||
context_object_name = "profile"
|
||||
model = User
|
||||
context_object_name = "user_object"
|
||||
template_name = "member/profile_detail.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
user = context['profile'].user
|
||||
user = context['user_object']
|
||||
history_list = \
|
||||
Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note))
|
||||
context['history_list'] = HistoryTable(history_list)
|
||||
|
@ -7,7 +7,8 @@ from polymorphic.admin import PolymorphicChildModelAdmin, \
|
||||
PolymorphicChildModelFilter, PolymorphicParentModelAdmin
|
||||
|
||||
from .models.notes import Alias, Note, NoteClub, NoteSpecial, NoteUser
|
||||
from .models.transactions import Transaction, TransactionCategory, TransactionTemplate
|
||||
from .models.transactions import Transaction, TemplateCategory, TransactionTemplate, \
|
||||
TemplateTransaction, MembershipTransaction
|
||||
|
||||
|
||||
class AliasInlines(admin.TabularInline):
|
||||
@ -97,13 +98,14 @@ class NoteUserAdmin(PolymorphicChildModelAdmin):
|
||||
|
||||
|
||||
@admin.register(Transaction)
|
||||
class TransactionAdmin(admin.ModelAdmin):
|
||||
class TransactionAdmin(PolymorphicParentModelAdmin):
|
||||
"""
|
||||
Admin customisation for Transaction
|
||||
"""
|
||||
child_models = (TemplateTransaction, MembershipTransaction)
|
||||
list_display = ('created_at', 'poly_source', 'poly_destination',
|
||||
'quantity', 'amount', 'transaction_type', 'valid')
|
||||
list_filter = ('transaction_type', 'valid')
|
||||
'quantity', 'amount', 'valid')
|
||||
list_filter = ('valid',)
|
||||
autocomplete_fields = (
|
||||
'source',
|
||||
'destination',
|
||||
@ -132,7 +134,7 @@ class TransactionAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
if obj: # user is editing an existing object
|
||||
return 'created_at', 'source', 'destination', 'quantity',\
|
||||
'amount', 'transaction_type'
|
||||
'amount'
|
||||
return []
|
||||
|
||||
|
||||
@ -141,8 +143,8 @@ class TransactionTemplateAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin customisation for TransactionTemplate
|
||||
"""
|
||||
list_display = ('name', 'poly_destination', 'amount', 'template_type')
|
||||
list_filter = ('template_type', )
|
||||
list_display = ('name', 'poly_destination', 'amount', 'category', 'display', )
|
||||
list_filter = ('category', 'display')
|
||||
autocomplete_fields = ('destination', )
|
||||
|
||||
def poly_destination(self, obj):
|
||||
@ -154,8 +156,8 @@ class TransactionTemplateAdmin(admin.ModelAdmin):
|
||||
poly_destination.short_description = _('destination')
|
||||
|
||||
|
||||
@admin.register(TransactionCategory)
|
||||
class TransactionCategoryAdmin(admin.ModelAdmin):
|
||||
@admin.register(TemplateCategory)
|
||||
class TemplateCategoryAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin customisation for TransactionTemplate
|
||||
"""
|
||||
|
@ -162,56 +162,56 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "note.transactioncategory",
|
||||
"model": "note.templatecategory",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"name": "Soft"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "note.transactioncategory",
|
||||
"model": "note.templatecategory",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"name": "Pulls"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "note.transactioncategory",
|
||||
"model": "note.templatecategory",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"name": "Gala"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "note.transactioncategory",
|
||||
"model": "note.templatecategory",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"name": "Clubs"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "note.transactioncategory",
|
||||
"model": "note.templatecategory",
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"name": "Bouffe"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "note.transactioncategory",
|
||||
"model": "note.templatecategory",
|
||||
"pk": 6,
|
||||
"fields": {
|
||||
"name": "BDA"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "note.transactioncategory",
|
||||
"model": "note.templatecategory",
|
||||
"pk": 7,
|
||||
"fields": {
|
||||
"name": "Autre"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "note.transactioncategory",
|
||||
"model": "note.templatecategory",
|
||||
"pk": 8,
|
||||
"fields": {
|
||||
"name": "Alcool"
|
||||
|
@ -4,7 +4,7 @@
|
||||
from dal import autocomplete
|
||||
from django import forms
|
||||
|
||||
from .models import Transaction, TransactionTemplate
|
||||
from .models import Transaction, TransactionTemplate, TemplateTransaction
|
||||
|
||||
|
||||
class TransactionTemplateForm(forms.ModelForm):
|
||||
@ -31,8 +31,6 @@ class TransactionTemplateForm(forms.ModelForm):
|
||||
|
||||
class TransactionForm(forms.ModelForm):
|
||||
def save(self, commit=True):
|
||||
self.instance.transaction_type = 'transfert'
|
||||
|
||||
super().save(commit)
|
||||
|
||||
class Meta:
|
||||
@ -71,12 +69,13 @@ class ConsoForm(forms.ModelForm):
|
||||
name=self.data['button']).get()
|
||||
self.instance.destination = button.destination
|
||||
self.instance.amount = button.amount
|
||||
self.instance.transaction_type = 'bouton'
|
||||
self.instance.reason = button.name
|
||||
self.instance.reason = '{} ({})'.format(button.name, button.category)
|
||||
self.instance.name = button.name
|
||||
self.instance.category = button.category
|
||||
super().save(commit)
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
model = TemplateTransaction
|
||||
fields = ('source', )
|
||||
|
||||
# Le champ d'utilisateur est remplacé par un champ d'auto-complétion.
|
||||
|
@ -3,11 +3,12 @@
|
||||
|
||||
from .notes import Alias, Note, NoteClub, NoteSpecial, NoteUser
|
||||
from .transactions import MembershipTransaction, Transaction, \
|
||||
TransactionCategory, TransactionTemplate
|
||||
TemplateCategory, TransactionTemplate, TemplateTransaction
|
||||
|
||||
__all__ = [
|
||||
# Notes
|
||||
'Alias', 'Note', 'NoteClub', 'NoteSpecial', 'NoteUser',
|
||||
# Transactions
|
||||
'MembershipTransaction', 'Transaction', 'TransactionCategory', 'TransactionTemplate',
|
||||
'MembershipTransaction', 'Transaction', 'TemplateCategory', 'TransactionTemplate',
|
||||
'TemplateTransaction',
|
||||
]
|
||||
|
@ -27,6 +27,12 @@ class Note(PolymorphicModel):
|
||||
help_text=_('in centimes, money credited for this instance'),
|
||||
default=0,
|
||||
)
|
||||
last_negative= models.DateTimeField(
|
||||
verbose_name=_('last negative date'),
|
||||
help_text=_('last time the balance was negative'),
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
_('active'),
|
||||
default=True,
|
||||
@ -64,7 +70,8 @@ class Note(PolymorphicModel):
|
||||
if aliases.exists():
|
||||
# Alias exists, so check if it is linked to this note
|
||||
if aliases.first().note != self:
|
||||
raise ValidationError(_('This alias is already taken.'))
|
||||
raise ValidationError(_('This alias is already taken.'),
|
||||
code="same_alias")
|
||||
|
||||
# Save note
|
||||
super().save(*args, **kwargs)
|
||||
@ -87,7 +94,8 @@ class Note(PolymorphicModel):
|
||||
if aliases.exists():
|
||||
# Alias exists, so check if it is linked to this note
|
||||
if aliases.first().note != self:
|
||||
raise ValidationError(_('This alias is already taken.'))
|
||||
raise ValidationError(_('This alias is already taken.'),
|
||||
code="same_alias",)
|
||||
else:
|
||||
# Alias does not exist yet, so check if it can exist
|
||||
a = Alias(name=str(self))
|
||||
@ -222,16 +230,19 @@ class Alias(models.Model):
|
||||
def clean(self):
|
||||
normalized_name = Alias.normalize(self.name)
|
||||
if len(normalized_name) >= 255:
|
||||
raise ValidationError(_('Alias too long.'))
|
||||
raise ValidationError(_('Alias is too long.'),
|
||||
code='alias_too_long')
|
||||
try:
|
||||
if self != Alias.objects.get(normalized_name=normalized_name):
|
||||
raise ValidationError(
|
||||
_('An alias with a similar name '
|
||||
'already exists.'))
|
||||
sim_alias = Alias.objects.get(normalized_name=normalized_name)
|
||||
if self != sim_alias:
|
||||
raise ValidationError(_('An alias with a similar name already exists:'),
|
||||
code="same_alias"
|
||||
)
|
||||
except Alias.DoesNotExist:
|
||||
pass
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
if self.name == str(self.note):
|
||||
raise ValidationError(_("You can't delete your main alias."))
|
||||
raise ValidationError(_("You can't delete your main alias."),
|
||||
code="cant_delete_main_alias")
|
||||
return super().delete(using, keep_parents)
|
||||
|
@ -5,6 +5,7 @@ from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.urls import reverse
|
||||
from polymorphic.models import PolymorphicModel
|
||||
|
||||
from .notes import Note, NoteClub
|
||||
|
||||
@ -13,7 +14,7 @@ Defines transactions
|
||||
"""
|
||||
|
||||
|
||||
class TransactionCategory(models.Model):
|
||||
class TemplateCategory(models.Model):
|
||||
"""
|
||||
Defined a recurrent transaction category
|
||||
|
||||
@ -43,6 +44,7 @@ class TransactionTemplate(models.Model):
|
||||
verbose_name=_('name'),
|
||||
max_length=255,
|
||||
unique=True,
|
||||
error_messages={'unique':_("A template with this name already exist")},
|
||||
)
|
||||
destination = models.ForeignKey(
|
||||
NoteClub,
|
||||
@ -54,12 +56,19 @@ class TransactionTemplate(models.Model):
|
||||
verbose_name=_('amount'),
|
||||
help_text=_('in centimes'),
|
||||
)
|
||||
template_type = models.ForeignKey(
|
||||
TransactionCategory,
|
||||
category = models.ForeignKey(
|
||||
TemplateCategory,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=_('type'),
|
||||
max_length=31,
|
||||
)
|
||||
display = models.BooleanField(
|
||||
default = True,
|
||||
)
|
||||
description = models.CharField(
|
||||
verbose_name=_('description'),
|
||||
max_length=255,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("transaction template")
|
||||
@ -69,7 +78,7 @@ class TransactionTemplate(models.Model):
|
||||
return reverse('note:template_update', args=(self.pk, ))
|
||||
|
||||
|
||||
class Transaction(models.Model):
|
||||
class Transaction(PolymorphicModel):
|
||||
"""
|
||||
General transaction between two :model:`note.Note`
|
||||
|
||||
@ -100,10 +109,6 @@ class Transaction(models.Model):
|
||||
default=1,
|
||||
)
|
||||
amount = models.PositiveIntegerField(verbose_name=_('amount'), )
|
||||
transaction_type = models.CharField(
|
||||
verbose_name=_('type'),
|
||||
max_length=31,
|
||||
)
|
||||
reason = models.CharField(
|
||||
verbose_name=_('reason'),
|
||||
max_length=255,
|
||||
@ -144,6 +149,22 @@ class Transaction(models.Model):
|
||||
return self.amount * self.quantity
|
||||
|
||||
|
||||
class TemplateTransaction(Transaction):
|
||||
"""
|
||||
Special type of :model:`note.Transaction` associated to a :model:`note.TransactionTemplate`.
|
||||
|
||||
"""
|
||||
|
||||
template = models.ForeignKey(
|
||||
TransactionTemplate,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
category = models.ForeignKey(
|
||||
TemplateCategory,
|
||||
on_delete=models.PROTECT,
|
||||
)
|
||||
|
||||
class MembershipTransaction(Transaction):
|
||||
"""
|
||||
Special type of :model:`note.Transaction` associated to a :model:`member.Membership`.
|
||||
|
@ -8,7 +8,7 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import CreateView, ListView, UpdateView
|
||||
|
||||
from .models import Transaction, TransactionTemplate, Alias
|
||||
from .models import Transaction, TransactionTemplate, Alias, TemplateTransaction
|
||||
from .forms import TransactionForm, TransactionTemplateForm, ConsoForm
|
||||
|
||||
|
||||
@ -129,7 +129,7 @@ class ConsoView(LoginRequiredMixin, CreateView):
|
||||
"""
|
||||
Consume
|
||||
"""
|
||||
model = Transaction
|
||||
model = TemplateTransaction
|
||||
template_name = "note/conso_form.html"
|
||||
form_class = ConsoForm
|
||||
|
||||
@ -138,8 +138,8 @@ class ConsoView(LoginRequiredMixin, CreateView):
|
||||
Add some context variables in template such as page title
|
||||
"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['transaction_templates'] = TransactionTemplate.objects.all() \
|
||||
.order_by('template_type')
|
||||
context['transaction_templates'] = TransactionTemplate.objects.filter(display=True) \
|
||||
.order_by('category')
|
||||
context['title'] = _("Consommations")
|
||||
|
||||
# select2 compatibility
|
||||
@ -152,3 +152,4 @@ class ConsoView(LoginRequiredMixin, CreateView):
|
||||
When clicking a button, reload the same page
|
||||
"""
|
||||
return reverse('note:consos')
|
||||
|
||||
|
@ -118,6 +118,12 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
},
|
||||
]
|
||||
|
||||
# Use our custom hasher in order to import NK15 passwords
|
||||
PASSWORD_HASHERS = [
|
||||
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
|
||||
'member.hashers.CustomNK15Hasher',
|
||||
]
|
||||
|
||||
# Django Guardian object permissions
|
||||
|
||||
AUTHENTICATION_BACKENDS = (
|
||||
|
@ -59,8 +59,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
<nav class="navbar navbar-expand-md navbar-light bg-light fixed-navbar shadow-sm">
|
||||
<a class="navbar-brand" href="/">{{ request.site.name }}</a>
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse"
|
||||
data-target="#navbarNavAltMarkup"
|
||||
aria-controls="navbarNavAltMarkup" aria-expanded="false"
|
||||
data-target="#navbarNavDropdown"
|
||||
aria-controls="navbarNavDropdown" aria-expanded="false"
|
||||
aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
@ -87,7 +87,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-right"
|
||||
aria-labelledby="navbarDropdownMenuLink">
|
||||
<a class="dropdown-item" href="{% url 'member:user_detail' pk=user.profile.pk %}">
|
||||
<a class="dropdown-item" href="{% url 'member:user_detail' pk=user.pk %}">
|
||||
<i class="fa fa-user"></i> Mon compte
|
||||
</a>
|
||||
<a class="dropdown-item" href="{% url 'logout' %}">
|
||||
|
@ -5,14 +5,14 @@
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-3 mb-4">
|
||||
<div class="card bg-light shadow">
|
||||
<img src="{{ object.note.display_image.url }}" class="card-img-top" alt="">
|
||||
<img src="{{ object.note.display_image }}" class="card-img-top" alt="">
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-xl-6">{% trans 'name'|capfirst %}, {% trans 'first name' %}</dt>
|
||||
<dd class="col-xl-6">{{ object.user.last_name }} {{ object.user.first_name }}</dd>
|
||||
<dd class="col-xl-6">{{ object.last_name }} {{ object.first_name }}</dd>
|
||||
|
||||
<dt class="col-xl-6">{% trans 'username'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ object.user.username }}</dd>
|
||||
<dd class="col-xl-6">{{ user.username }}</dd>
|
||||
|
||||
<dt class="col-xl-6">{% trans 'password'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">
|
||||
@ -22,19 +22,19 @@
|
||||
</dd>
|
||||
|
||||
<dt class="col-xl-6">{% trans 'section'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ object.section }}</dd>
|
||||
<dd class="col-xl-6">{{ object.profile.section }}</dd>
|
||||
|
||||
<dt class="col-xl-6">{% trans 'address'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ object.address }}</dd>
|
||||
<dd class="col-xl-6">{{ object.profile.address }}</dd>
|
||||
|
||||
<dt class="col-xl-6">{% trans 'balance'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ object.user.note.balance | pretty_money }}</dd>
|
||||
<dd class="col-xl-6">{{ object.note.balance | pretty_money }}</dd>
|
||||
|
||||
<dt class="col-xl-6">{% trans 'aliases'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ object.user.note.alias_set.all|join:", " }}</dd>
|
||||
<dd class="col-xl-6">{{ object.note.alias_set.all|join:", " }}</dd>
|
||||
</dl>
|
||||
|
||||
{% if object.user.pk == user.pk %}
|
||||
{% if object.pk == user.pk %}
|
||||
<a class="small" href="{% url 'member:auth_token' %}">{% trans 'Manage auth token' %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
@ -10,7 +10,7 @@
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
{{ profile_form|crispy }}
|
||||
<button class="btn btn-link" type="submit">
|
||||
<button class="btn btn-success" type="submit">
|
||||
{% trans "Sign Up" %}
|
||||
</button>
|
||||
</form>
|
||||
|
@ -7,7 +7,7 @@
|
||||
|
||||
{% block content %}
|
||||
{# Regroup buttons under categories #}
|
||||
{% regroup transaction_templates by template_type as template_types %}
|
||||
{% regroup transaction_templates by category as categories %}
|
||||
|
||||
<form method="post" onsubmit="window.onbeforeunload=null">
|
||||
{% csrf_token %}
|
||||
@ -44,10 +44,10 @@
|
||||
{# Tabs for button categories #}
|
||||
<div class="card-header">
|
||||
<ul class="nav nav-tabs nav-fill card-header-tabs">
|
||||
{% for template_type in template_types %}
|
||||
{% for category in categories %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-toggle="tab" href="#{{ template_type.grouper|slugify }}">
|
||||
{{ template_type.grouper }}
|
||||
<a class="nav-link" data-toggle="tab" href="#{{ category.grouper|slugify }}">
|
||||
{{ category.grouper }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
@ -57,10 +57,10 @@
|
||||
{# Tabs content #}
|
||||
<div class="card-body">
|
||||
<div class="tab-content">
|
||||
{% for template_type in template_types %}
|
||||
<div class="tab-pane" id="{{ template_type.grouper|slugify }}">
|
||||
{% for category in categories %}
|
||||
<div class="tab-pane" id="{{ category.grouper|slugify }}">
|
||||
<div class="d-inline-flex flex-wrap justify-content-center">
|
||||
{% for button in template_type.list %}
|
||||
{% for button in category.list %}
|
||||
<button class="btn btn-outline-dark rounded-0 flex-fill"
|
||||
name="button" value="{{ button.name }}">
|
||||
{{ button.name }} ({{ button.amount | pretty_money }})
|
||||
|
Loading…
Reference in New Issue
Block a user