Use custom inputs for date picker and amounts

This commit is contained in:
Yohann D'ANELLO 2020-03-27 13:50:02 +01:00
parent 45b14ed1bd
commit f81e2b5b5b
16 changed files with 518 additions and 62 deletions

View File

@ -2,11 +2,15 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from django import forms
from activity.models import Activity
from note_kfet.inputs import DateTimePickerInput
class ActivityForm(forms.ModelForm):
class Meta:
model = Activity
fields = '__all__'
widgets = {
"date_start": DateTimePickerInput(),
"date_end": DateTimePickerInput(),
}

View File

@ -1,6 +1,7 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import CreateView, DetailView, UpdateView, TemplateView
from django.utils.translation import gettext_lazy as _
from django_tables2.views import SingleTableView
@ -9,12 +10,12 @@ from .forms import ActivityForm
from .models import Activity
class ActivityCreateView(CreateView):
class ActivityCreateView(LoginRequiredMixin, CreateView):
model = Activity
form_class = ActivityForm
class ActivityListView(SingleTableView):
class ActivityListView(LoginRequiredMixin, SingleTableView):
model = Activity
def get_context_data(self, **kwargs):
@ -25,14 +26,14 @@ class ActivityListView(SingleTableView):
return ctx
class ActivityDetailView(DetailView):
class ActivityDetailView(LoginRequiredMixin, DetailView):
model = Activity
class ActivityUpdateView(UpdateView):
class ActivityUpdateView(LoginRequiredMixin, UpdateView):
model = Activity
form_class = ActivityForm
class ActivityEntryView(TemplateView):
class ActivityEntryView(LoginRequiredMixin, TemplateView):
pass

View File

@ -18,10 +18,5 @@ def pretty_money(value):
)
def cents_to_euros(value):
return "{:.02f}".format(value / 100) if value else ""
register = template.Library()
register.filter('pretty_money', pretty_money)
register.filter('cents_to_euros', cents_to_euros)

View File

