diff --git a/apps/member/forms.py b/apps/member/forms.py index e024ee84f8e860955c85f36b3ff3f106ebf5366c..5dabef245827071880a7c485a81cc8210535b547 100644 --- a/apps/member/forms.py +++ b/apps/member/forms.py @@ -6,7 +6,15 @@ from django.contrib.auth.forms import UserChangeForm, UserCreationForm from django.contrib.auth.models import User from django import forms -from .models import Profile, Club +from .models import Profile, Club, Membership + +from django.utils.translation import gettext_lazy as _ + +from crispy_forms.helper import FormHelper +from crispy_forms import layout, bootstrap +from crispy_forms.bootstrap import InlineField, FormActions, StrictButton, Div, Field +from crispy_forms.layout import Layout + class ProfileForm(forms.ModelForm): """ @@ -21,3 +29,33 @@ class ClubForm(forms.ModelForm): class Meta: model = Club fields ='__all__' + +class AddMembersForm(forms.Form): + class Meta: + fields = ('',) + +class MembershipForm(forms.ModelForm): + class Meta: + model = Membership + fields = ('user','roles','date_start') + +MemberFormSet = forms.modelformset_factory(Membership, + form=MembershipForm, + extra=2, + can_delete=True) + +class FormSetHelper(FormHelper): + def __init__(self,*args,**kwargs): + super().__init__(*args,**kwargs) + self.form_tag = False + self.form_method = 'POST' + self.form_class='form-inline' + # self.template = 'bootstrap/table_inline_formset.html' + self.layout = Layout( + Div( + Div('user',css_class='col-sm-2'), + Div('roles',css_class='col-sm-2'), + Div('date_start',css_class='col-sm-2'), + css_class="row formset-row", + ) + ) diff --git a/apps/member/models.py b/apps/member/models.py index 70f8ccf7d2c83f6affd727ecc0d3ad1d6f5ceb9a..10a51395dc1e2ccf6d4a2f076d25eb0a3003963d 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -7,7 +7,7 @@ from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ -from django.urls import reverse +from django.urls import reverse, reverse_lazy class Profile(models.Model): """ @@ -96,7 +96,7 @@ class Club(models.Model): return self.name def get_absolute_url(self): - return reverse('member:club_detail', args=(self.pk,)) + return reverse_lazy('member:club_detail', args=(self.pk,)) class Role(models.Model): diff --git a/apps/member/tables.py b/apps/member/tables.py new file mode 100644 index 0000000000000000000000000000000000000000..e9000227353ffa4f9586b93cb5eca4389dbc898c --- /dev/null +++ b/apps/member/tables.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python + +import django_tables2 as tables +from .models import Club + + +class ClubTable(tables.Table): + class Meta: + attrs = {'class':'table table-bordered table-condensed table-striped table-hover'} + model = Club + template_name = 'django_tables2/bootstrap.html' + fields= ('id','name','email') + row_attrs = {'class':'table-row', + 'data-href': lambda record: record.pk } diff --git a/apps/member/urls.py b/apps/member/urls.py index 7b179b56f6a30da6af5e09554aff4890302c1c1f..39d3d8967ef099a65b7ddd06fdbb5a826b7e67f5 100644 --- a/apps/member/urls.py +++ b/apps/member/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path('signup/',views.UserCreateView.as_view(),name="signup"), path('club/',views.ClubListView.as_view(),name="club_list"), path('club/<int:pk>/',views.ClubDetailView.as_view(),name="club_detail"), + path('club/<int:pk>/add_member/',views.ClubAddMemberView.as_view(),name="club_add_member"), path('club/create/',views.ClubCreateView.as_view(),name="club_create"), path('user/<int:pk>',views.UserDetailView.as_view(),name="user_detail") ] diff --git a/apps/member/views.py b/apps/member/views.py index d664df70494a07847dba845048e36a0761261058..f54103e7476772d832b2592db040b8b386792d6c 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -11,8 +11,12 @@ from django.contrib.auth.forms import UserCreationForm from django.urls import reverse_lazy from django.db.models import Q -from .models import Profile, Club -from .forms import ProfileForm, ClubForm +from django_tables2.views import SingleTableView + + +from .models import Profile, Club, Membership +from .forms import ProfileForm, ClubForm,MembershipForm, MemberFormSet,FormSetHelper +from .tables import ClubTable from note.models.transactions import Transaction class UserCreateView(CreateView): @@ -26,7 +30,7 @@ class UserCreateView(CreateView): second_form = UserCreationForm def get_context_data(self,**kwargs): - context = super(SignUp,self).get_context_data(**kwargs) + context = super().get_context_data(**kwargs) context["user_form"] = self.second_form return context @@ -63,14 +67,46 @@ class ClubCreateView(LoginRequiredMixin,CreateView): def form_valid(self,form): return super().form_valid(form) -class ClubListView(LoginRequiredMixin,ListView): +class ClubListView(LoginRequiredMixin,SingleTableView): """ - List TransactionsTemplates + List existing tables """ model = Club - form_class = ClubForm + table_class = ClubTable class ClubDetailView(LoginRequiredMixin,DetailView): - """ - """ model = Club + context_object_name="club" + + def get_context_data(self,**kwargs): + context = super().get_context_data(**kwargs) + club = context["club"] + club_transactions = \ + Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note)) + context['history_list'] = club_transactions + club_member = \ + Membership.objects.all().filter(club=club) + #TODO: consider only valid Membership + context['member_list'] = club_member + return context + +class ClubAddMemberView(LoginRequiredMixin,CreateView): + model = Membership + form_class = MembershipForm + template_name = 'member/add_members.html' + def get_context_data(self,**kwargs): + context = super().get_context_data(**kwargs) + context['formset'] = MemberFormSet() + context['helper'] = FormSetHelper() + return context + + def post(self,request,*args,**kwargs): + formset = MembershipFormset(request.POST) + if formset.is_valid(): + return self.form_valid(formset) + else: + return self.form_invalid(formset) + + def form_valid(self,formset): + formset.save() + return super().form_valid(formset) diff --git a/static/js/dynamic-formset.js b/static/js/dynamic-formset.js new file mode 100644 index 0000000000000000000000000000000000000000..87edfaaeb38e7ed4cd2f06fe0e9f48939b706b86 --- /dev/null +++ b/static/js/dynamic-formset.js @@ -0,0 +1,231 @@ +/** + * jQuery Formset 1.3-pre + * @author Stanislaus Madueke (stan DOT madueke AT gmail DOT com) + * @requires jQuery 1.2.6 or later + * + * Copyright (c) 2009, Stanislaus Madueke + * All rights reserved. + * + * Licensed under the New BSD License + * See: http://www.opensource.org/licenses/bsd-license.php + */ +;(function($) { + $.fn.formset = function(opts) + { + var options = $.extend({}, $.fn.formset.defaults, opts), + flatExtraClasses = options.extraClasses.join(' '), + totalForms = $('#id_' + options.prefix + '-TOTAL_FORMS'), + maxForms = $('#id_' + options.prefix + '-MAX_NUM_FORMS'), + minForms = $('#id_' + options.prefix + '-MIN_NUM_FORMS'), + childElementSelector = 'input,select,textarea,label,div', + $$ = $(this), + + applyExtraClasses = function(row, ndx) { + if (options.extraClasses) { + row.removeClass(flatExtraClasses); + row.addClass(options.extraClasses[ndx % options.extraClasses.length]); + } + }, + + updateElementIndex = function(elem, prefix, ndx) { + var idRegex = new RegExp(prefix + '-(\\d+|__prefix__)-'), + replacement = prefix + '-' + ndx + '-'; + if (elem.attr("for")) elem.attr("for", elem.attr("for").replace(idRegex, replacement)); + if (elem.attr('id')) elem.attr('id', elem.attr('id').replace(idRegex, replacement)); + if (elem.attr('name')) elem.attr('name', elem.attr('name').replace(idRegex, replacement)); + }, + + hasChildElements = function(row) { + return row.find(childElementSelector).length > 0; + }, + + showAddButton = function() { + return maxForms.length == 0 || // For Django versions pre 1.2 + (maxForms.val() == '' || (maxForms.val() - totalForms.val() > 0)); + }, + + /** + * Indicates whether delete link(s) can be displayed - when total forms > min forms + */ + showDeleteLinks = function() { + return minForms.length == 0 || // For Django versions pre 1.7 + (minForms.val() == '' || (totalForms.val() - minForms.val() > 0)); + }, + + insertDeleteLink = function(row) { + var delCssSelector = $.trim(options.deleteCssClass).replace(/\s+/g, '.'), + addCssSelector = $.trim(options.addCssClass).replace(/\s+/g, '.'); + if (row.is('TR')) { + // If the forms are laid out in table rows, insert + // the remove button into the last table cell: + row.children(':last').append('<a class="' + options.deleteCssClass +'" href="javascript:void(0)">' + options.deleteText + '</a>'); + } else if (row.is('UL') || row.is('OL')) { + // If they're laid out as an ordered/unordered list, + // insert an <li> after the last list item: + row.append('<li><a class="' + options.deleteCssClass + '" href="javascript:void(0)">' + options.deleteText +'</a></li>'); + } else { + // Otherwise, just insert the remove button as the + // last child element of the form's container: + row.append('<a class="' + options.deleteCssClass + '" href="javascript:void(0)">' + options.deleteText +'</a>'); + } + // Check if we're under the minimum number of forms - not to display delete link at rendering + if (!showDeleteLinks()){ + row.find('a.' + delCssSelector).hide(); + } + + row.find('a.' + delCssSelector).click(function() { + var row = $(this).parents('.' + options.formCssClass), + del = row.find('input:hidden[id $= "-DELETE"]'), + buttonRow = row.siblings("a." + addCssSelector + ', .' + options.formCssClass + '-add'), + forms; + if (del.length) { + // We're dealing with an inline formset. + // Rather than remove this form from the DOM, we'll mark it as deleted + // and hide it, then let Django handle the deleting: + del.val('on'); + row.hide(); + forms = $('.' + options.formCssClass).not(':hidden'); + } else { + row.remove(); + // Update the TOTAL_FORMS count: + forms = $('.' + options.formCssClass).not('.formset-custom-template'); + totalForms.val(forms.length); + } + for (var i=0, formCount=forms.length; i<formCount; i++) { + // Apply `extraClasses` to form rows so they're nicely alternating: + applyExtraClasses(forms.eq(i), i); + if (!del.length) { + // Also update names and IDs for all child controls (if this isn't + // a delete-able inline formset) so they remain in sequence: + forms.eq(i).find(childElementSelector).each(function() { + updateElementIndex($(this), options.prefix, i); + }); + } + } + // Check if we've reached the minimum number of forms - hide all delete link(s) + if (!showDeleteLinks()){ + $('a.' + delCssSelector).each(function(){$(this).hide();}); + } + // Check if we need to show the add button: + if (buttonRow.is(':hidden') && showAddButton()) buttonRow.show(); + // If a post-delete callback was provided, call it with the deleted form: + if (options.removed) options.removed(row); + return false; + }); + }; + + $$.each(function(i) { + var row = $(this), + del = row.find('input:checkbox[id $= "-DELETE"]'); + if (del.length) { + // If you specify "can_delete = True" when creating an inline formset, + // Django adds a checkbox to each form in the formset. + // Replace the default checkbox with a hidden field: + if (del.is(':checked')) { + // If an inline formset containing deleted forms fails validation, make sure + // we keep the forms hidden (thanks for the bug report and suggested fix Mike) + del.before('<input type="hidden" name="' + del.attr('name') +'" id="' + del.attr('id') +'" value="on" />'); + row.hide(); + } else { + del.before('<input type="hidden" name="' + del.attr('name') +'" id="' + del.attr('id') +'" />'); + } + // Hide any labels associated with the DELETE checkbox: + $('label[for="' + del.attr('id') + '"]').hide(); + del.remove(); + } + if (hasChildElements(row)) { + row.addClass(options.formCssClass); + if (row.is(':visible')) { + insertDeleteLink(row); + applyExtraClasses(row, i); + } + } + }); + + if ($$.length) { + var hideAddButton = !showAddButton(), + addButton, template; + if (options.formTemplate) { + // If a form template was specified, we'll clone it to generate new form instances: + template = (options.formTemplate instanceof $) ? options.formTemplate : $(options.formTemplate); + template.removeAttr('id').addClass(options.formCssClass + ' formset-custom-template'); + template.find(childElementSelector).each(function() { + updateElementIndex($(this), options.prefix, '__prefix__'); + }); + insertDeleteLink(template); + } else { + // Otherwise, use the last form in the formset; this works much better if you've got + // extra (>= 1) forms (thnaks to justhamade for pointing this out): + template = $('.' + options.formCssClass + ':last').clone(true).removeAttr('id'); + template.find('input:hidden[id $= "-DELETE"]').remove(); + // Clear all cloned fields, except those the user wants to keep (thanks to brunogola for the suggestion): + template.find(childElementSelector).not(options.keepFieldValues).each(function() { + var elem = $(this); + // If this is a checkbox or radiobutton, uncheck it. + // This fixes Issue 1, reported by Wilson.Andrew.J: + if (elem.is('input:checkbox') || elem.is('input:radio')) { + elem.attr('checked', false); + } else { + elem.val(''); + } + }); + } + // FIXME: Perhaps using $.data would be a better idea? + options.formTemplate = template; + + if ($$.is('TR')) { + // If forms are laid out as table rows, insert the + // "add" button in a new table row: + var numCols = $$.eq(0).children().length, // This is a bit of an assumption :| + buttonRow = $('<tr><td colspan="' + numCols + '"><a class="' + options.addCssClass + '" href="javascript:void(0)">' + options.addText + '</a></tr>') + .addClass(options.formCssClass + '-add'); + $$.parent().append(buttonRow); + if (hideAddButton) buttonRow.hide(); + addButton = buttonRow.find('a'); + } else { + // Otherwise, insert it immediately after the last form: + $$.filter(':last').after('<a class="' + options.addCssClass + '" href="javascript:void(0)">' + options.addText + '</a>'); + addButton = $$.filter(':last').next(); + if (hideAddButton) addButton.hide(); + } + addButton.click(function() { + var formCount = parseInt(totalForms.val()), + row = options.formTemplate.clone(true).removeClass('formset-custom-template'), + buttonRow = $($(this).parents('tr.' + options.formCssClass + '-add').get(0) || this), + delCssSelector = $.trim(options.deleteCssClass).replace(/\s+/g, '.'); + applyExtraClasses(row, formCount); + row.insertBefore(buttonRow).show(); + row.find(childElementSelector).each(function() { + updateElementIndex($(this), options.prefix, formCount); + }); + totalForms.val(formCount + 1); + // Check if we're above the minimum allowed number of forms -> show all delete link(s) + if (showDeleteLinks()){ + $('a.' + delCssSelector).each(function(){$(this).show();}); + } + // Check if we've exceeded the maximum allowed number of forms: + if (!showAddButton()) buttonRow.hide(); + // If a post-add callback was supplied, call it with the added form: + if (options.added) options.added(row); + return false; + }); + } + + return $$; + }; + + /* Setup plugin defaults */ + $.fn.formset.defaults = { + prefix: 'form', // The form prefix for your django formset + formTemplate: null, // The jQuery selection cloned to generate new form instances + addText: 'add another', // Text for the add link + deleteText: 'remove', // Text for the delete link + addCssClass: '', // CSS class applied to the add link + deleteCssClass: '', // CSS class applied to the delete link + formCssClass: 'dynamic-form', // CSS class applied to each form in a formset + extraClasses: [], // Additional CSS classes, which will be applied to each form in turn + keepFieldValues: '', // jQuery selector for fields whose values should be kept when the form is cloned + added: null, // Function called each time a new form is added + removed: null // Function called each time a form is deleted + }; +})(jQuery); diff --git a/templates/member/add_members.html b/templates/member/add_members.html new file mode 100644 index 0000000000000000000000000000000000000000..8032af30cc368e3595db3ea304c2b41f16a5e447 --- /dev/null +++ b/templates/member/add_members.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% load crispy_forms_tags %} +{% load static %} +{% block content %} + +<form method="post" action=""> + {% csrf_token %} + {% crispy formset helper %} + <div class="form-actions"> + <input type="submit" name="submit" value="Add Members" class="btn btn-primary" id="submit-save"> + </div> +</form> + +<!-- Include formset plugin - including jQuery dependency --> +<script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js"></script> +<script src="{% static 'js/dynamic-formset.js' %}"></script> +<script> + $('.formset-row').formset({ + addText: 'add another', // Text for the add link + deleteText: 'remove', // Text for the delete link + addCssClass: 'btn btn-primary', // CSS class applied to the add link + deleteCssClass: 'btn btn-danger h-50 my-auto', + }); +</script> +{% endblock %} diff --git a/templates/member/club_detail.html b/templates/member/club_detail.html index 1e222e2646f9c1ee43ccedee87ab4af3b5170132..d8e6f8056278a1841f678e750bc0e59852ca39c2 100644 --- a/templates/member/club_detail.html +++ b/templates/member/club_detail.html @@ -1,6 +1,61 @@ {% extends "base.html" %} {% load static %} +{% load i18n %} +{% load render_table from django_tables2 %} {% block content %} -<p><a class="btn btn-default" href="{% url 'member:club_list' %}">Clubs</a></p> -<h5>{{ object.name }}</h5> -{% endblock %} +<p><a class="btn btn-primary" href="{% url 'member:club_list' %}">Clubs</a></p> +<h3 class="text-center"> Club {{ object.name }}</h3> +<dl> + <dt>{% trans 'Membership starts on' %}</dt> + <dd>{{ club.membership_start }}</dd> + <dt>{% trans 'Membership ends on' %}</dt> + <dd>{{ club.membership_end }}</dd> + <dt>{% trans 'Membership duration' %}</dt> + <dd>{{ club.membership_duration }}</dd> + <dt> Aliases </dt> + <dd>{{ club.note.aliases_set.all }}</dd> + <dt>{% trans 'balance' %}</dt> + <dd>{{ club.note.balance }}</dd> + +</dl> + + +<div class="btn-group" role="group"> + <a class="btn btn-primary" href="{% url 'member:club_add_member' pk=object.pk %}"> Ajouter des membres </a> + <a class="btn btn-primary" href="{% url 'member:club_add_member' pk=object.pk %}"> Modifier les informations </a> + <a class="btn btn-primary" href="{% url 'member:club_add_member' pk=object.pk %}"> Ajouter des roles </a> +</div> + +<div class="accordion" id="accordionExample"> + <div class="card"> + <div class="card-header" id="headingOne"> + <h5 class="mb-0"> + <button class="btn btn-link" type="button" data-toggle="collapse" data-target="#collapseOne" aria-expanded="true" aria-controls="collapseOne"> + <i class="fa fa-users"></i> Membres du club + </button> + </h5> + </div> + + <div id="collapseOne" class="collapse show" aria-labelledby="headingOne" data-parent="#accordionExample"> + <div class="card-body"> + + {% render_table member_list %} + </div> + </div> + </div> + <div class="card"> + <div class="card-header" id="headingTwo"> + <h5 class="mb-0"> + <button class="btn btn-link collapsed" type="button" data-toggle="collapse" data-target="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo"> + <i class="fa fa-euro"></i> Historique des transactions + </button> + </h5> + </div> + <div id="collapseTwo" class="collapse" aria-labelledby="headingTwo" data-parent="#accordionExample"> + <div class="card-body"> + {% render_table history_list %} + </div> + </div> + </div> +</div> + {% endblock %} diff --git a/templates/member/club_list.html b/templates/member/club_list.html index dc1608f27499845a695a56bed05584bfe9c7243d..88b2489e018d938a926f92227a6b5742c792ac22 100644 --- a/templates/member/club_list.html +++ b/templates/member/club_list.html @@ -2,7 +2,20 @@ {% load render_table from django_tables2 %} {% block content %} -{% render_table object_list %} +{% render_table table %} <a class="btn btn-primary" href="{% url 'member:club_create' %}">New Club</a> + +{% endblock %} +{% block javascript %} +{{ block.super }} +<script type="text/javascript"> + +$(document).ready(function($) { + $(".table-row").click(function() { + window.document.location = $(this).data("href"); + }); +}); + +</script> {% endblock %}