Merge remote-tracking branch 'origin/master' into cas

This commit is contained in:
Yohann D'ANELLO 2020-02-27 13:59:52 +01:00
commit 94d327821a
16 changed files with 145 additions and 71 deletions

View File

@ -11,7 +11,7 @@ On supposera pour la suite que vous utiliser debian/ubuntu sur un serveur tout n
1. Paquets nécessaires 1. Paquets nécessaires
$ sudo apt install nginx python3 python3-pip python3-dev uwsgi $ 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 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: À la racine du projet:
$ virtualenv env $ python3 -m venv env
$ source /env/bin/activate $ source env/bin/activate
(env)$ pip3 install -r requirements.txt (env)$ pip3 install -r requirements.txt
(env)$ deactivate (env)$ deactivate

View File

@ -14,6 +14,11 @@ from crispy_forms.layout import Layout
class SignUpForm(UserCreationForm): 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: class Meta:
model = User model = User
fields = ['first_name', 'last_name', 'username', 'email'] fields = ['first_name', 'last_name', 'username', 'email']

27
apps/member/hashers.py Normal file
View 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>`
<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)

View File

@ -114,12 +114,13 @@ class UserDetailView(LoginRequiredMixin, DetailView):
""" """
Affiche les informations sur un utilisateur, sa note, ses clubs... Affiche les informations sur un utilisateur, sa note, ses clubs...
""" """
model = Profile model = User
context_object_name = "profile" context_object_name = "user_object"
template_name = "member/profile_detail.html"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
user = context['profile'].user user = context['user_object']
history_list = \ history_list = \
Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note)) Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note))
context['history_list'] = HistoryTable(history_list) context['history_list'] = HistoryTable(history_list)

View File

@ -7,7 +7,8 @@ from polymorphic.admin import PolymorphicChildModelAdmin, \
PolymorphicChildModelFilter, PolymorphicParentModelAdmin PolymorphicChildModelFilter, PolymorphicParentModelAdmin
from .models.notes import Alias, Note, NoteClub, NoteSpecial, NoteUser 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): class AliasInlines(admin.TabularInline):
@ -97,13 +98,14 @@ class NoteUserAdmin(PolymorphicChildModelAdmin):
@admin.register(Transaction) @admin.register(Transaction)
class TransactionAdmin(admin.ModelAdmin): class TransactionAdmin(PolymorphicParentModelAdmin):
""" """
Admin customisation for Transaction Admin customisation for Transaction
""" """
child_models = (TemplateTransaction, MembershipTransaction)
list_display = ('created_at', 'poly_source', 'poly_destination', list_display = ('created_at', 'poly_source', 'poly_destination',
'quantity', 'amount', 'transaction_type', 'valid') 'quantity', 'amount', 'valid')
list_filter = ('transaction_type', 'valid') list_filter = ('valid',)
autocomplete_fields = ( autocomplete_fields = (
'source', 'source',
'destination', 'destination',
@ -132,7 +134,7 @@ class TransactionAdmin(admin.ModelAdmin):
""" """
if obj: # user is editing an existing object if obj: # user is editing an existing object
return 'created_at', 'source', 'destination', 'quantity',\ return 'created_at', 'source', 'destination', 'quantity',\
'amount', 'transaction_type' 'amount'
return [] return []
@ -141,8 +143,8 @@ class TransactionTemplateAdmin(admin.ModelAdmin):
""" """
Admin customisation for TransactionTemplate Admin customisation for TransactionTemplate
""" """
list_display = ('name', 'poly_destination', 'amount', 'template_type') list_display = ('name', 'poly_destination', 'amount', 'category', 'display', )
list_filter = ('template_type', ) list_filter = ('category', 'display')
autocomplete_fields = ('destination', ) autocomplete_fields = ('destination', )
def poly_destination(self, obj): def poly_destination(self, obj):
@ -154,8 +156,8 @@ class TransactionTemplateAdmin(admin.ModelAdmin):
poly_destination.short_description = _('destination') poly_destination.short_description = _('destination')
@admin.register(TransactionCategory) @admin.register(TemplateCategory)
class TransactionCategoryAdmin(admin.ModelAdmin): class TemplateCategoryAdmin(admin.ModelAdmin):
""" """
Admin customisation for TransactionTemplate Admin customisation for TransactionTemplate
""" """

View File

@ -162,56 +162,56 @@
} }
}, },
{ {
"model": "note.transactioncategory", "model": "note.templatecategory",
"pk": 1, "pk": 1,
"fields": { "fields": {
"name": "Soft" "name": "Soft"
} }
}, },
{ {
"model": "note.transactioncategory", "model": "note.templatecategory",
"pk": 2, "pk": 2,
"fields": { "fields": {
"name": "Pulls" "name": "Pulls"
} }
}, },
{ {
"model": "note.transactioncategory", "model": "note.templatecategory",
"pk": 3, "pk": 3,
"fields": { "fields": {
"name": "Gala" "name": "Gala"
} }
}, },
{ {
"model": "note.transactioncategory", "model": "note.templatecategory",
"pk": 4, "pk": 4,
"fields": { "fields": {
"name": "Clubs" "name": "Clubs"
} }
}, },
{ {
"model": "note.transactioncategory", "model": "note.templatecategory",
"pk": 5, "pk": 5,
"fields": { "fields": {
"name": "Bouffe" "name": "Bouffe"
} }
}, },
{ {
"model": "note.transactioncategory", "model": "note.templatecategory",
"pk": 6, "pk": 6,
"fields": { "fields": {
"name": "BDA" "name": "BDA"
} }
}, },
{ {
"model": "note.transactioncategory", "model": "note.templatecategory",
"pk": 7, "pk": 7,
"fields": { "fields": {
"name": "Autre" "name": "Autre"
} }
}, },
{ {
"model": "note.transactioncategory", "model": "note.templatecategory",
"pk": 8, "pk": 8,
"fields": { "fields": {
"name": "Alcool" "name": "Alcool"

View File

@ -4,7 +4,7 @@
from dal import autocomplete from dal import autocomplete
from django import forms from django import forms
from .models import Transaction, TransactionTemplate from .models import Transaction, TransactionTemplate, TemplateTransaction
class TransactionTemplateForm(forms.ModelForm): class TransactionTemplateForm(forms.ModelForm):
@ -31,8 +31,6 @@ class TransactionTemplateForm(forms.ModelForm):
class TransactionForm(forms.ModelForm): class TransactionForm(forms.ModelForm):
def save(self, commit=True): def save(self, commit=True):
self.instance.transaction_type = 'transfert'
super().save(commit) super().save(commit)
class Meta: class Meta:
@ -71,12 +69,13 @@ class ConsoForm(forms.ModelForm):
name=self.data['button']).get() name=self.data['button']).get()
self.instance.destination = button.destination self.instance.destination = button.destination
self.instance.amount = button.amount self.instance.amount = button.amount
self.instance.transaction_type = 'bouton' self.instance.reason = '{} ({})'.format(button.name, button.category)
self.instance.reason = button.name self.instance.name = button.name
self.instance.category = button.category
super().save(commit) super().save(commit)
class Meta: class Meta:
model = Transaction model = TemplateTransaction
fields = ('source', ) fields = ('source', )
# Le champ d'utilisateur est remplacé par un champ d'auto-complétion. # Le champ d'utilisateur est remplacé par un champ d'auto-complétion.

View File

@ -3,11 +3,12 @@
from .notes import Alias, Note, NoteClub, NoteSpecial, NoteUser from .notes import Alias, Note, NoteClub, NoteSpecial, NoteUser
from .transactions import MembershipTransaction, Transaction, \ from .transactions import MembershipTransaction, Transaction, \
TransactionCategory, TransactionTemplate TemplateCategory, TransactionTemplate, TemplateTransaction
__all__ = [ __all__ = [
# Notes # Notes
'Alias', 'Note', 'NoteClub', 'NoteSpecial', 'NoteUser', 'Alias', 'Note', 'NoteClub', 'NoteSpecial', 'NoteUser',
# Transactions # Transactions
'MembershipTransaction', 'Transaction', 'TransactionCategory', 'TransactionTemplate', 'MembershipTransaction', 'Transaction', 'TemplateCategory', 'TransactionTemplate',
'TemplateTransaction',
] ]

View File

@ -27,6 +27,12 @@ class Note(PolymorphicModel):
help_text=_('in centimes, money credited for this instance'), help_text=_('in centimes, money credited for this instance'),
default=0, 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( is_active = models.BooleanField(
_('active'), _('active'),
default=True, default=True,
@ -64,7 +70,8 @@ class Note(PolymorphicModel):
if aliases.exists(): if aliases.exists():
# Alias exists, so check if it is linked to this note # Alias exists, so check if it is linked to this note
if aliases.first().note != self: if aliases.first().note != self:
raise ValidationError(_('This alias is already taken.')) raise ValidationError(_('This alias is already taken.'),
code="same_alias")
# Save note # Save note
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -87,7 +94,8 @@ class Note(PolymorphicModel):
if aliases.exists(): if aliases.exists():
# Alias exists, so check if it is linked to this note # Alias exists, so check if it is linked to this note
if aliases.first().note != self: if aliases.first().note != self:
raise ValidationError(_('This alias is already taken.')) raise ValidationError(_('This alias is already taken.'),
code="same_alias",)
else: else:
# Alias does not exist yet, so check if it can exist # Alias does not exist yet, so check if it can exist
a = Alias(name=str(self)) a = Alias(name=str(self))
@ -222,16 +230,19 @@ class Alias(models.Model):
def clean(self): def clean(self):
normalized_name = Alias.normalize(self.name) normalized_name = Alias.normalize(self.name)
if len(normalized_name) >= 255: if len(normalized_name) >= 255:
raise ValidationError(_('Alias too long.')) raise ValidationError(_('Alias is too long.'),
code='alias_too_long')
try: try:
if self != Alias.objects.get(normalized_name=normalized_name): sim_alias = Alias.objects.get(normalized_name=normalized_name)
raise ValidationError( if self != sim_alias:
_('An alias with a similar name ' raise ValidationError(_('An alias with a similar name already exists:'),
'already exists.')) code="same_alias"
)
except Alias.DoesNotExist: except Alias.DoesNotExist:
pass pass
def delete(self, using=None, keep_parents=False): def delete(self, using=None, keep_parents=False):
if self.name == str(self.note): 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) return super().delete(using, keep_parents)

View File

@ -5,6 +5,7 @@ from django.db import models
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.urls import reverse from django.urls import reverse
from polymorphic.models import PolymorphicModel
from .notes import Note, NoteClub from .notes import Note, NoteClub
@ -13,7 +14,7 @@ Defines transactions
""" """
class TransactionCategory(models.Model): class TemplateCategory(models.Model):
""" """
Defined a recurrent transaction category Defined a recurrent transaction category
@ -43,6 +44,7 @@ class TransactionTemplate(models.Model):
verbose_name=_('name'), verbose_name=_('name'),
max_length=255, max_length=255,
unique=True, unique=True,
error_messages={'unique':_("A template with this name already exist")},
) )
destination = models.ForeignKey( destination = models.ForeignKey(
NoteClub, NoteClub,
@ -54,12 +56,19 @@ class TransactionTemplate(models.Model):
verbose_name=_('amount'), verbose_name=_('amount'),
help_text=_('in centimes'), help_text=_('in centimes'),
) )
template_type = models.ForeignKey( category = models.ForeignKey(
TransactionCategory, TemplateCategory,
on_delete=models.PROTECT, on_delete=models.PROTECT,
verbose_name=_('type'), verbose_name=_('type'),
max_length=31, max_length=31,
) )
display = models.BooleanField(
default = True,
)
description = models.CharField(
verbose_name=_('description'),
max_length=255,
)
class Meta: class Meta:
verbose_name = _("transaction template") verbose_name = _("transaction template")
@ -69,7 +78,7 @@ class TransactionTemplate(models.Model):
return reverse('note:template_update', args=(self.pk, )) return reverse('note:template_update', args=(self.pk, ))
class Transaction(models.Model): class Transaction(PolymorphicModel):
""" """
General transaction between two :model:`note.Note` General transaction between two :model:`note.Note`
@ -100,10 +109,6 @@ class Transaction(models.Model):
default=1, default=1,
) )
amount = models.PositiveIntegerField(verbose_name=_('amount'), ) amount = models.PositiveIntegerField(verbose_name=_('amount'), )
transaction_type = models.CharField(
verbose_name=_('type'),
max_length=31,
)
reason = models.CharField( reason = models.CharField(
verbose_name=_('reason'), verbose_name=_('reason'),
max_length=255, max_length=255,
@ -144,6 +149,22 @@ class Transaction(models.Model):
return self.amount * self.quantity 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): class MembershipTransaction(Transaction):
""" """
Special type of :model:`note.Transaction` associated to a :model:`member.Membership`. Special type of :model:`note.Transaction` associated to a :model:`member.Membership`.

View File

@ -8,7 +8,7 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, ListView, UpdateView 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 from .forms import TransactionForm, TransactionTemplateForm, ConsoForm
@ -129,7 +129,7 @@ class ConsoView(LoginRequiredMixin, CreateView):
""" """
Consume Consume
""" """
model = Transaction model = TemplateTransaction
template_name = "note/conso_form.html" template_name = "note/conso_form.html"
form_class = ConsoForm form_class = ConsoForm
@ -138,8 +138,8 @@ class ConsoView(LoginRequiredMixin, CreateView):
Add some context variables in template such as page title Add some context variables in template such as page title
""" """
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['transaction_templates'] = TransactionTemplate.objects.all() \ context['transaction_templates'] = TransactionTemplate.objects.filter(display=True) \
.order_by('template_type') .order_by('category')
context['title'] = _("Consommations") context['title'] = _("Consommations")
# select2 compatibility # select2 compatibility
@ -152,3 +152,4 @@ class ConsoView(LoginRequiredMixin, CreateView):
When clicking a button, reload the same page When clicking a button, reload the same page
""" """
return reverse('note:consos') return reverse('note:consos')

View File

@ -121,6 +121,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 # Django Guardian object permissions
AUTHENTICATION_BACKENDS = ( AUTHENTICATION_BACKENDS = (

View File

@ -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"> <nav class="navbar navbar-expand-md navbar-light bg-light fixed-navbar shadow-sm">
<a class="navbar-brand" href="/">{{ request.site.name }}</a> <a class="navbar-brand" href="/">{{ request.site.name }}</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" <button class="navbar-toggler" type="button" data-toggle="collapse"
data-target="#navbarNavAltMarkup" data-target="#navbarNavDropdown"
aria-controls="navbarNavAltMarkup" aria-expanded="false" aria-controls="navbarNavDropdown" aria-expanded="false"
aria-label="Toggle navigation"> aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
@ -87,7 +87,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
</a> </a>
<div class="dropdown-menu dropdown-menu-right" <div class="dropdown-menu dropdown-menu-right"
aria-labelledby="navbarDropdownMenuLink"> 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 <i class="fa fa-user"></i> Mon compte
</a> </a>
<a class="dropdown-item" href="{% url 'logout' %}"> <a class="dropdown-item" href="{% url 'logout' %}">

View File

@ -5,14 +5,14 @@
<div class="row mt-4"> <div class="row mt-4">
<div class="col-md-3 mb-4"> <div class="col-md-3 mb-4">
<div class="card bg-light shadow"> <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"> <div class="card-body">
<dl class="row"> <dl class="row">
<dt class="col-xl-6">{% trans 'name'|capfirst %}, {% trans 'first name' %}</dt> <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> <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> <dt class="col-xl-6">{% trans 'password'|capfirst %}</dt>
<dd class="col-xl-6"> <dd class="col-xl-6">
@ -22,19 +22,19 @@
</dd> </dd>
<dt class="col-xl-6">{% trans 'section'|capfirst %}</dt> <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> <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> <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> <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> </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> <a class="small" href="{% url 'member:auth_token' %}">{% trans 'Manage auth token' %}</a>
{% endif %} {% endif %}
</div> </div>

View File

@ -10,7 +10,7 @@
{% csrf_token %} {% csrf_token %}
{{ form|crispy }} {{ form|crispy }}
{{ profile_form|crispy }} {{ profile_form|crispy }}
<button class="btn btn-link" type="submit"> <button class="btn btn-success" type="submit">
{% trans "Sign Up" %} {% trans "Sign Up" %}
</button> </button>
</form> </form>

View File

@ -7,7 +7,7 @@
{% block content %} {% block content %}
{# Regroup buttons under categories #} {# 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"> <form method="post" onsubmit="window.onbeforeunload=null">
{% csrf_token %} {% csrf_token %}
@ -44,10 +44,10 @@
{# Tabs for button categories #} {# Tabs for button categories #}
<div class="card-header"> <div class="card-header">
<ul class="nav nav-tabs nav-fill card-header-tabs"> <ul class="nav nav-tabs nav-fill card-header-tabs">
{% for template_type in template_types %} {% for category in categories %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" data-toggle="tab" href="#{{ template_type.grouper|slugify }}"> <a class="nav-link" data-toggle="tab" href="#{{ category.grouper|slugify }}">
{{ template_type.grouper }} {{ category.grouper }}
</a> </a>
</li> </li>
{% endfor %} {% endfor %}
@ -57,10 +57,10 @@
{# Tabs content #} {# Tabs content #}
<div class="card-body"> <div class="card-body">
<div class="tab-content"> <div class="tab-content">
{% for template_type in template_types %} {% for category in categories %}
<div class="tab-pane" id="{{ template_type.grouper|slugify }}"> <div class="tab-pane" id="{{ category.grouper|slugify }}">
<div class="d-inline-flex flex-wrap justify-content-center"> <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" <button class="btn btn-outline-dark rounded-0 flex-fill"
name="button" value="{{ button.name }}"> name="button" value="{{ button.name }}">
{{ button.name }} ({{ button.amount | pretty_money }}) {{ button.name }} ({{ button.amount | pretty_money }})