diff --git a/apps/activity/forms.py b/apps/activity/forms.py index a36f85cd..b7dc6de9 100644 --- a/apps/activity/forms.py +++ b/apps/activity/forms.py @@ -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(), + } diff --git a/apps/activity/views.py b/apps/activity/views.py index 224181ef..f1ecc1b3 100644 --- a/apps/activity/views.py +++ b/apps/activity/views.py @@ -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 diff --git a/apps/note/templatetags/pretty_money.py b/apps/note/templatetags/pretty_money.py index ba527f9b..265870a8 100644 --- a/apps/note/templatetags/pretty_money.py +++ b/apps/note/templatetags/pretty_money.py @@ -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) diff --git a/apps/note/views.py b/apps/note/views.py index ddf5ee6f..c8f57924 100644 --- a/apps/note/views.py +++ b/apps/note/views.py @@ -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() diff --git a/apps/treasury/forms.py b/apps/treasury/forms.py index caaa365f..7fe7de4c 100644 --- a/apps/treasury/forms.py +++ b/apps/treasury/forms.py @@ -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, ) diff --git a/apps/treasury/views.py b/apps/treasury/views.py index 90440566..c374ced1 100644 --- a/apps/treasury/views.py +++ b/apps/treasury/views.py @@ -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(): diff --git a/note_kfet/inputs.py b/note_kfet/inputs.py new file mode 100644 index 00000000..ca3a13c7 --- /dev/null +++ b/note_kfet/inputs.py @@ -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 \ No newline at end of file diff --git a/note_kfet/settings/base.py b/note_kfet/settings/base.py index d49b2542..bdd4d9a3 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -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 diff --git a/static/bootstrap_datepicker_plus/css/datepicker-widget.css b/static/bootstrap_datepicker_plus/css/datepicker-widget.css new file mode 100644 index 00000000..baeec507 --- /dev/null +++ b/static/bootstrap_datepicker_plus/css/datepicker-widget.css @@ -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; +} diff --git a/static/bootstrap_datepicker_plus/js/datepicker-widget.js b/static/bootstrap_datepicker_plus/js/datepicker-widget.js new file mode 100644 index 00000000..2288b46b --- /dev/null +++ b/static/bootstrap_datepicker_plus/js/datepicker-widget.js @@ -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'); + }); + } +}); diff --git a/templates/bootstrap_datepicker_plus/date_picker.html b/templates/bootstrap_datepicker_plus/date_picker.html new file mode 100644 index 00000000..67a11df1 --- /dev/null +++ b/templates/bootstrap_datepicker_plus/date_picker.html @@ -0,0 +1,6 @@ +
+ {% include "bootstrap_datepicker_plus/input.html" %} +
+
+
+
diff --git a/templates/bootstrap_datepicker_plus/input.html b/templates/bootstrap_datepicker_plus/input.html new file mode 100644 index 00000000..b2f8c403 --- /dev/null +++ b/templates/bootstrap_datepicker_plus/input.html @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/templates/bootstrap_datepicker_plus/time_picker.html b/templates/bootstrap_datepicker_plus/time_picker.html new file mode 100644 index 00000000..2bd509a3 --- /dev/null +++ b/templates/bootstrap_datepicker_plus/time_picker.html @@ -0,0 +1,6 @@ +
+ {% include "bootstrap_datepicker_plus/input.html" %} +
+
+
+
diff --git a/templates/note/amount_input.html b/templates/note/amount_input.html new file mode 100644 index 00000000..6ef4a53a --- /dev/null +++ b/templates/note/amount_input.html @@ -0,0 +1,11 @@ +
+ +
+ +
+
\ No newline at end of file diff --git a/templates/note/transaction_form.html b/templates/note/transaction_form.html index d2cd85e9..65aaa635 100644 --- a/templates/note/transaction_form.html +++ b/templates/note/transaction_form.html @@ -126,12 +126,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
-
- -
- -
-
+ {% include "note/amount_input.html" with widget=amount_widget %}
diff --git a/templates/treasury/invoice_form.html b/templates/treasury/invoice_form.html index 0edcbdcd..2875d410 100644 --- a/templates/treasury/invoice_form.html +++ b/templates/treasury/invoice_form.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load i18n %} -{% load crispy_forms_tags pretty_money %} +{% load crispy_forms_tags %} {% block content %}

{% trans "Invoices list" %}

@@ -26,18 +26,8 @@ {% endif %} {{ form.designation }} - {{ form.quantity }} - - {# Use custom input for amount, with the € symbol #} -
- -
- -
-
- + {{ form.quantity }} + {{ form.amount }} {# 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 @@ {{ formset.empty_form.designation }} {{ formset.empty_form.quantity }} - -
- -
- -
-
- + {{ formset.empty_form.amount }} {{ formset.empty_form.invoice }} {{ formset.empty_form.id }}