@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, UpdateView
from django_tables2 import SingleTableView
from django.urls import reverse_lazy
from note_kfet.inputs import AmountInput
from permission.backends import PermissionBackend
from .forms import TransactionTemplateForm
@ -40,6 +41,7 @@ class TransactionCreateView(LoginRequiredMixin, SingleTableView):
"""
context = super().get_context_data(**kwargs)
context['title'] = _('Transfer money')
context['amount_widget'] = AmountInput(attrs={"id": "amount"})
context['polymorphic_ctype'] = ContentType.objects.get_for_model(Transaction).pk
context['special_polymorphic_ctype'] = ContentType.objects.get_for_model(SpecialTransaction).pk
context['special_types'] = NoteSpecial.objects.order_by("special_type").all()

View File

@ -7,6 +7,7 @@ from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
from django import forms
from django.utils.translation import gettext_lazy as _
from note_kfet.inputs import DatePickerInput, AmountInput
from .models import Invoice, Product, Remittance, SpecialTransactionProxy
@ -19,7 +20,7 @@ class InvoiceForm(forms.ModelForm):
# Django forms don't support date fields. We have to add it manually
date = forms.DateField(
initial=datetime.date.today,
widget=forms.TextInput(attrs={'type': 'date'})
widget=DatePickerInput()
)
def clean_date(self):
@ -30,12 +31,21 @@ class InvoiceForm(forms.ModelForm):
exclude = ('bde', )
class ProductForm(forms.ModelForm):
class Meta:
model = Product
fields = '__all__'
widgets = {
"amount": AmountInput()
}
# Add a subform per product in the invoice form, and manage correctly the link between the invoice and
# its products. The FormSet will search automatically the ForeignKey in the Product model.
ProductFormSet = forms.inlineformset_factory(
Invoice,
Product,
fields='__all__',
form=ProductForm,
extra=1,
)

View File

@ -50,18 +50,8 @@ class InvoiceCreateView(LoginRequiredMixin, CreateView):
def form_valid(self, form):
ret = super().form_valid(form)
kwargs = {}
# The user type amounts in cents. We convert it in euros.
for key in self.request.POST:
value = self.request.POST[key]
if key.endswith("amount") and value:
kwargs[key] = str(int(100 * float(value)))
elif value:
kwargs[key] = value
# For each product, we save it
formset = ProductFormSet(kwargs, instance=form.instance)
formset = ProductFormSet(self.request.POST, instance=form.instance)
if formset.is_valid():
for f in formset:
# We don't save the product if the designation is not entered, ie. if the line is empty
@ -112,16 +102,7 @@ class InvoiceUpdateView(LoginRequiredMixin, UpdateView):
def form_valid(self, form):
ret = super().form_valid(form)
kwargs = {}
# The user type amounts in cents. We convert it in euros.
for key in self.request.POST:
value = self.request.POST[key]
if key.endswith("amount") and value:
kwargs[key] = str(int(100 * float(value)))
elif value:
kwargs[key] = value
formset = ProductFormSet(kwargs, instance=form.instance)
formset = ProductFormSet(self.request.POST, instance=form.instance)
saved = []
# For each product, we save it
if formset.is_valid():

280
note_kfet/inputs.py Normal file
View File

@ -0,0 +1,280 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
"""
This file comes from the project `django-bootstrap-datepicker-plus` available on Github:
https://github.com/monim67/django-bootstrap-datepicker-plus
This is distributed under Apache License 2.0.
This adds datetime pickers with bootstrap.
"""
"""Contains Base Date-Picker input class for widgets of this package."""
from json import dumps as json_dumps
from django.forms.widgets import DateTimeBaseInput, NumberInput
class AmountInput(NumberInput):
"""
This input type lets the user type amounts in euros, but forms receive data in cents
"""
template_name = "note/amount_input.html"
def format_value(self, value):
return None if value is None or value == "" else "{:.02f}".format(value / 100, )
def value_from_datadict(self, data, files, name):
val = super().value_from_datadict(data, files, name)
return str(int(100 * float(val))) if val else val
class DatePickerDictionary:
"""Keeps track of all date-picker input classes."""
_i = 0
items = dict()
@classmethod
def generate_id(cls):
"""Return a unique ID for each date-picker input class."""
cls._i += 1
return 'dp_%s' % cls._i
class BasePickerInput(DateTimeBaseInput):
"""Base Date-Picker input class for widgets of this package."""
template_name = 'bootstrap_datepicker_plus/date_picker.html'
picker_type = 'DATE'
format = '%Y-%m-%d'
config = {}
_default_config = {
'id': None,
'picker_type': None,
'linked_to': None,
'options': {} # final merged options
}
options = {} # options extended by user
options_param = {} # options passed as parameter
_default_options = {
'showClose': True,
'showClear': True,
'showTodayButton': True,
"locale": "fr",
}
# source: https://github.com/tutorcruncher/django-bootstrap3-datetimepicker
# file: /blob/31fbb09/bootstrap3_datetime/widgets.py#L33
format_map = (
('DDD', r'%j'),
('DD', r'%d'),
('MMMM', r'%B'),
('MMM', r'%b'),
('MM', r'%m'),
('YYYY', r'%Y'),
('YY', r'%y'),
('HH', r'%H'),
('hh', r'%I'),
('mm', r'%M'),
('ss', r'%S'),
('a', r'%p'),
('ZZ', r'%z'),
)
class Media:
"""JS/CSS resources needed to render the date-picker calendar."""
js = (
'https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.9.0/'
'moment-with-locales.min.js',
'https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/'
'4.17.47/js/bootstrap-datetimepicker.min.js',
'bootstrap_datepicker_plus/js/datepicker-widget.js'
)
css = {'all': (
'https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/'
'4.17.47/css/bootstrap-datetimepicker.css',
'bootstrap_datepicker_plus/css/datepicker-widget.css'
), }
@classmethod
def format_py2js(cls, datetime_format):
"""Convert python datetime format to moment datetime format."""
for js_format, py_format in cls.format_map:
datetime_format = datetime_format.replace(py_format, js_format)
return datetime_format
@classmethod
def format_js2py(cls, datetime_format):
"""Convert moment datetime format to python datetime format."""
for js_format, py_format in cls.format_map:
datetime_format = datetime_format.replace(js_format, py_format)
return datetime_format
def __init__(self, attrs=None, format=None, options=None):
"""Initialize the Date-picker widget."""
self.format_param = format
self.options_param = options if options else {}
self.config = self._default_config.copy()
self.config['id'] = DatePickerDictionary.generate_id()
self.config['picker_type'] = self.picker_type
self.config['options'] = self._calculate_options()
attrs = attrs if attrs else {}
if 'class' not in attrs:
attrs['class'] = 'form-control'
super().__init__(attrs, self._calculate_format())
def _calculate_options(self):
"""Calculate and Return the options."""
_options = self._default_options.copy()
_options.update(self.options)
if self.options_param:
_options.update(self.options_param)
return _options
def _calculate_format(self):
"""Calculate and Return the datetime format."""
_format = self.format_param if self.format_param else self.format
if self.config['options'].get('format'):
_format = self.format_js2py(self.config['options'].get('format'))
else:
self.config['options']['format'] = self.format_py2js(_format)
return _format
def get_context(self, name, value, attrs):
"""Return widget context dictionary."""
context = super().get_context(
name, value, attrs)
context['widget']['attrs']['dp_config'] = json_dumps(self.config)
return context
def start_of(self, event_id):
"""
Set Date-Picker as the start-date of a date-range.
Args:
- event_id (string): User-defined unique id for linking two fields
"""
DatePickerDictionary.items[str(event_id)] = self
return self
def end_of(self, event_id, import_options=True):
"""
Set Date-Picker as the end-date of a date-range.
Args:
- event_id (string): User-defined unique id for linking two fields
- import_options (bool): inherit options from start-date input,
default: TRUE
"""
event_id = str(event_id)
if event_id in DatePickerDictionary.items:
linked_picker = DatePickerDictionary.items[event_id]
self.config['linked_to'] = linked_picker.config['id']
if import_options:
backup_moment_format = self.config['options']['format']
self.config['options'].update(linked_picker.config['options'])
self.config['options'].update(self.options_param)
if self.format_param or 'format' in self.options_param:
self.config['options']['format'] = backup_moment_format
else:
self.format = linked_picker.format
# Setting useCurrent is necessary, see following issue
# https://github.com/Eonasdan/bootstrap-datetimepicker/issues/1075
self.config['options']['useCurrent'] = False
self._link_to(linked_picker)
else:
raise KeyError(
'start-date not specified for event_id "%s"' % event_id)
return self
def _link_to(self, linked_picker):
"""
Executed when two date-inputs are linked together.
This method for sub-classes to override to customize the linking.
"""
pass
class DatePickerInput(BasePickerInput):
"""
Widget to display a Date-Picker Calendar on a DateField property.
Args:
- attrs (dict): HTML attributes of rendered HTML input
- format (string): Python DateTime format eg. "%Y-%m-%d"
- options (dict): Options to customize the widget, see README
"""
picker_type = 'DATE'
format = '%Y-%m-%d'
format_key = 'DATE_INPUT_FORMATS'
class TimePickerInput(BasePickerInput):
"""
Widget to display a Time-Picker Calendar on a TimeField property.
Args:
- attrs (dict): HTML attributes of rendered HTML input
- format (string): Python DateTime format eg. "%Y-%m-%d"
- options (dict): Options to customize the widget, see README
"""
picker_type = 'TIME'
format = '%H:%M'
format_key = 'TIME_INPUT_FORMATS'
template_name = 'bootstrap_datepicker_plus/time_picker.html'
class DateTimePickerInput(BasePickerInput):
"""
Widget to display a DateTime-Picker Calendar on a DateTimeField property.
Args:
- attrs (dict): HTML attributes of rendered HTML input
- format (string): Python DateTime format eg. "%Y-%m-%d"
- options (dict): Options to customize the widget, see README
"""
picker_type = 'DATETIME'
format = '%Y-%m-%d %H:%M'
format_key = 'DATETIME_INPUT_FORMATS'
class MonthPickerInput(BasePickerInput):
"""
Widget to display a Month-Picker Calendar on a DateField property.
Args:
- attrs (dict): HTML attributes of rendered HTML input
- format (string): Python DateTime format eg. "%Y-%m-%d"
- options (dict): Options to customize the widget, see README
"""
picker_type = 'MONTH'
format = '01/%m/%Y'
format_key = 'DATE_INPUT_FORMATS'
class YearPickerInput(BasePickerInput):
"""
Widget to display a Year-Picker Calendar on a DateField property.
Args:
- attrs (dict): HTML attributes of rendered HTML input
- format (string): Python DateTime format eg. "%Y-%m-%d"
- options (dict): Options to customize the widget, see README
"""
picker_type = 'YEAR'
format = '01/01/%Y'
format_key = 'DATE_INPUT_FORMATS'
def _link_to(self, linked_picker):
"""Customize the options when linked with other date-time input"""
yformat = self.config['options']['format'].replace('-01-01', '-12-31')
self.config['options']['format'] = yformat

View File

@ -48,6 +48,7 @@ INSTALLED_APPS = [
'django.contrib.sites',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.forms',
# API
'rest_framework',
'rest_framework.authtoken',
@ -100,6 +101,8 @@ TEMPLATES = [
},
]
FORM_RENDERER = 'django.forms.renderers.TemplatesSetting'
WSGI_APPLICATION = 'note_kfet.wsgi.application'
# Password validation

View File

@ -0,0 +1,121 @@
@font-face {
font-family: 'Glyphicons Halflings';
src: url('//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/fonts/glyphicons-halflings-regular.eot');
src: url('//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'),
url('//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/fonts/glyphicons-halflings-regular.woff2') format('woff2'),
url('//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/fonts/glyphicons-halflings-regular.woff') format('woff'),
url('//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/fonts/glyphicons-halflings-regular.ttf') format('truetype'),
url('//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg');
}
.glyphicon {
position: relative;
top: 1px;
display: inline-block;
font-family: 'Glyphicons Halflings';
font-style: normal;
font-weight: normal;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.glyphicon-time:before {
content: "\e023";
}
.glyphicon-chevron-left:before {
content: "\e079";
}
.glyphicon-chevron-right:before {
content: "\e080";
}
.glyphicon-chevron-up:before {
content: "\e113";
}
.glyphicon-chevron-down:before {
content: "\e114";
}
.glyphicon-calendar:before {
content: "\e109";
}
.glyphicon-screenshot:before {
content: "\e087";
}
.glyphicon-trash:before {
content: "\e020";
}
.glyphicon-remove:before {
content: "\e014";
}
.bootstrap-datetimepicker-widget .btn {
display: inline-block;
padding: 6px 12px;
margin-bottom: 0;
font-size: 14px;
font-weight: normal;
line-height: 1.42857143;
text-align: center;
white-space: nowrap;
vertical-align: middle;
-ms-touch-action: manipulation;
touch-action: manipulation;
cursor: pointer;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
background-image: none;
border: 1px solid transparent;
border-radius: 4px;
}
.bootstrap-datetimepicker-widget.dropdown-menu {
position: absolute;
left: 0;
z-index: 1000;
display: none;
float: left;
min-width: 160px;
padding: 5px 0;
margin: 2px 0 0;
font-size: 14px;
text-align: left;
list-style: none;
background-color: #fff;
-webkit-background-clip: padding-box;
background-clip: padding-box;
border: 1px solid #ccc;
border: 1px solid rgba(0, 0, 0, .15);
border-radius: 4px;
-webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, .175);
box-shadow: 0 6px 12px rgba(0, 0, 0, .175);
}
.bootstrap-datetimepicker-widget .list-unstyled {
padding-left: 0;
list-style: none;
}
.bootstrap-datetimepicker-widget .collapse {
display: none;
}
.bootstrap-datetimepicker-widget .collapse.in {
display: block;
}
/* fix for bootstrap4 */
.bootstrap-datetimepicker-widget .table-condensed > thead > tr > th,
.bootstrap-datetimepicker-widget .table-condensed > tbody > tr > td,
.bootstrap-datetimepicker-widget .table-condensed > tfoot > tr > td {
padding: 5px;
}

View File

@ -0,0 +1,55 @@
jQuery(function ($) {
var datepickerDict = {};
var isBootstrap4 = $.fn.collapse.Constructor.VERSION.split('.').shift() == "4";
function fixMonthEndDate(e, picker) {
e.date && picker.val().length && picker.val(e.date.endOf('month').format('YYYY-MM-DD'));
}
$("[dp_config]:not([disabled])").each(function (i, element) {
var $element = $(element), data = {};
try {
data = JSON.parse($element.attr('dp_config'));
}
catch (x) { }
if (data.id && data.options) {
data.$element = $element.datetimepicker(data.options);
data.datepickerdata = $element.data("DateTimePicker");
datepickerDict[data.id] = data;
data.$element.next('.input-group-addon').on('click', function(){
data.datepickerdata.show();
});
if(isBootstrap4){
data.$element.on("dp.show", function (e) {
$('.collapse.in').addClass('show');
});
}
}
});
$.each(datepickerDict, function (id, to_picker) {
if (to_picker.linked_to) {
var from_picker = datepickerDict[to_picker.linked_to];
from_picker.datepickerdata.maxDate(to_picker.datepickerdata.date() || false);
to_picker.datepickerdata.minDate(from_picker.datepickerdata.date() || false);
from_picker.$element.on("dp.change", function (e) {
to_picker.datepickerdata.minDate(e.date || false);
});
to_picker.$element.on("dp.change", function (e) {
if (to_picker.picker_type == 'MONTH') fixMonthEndDate(e, to_picker.$element);
from_picker.datepickerdata.maxDate(e.date || false);
});
if (to_picker.picker_type == 'MONTH') {
to_picker.$element.on("dp.hide", function (e) {
fixMonthEndDate(e, to_picker.$element);
});
fixMonthEndDate({ date: to_picker.datepickerdata.date() }, to_picker.$element);
}
}
});
if(isBootstrap4) {
$('body').on('show.bs.collapse','.bootstrap-datetimepicker-widget .collapse',function(e){
$(e.target).addClass('in');
});
$('body').on('hidden.bs.collapse','.bootstrap-datetimepicker-widget .collapse',function(e){
$(e.target).removeClass('in');
});
}
});

View File

@ -0,0 +1,6 @@
<div class="input-group date">
{% include "bootstrap_datepicker_plus/input.html" %}
<div class="input-group-addon input-group-append" data-target="#datetimepicker1" data-toggle="datetimepickerv">
<div class="input-group-text"><i class="glyphicon glyphicon-calendar"></i></div>
</div>
</div>

View File

@ -0,0 +1,4 @@
<input type="{{ widget.type }}" name="{{ widget.name }}"{% if widget.value != None and widget.value != "" %}
value="{{ widget.value }}"{% endif %}{% for name, value in widget.attrs.items %}{% ifnotequal value False %}
{{ name }}{% ifnotequal value True %}="{{ value|stringformat:'s' }}"{% endifnotequal %}
{% endifnotequal %}{% endfor %}/>

View File

@ -0,0 +1,6 @@
<div class="input-group date">
{% include "bootstrap_datepicker_plus/input.html" %}
<div class="input-group-addon input-group-append" data-target="#datetimepicker1" data-toggle="datetimepickerv">
<div class="input-group-text"><i class="glyphicon glyphicon-time"></i></div>
</div>
</div>

View File

@ -0,0 +1,11 @@
<div class="input-group">
<input class="form-control mx-auto d-block" type="number" min="0" step="0.01"
{% if widget.value != None and widget.value != "" %}value="{{ widget.value }}"{% endif %}
name="{{ widget.name }}"
{% for name, value in widget.attrs.items %}
{% ifnotequal value False %}{{ name }}{% ifnotequal value True %}="{{ value|stringformat:'s' }}"{% endifnotequal %}{% endifnotequal %}
{% endfor %}>
<div class="input-group-append">
<span class="input-group-text"></span>
</div>
</div>

View File

@ -126,12 +126,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="form-row">
<div class="form-group col-md-6">
<label for="amount">{% trans "Amount" %} :</label>
<div class="input-group">
<input class="form-control mx-auto d-block" type="number" min="0" step="0.01" id="amount" />
<div class="input-group-append">
<span class="input-group-text"></span>
</div>
</div>
{% include "note/amount_input.html" with widget=amount_widget %}
</div>
<div class="form-group col-md-6">

View File

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% load crispy_forms_tags pretty_money %}
{% load crispy_forms_tags %}
{% block content %}
<p><a class="btn btn-default" href="{% url 'treasury:invoice_list' %}">{% trans "Invoices list" %}</a></p>
<form method="post" action="">
@ -27,17 +27,7 @@
<tr class="row-formset">
<td>{{ form.designation }}</td>
<td>{{ form.quantity }}</td>
<td>
{# Use custom input for amount, with the € symbol #}
<div class="input-group">
<input type="number" name="product_set-{{ forloop.counter0 }}-amount" step="0.01"
id="id_product_set-{{ forloop.counter0 }}-amount"
value="{{ form.instance.amount|cents_to_euros }}">
<div class="input-group-append">
<span class="input-group-text"></span>
</div>
</div>
</td>
<td>{{ form.amount }}</td>
{# These fields are hidden but handled by the formset to link the id and the invoice id #}
{{ form.invoice }}
{{ form.id }}
@ -64,15 +54,7 @@
<tr class="row-formset">
<td>{{ formset.empty_form.designation }}</td>
<td>{{ formset.empty_form.quantity }} </td>
<td>
<div class="input-group">
<input type="number" name="product_set-__prefix__-amount" step="0.01"
id="id_product_set-__prefix__-amount">
<div class="input-group-append">
<span class="input-group-text"></span>
</div>
</div>
</td>
<td>{{ formset.empty_form.amount }}</td>
{{ formset.empty_form.invoice }}
{{ formset.empty_form.id }}
</tr>