From c384ee02ebfe24e817655f316f901351739e4b40 Mon Sep 17 00:00:00 2001
From: Yohann D'ANELLO <yohann.danello@gmail.com>
Date: Tue, 31 Mar 2020 01:03:30 +0200
Subject: [PATCH] Implement a new type of note (see #45)

---
 apps/member/forms.py                    | 28 +++++++-
 apps/member/urls.py                     | 16 ++++-
 apps/member/views.py                    | 94 ++++++++++++++++++++++---
 apps/note/admin.py                      | 12 +++-
 apps/note/api/serializers.py            | 21 +++++-
 apps/note/models/notes.py               | 36 ++++++++++
 apps/note/tables.py                     | 20 +++++-
 static/js/base.js                       |  2 +-
 templates/member/club_detail.html       |  9 +++
 templates/member/club_info.html         |  7 +-
 templates/member/noteowner_detail.html  |  2 +-
 templates/note/noteactivity_detail.html | 57 +++++++++++++++
 templates/note/noteactivity_form.html   | 16 +++++
 templates/note/noteactivity_list.html   | 27 +++++++
 14 files changed, 326 insertions(+), 21 deletions(-)
 create mode 100644 templates/note/noteactivity_detail.html
 create mode 100644 templates/note/noteactivity_form.html
 create mode 100644 templates/note/noteactivity_list.html

diff --git a/apps/member/forms.py b/apps/member/forms.py
index 20f0acfe..d731c10c 100644
--- a/apps/member/forms.py
+++ b/apps/member/forms.py
@@ -7,7 +7,8 @@ from crispy_forms.layout import Layout
 from django import forms
 from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
 from django.contrib.auth.models import User
-from note_kfet.inputs import Autocomplete
+from note.models.notes import NoteActivity
+from note_kfet.inputs import Autocomplete, AmountInput
 from permission.models import PermissionMask
 
 from .models import Profile, Club, Membership
@@ -47,6 +48,31 @@ class ClubForm(forms.ModelForm):
     class Meta:
         model = Club
         fields = '__all__'
+        widgets = {
+            "membership_fee": AmountInput()
+        }
+
+
+class NoteActivityForm(forms.ModelForm):
+    class Meta:
+        model = NoteActivity
+        fields = ('note_name', 'club', 'controller', )
+        widgets = {
+            "club": Autocomplete(
+                Club,
+                attrs={
+                    'api_url': '/api/members/club/',
+                }
+            ),
+            "controller": Autocomplete(
+                User,
+                attrs={
+                    'api_url': '/api/user/',
+                    'name_field': 'username',
+                    'placeholder': 'Nom ...',
+                }
+            )
+        }
 
 
 class AddMembersForm(forms.Form):
diff --git a/apps/member/urls.py b/apps/member/urls.py
index 085a3fec..f1a5c2bd 100644
--- a/apps/member/urls.py
+++ b/apps/member/urls.py
@@ -8,13 +8,23 @@ from . import views
 app_name = 'member'
 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('club/<int:pk>/update', views.ClubUpdateView.as_view(), name="club_update"),
-    path('club/<int:pk>/update_pic', views.ClubPictureUpdateView.as_view(), name="club_update_pic"),
-    path('club/<int:pk>/aliases', views.ClubAliasView.as_view(), name="club_alias"),
+    path('club/<int:pk>/update/', views.ClubUpdateView.as_view(), name="club_update"),
+    path('club/<int:pk>/update_pic/', views.ClubPictureUpdateView.as_view(), name="club_update_pic"),
+    path('club/<int:pk>/aliases/', views.ClubAliasView.as_view(), name="club_alias"),
+    path('club/<int:pk>/linked_notes/', views.ClubLinkedNotesView.as_view(),
+         name="club_linked_note_list"),
+    path('club/<int:club_pk>/linked_notes/create/', views.ClubLinkedNoteCreateView.as_view(),
+         name="club_linked_note_create"),
+    path('club/<int:club_pk>/linked_notes/<int:pk>/', views.ClubLinkedNoteDetailView.as_view(),
+         name="club_linked_note_detail"),
+    path('club/<int:club_pk>/linked_notes/<int:pk>/update/', views.ClubLinkedNoteUpdateView.as_view(),
+         name="club_linked_note_update"),
+
     path('user/', views.UserListView.as_view(), name="user_list"),
     path('user/<int:pk>', views.UserDetailView.as_view(), name="user_detail"),
     path('user/<int:pk>/update', views.UserUpdateView.as_view(), name="user_update_profile"),
diff --git a/apps/member/views.py b/apps/member/views.py
index 8145b5e9..e8bde67d 100644
--- a/apps/member/views.py
+++ b/apps/member/views.py
@@ -18,13 +18,14 @@ from django_tables2.views import SingleTableView
 from rest_framework.authtoken.models import Token
 from note.forms import ImageForm
 from note.models import Alias, NoteUser
+from note.models.notes import NoteActivity
 from note.models.transactions import Transaction
-from note.tables import HistoryTable, AliasTable
+from note.tables import HistoryTable, AliasTable, NoteActivityTable
 from permission.backends import PermissionBackend
 
 from .filters import UserFilter, UserFilterFormHelper
 from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, MemberFormSet, FormSetHelper, \
-    CustomAuthenticationForm
+    CustomAuthenticationForm, NoteActivityForm
 from .models import Club, Membership
 from .tables import ClubTable, UserTable
 
@@ -134,7 +135,8 @@ class UserDetailView(LoginRequiredMixin, DetailView):
         context = super().get_context_data(**kwargs)
         user = context['user_object']
         history_list = \
-            Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note)).order_by("-id")
+            Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note)).order_by("-id")\
+            .filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view"))
         context['history_list'] = HistoryTable(history_list)
         club_list = \
             Membership.objects.all().filter(user=user).only("club")
@@ -179,8 +181,8 @@ class ProfileAliasView(LoginRequiredMixin, DetailView):
 class PictureUpdateView(LoginRequiredMixin, FormMixin, DetailView):
     form_class = ImageForm
 
-    def get_context_data(self, *args, **kwargs):
-        context = super().get_context_data(*args, **kwargs)
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
         context['form'] = self.form_class(self.request.POST, self.request.FILES)
         return context
 
@@ -290,8 +292,8 @@ class ClubDetailView(LoginRequiredMixin, DetailView):
     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))
+        club_transactions = Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note))\
+            .filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view")).order_by('-id')
         context['history_list'] = HistoryTable(club_transactions)
         club_member = \
             Membership.objects.all().filter(club=club)
@@ -317,7 +319,9 @@ class ClubUpdateView(LoginRequiredMixin, UpdateView):
     context_object_name = "club"
     form_class = ClubForm
     template_name = "member/club_form.html"
-    success_url = reverse_lazy("member:club_detail")
+
+    def get_success_url(self):
+        return reverse_lazy("member:club_detail", kwargs={"pk": self.object.pk})
 
 
 class ClubPictureUpdateView(PictureUpdateView):
@@ -361,3 +365,77 @@ class ClubAddMemberView(LoginRequiredMixin, CreateView):
     def form_valid(self, formset):
         formset.save()
         return super().form_valid(formset)
+
+
+class ClubLinkedNotesView(LoginRequiredMixin, SingleTableView):
+    model = NoteActivity
+    table_class = NoteActivityTable
+
+    def get_queryset(self):
+        return super().get_queryset().filter(club=self.get_object())\
+            .filter(PermissionBackend.filter_queryset(self.request.user, NoteActivity, "view"))
+
+    def get_object(self):
+        if hasattr(self, 'object'):
+            return self.object
+        self.object = Club.objects.get(pk=int(self.kwargs["pk"]))
+        return self.object
+
+    def get_context_data(self, **kwargs):
+        ctx = super().get_context_data(**kwargs)
+
+        ctx["object"] = ctx["club"] = self.get_object()
+
+        return ctx
+
+
+class ClubLinkedNoteCreateView(LoginRequiredMixin, CreateView):
+    model = NoteActivity
+    form_class = NoteActivityForm
+
+    def get_context_data(self, **kwargs):
+        ctx = super().get_context_data(**kwargs)
+
+        club = Club.objects.get(pk=self.kwargs["club_pk"])
+        ctx["object"] = ctx["club"] = club
+        ctx["form"].fields["club"].initial = club
+
+        return ctx
+
+    def get_success_url(self):
+        self.object.refresh_from_db()
+        return reverse_lazy('member:club_linked_note_detail',
+                            kwargs={"club_pk": self.object.club.pk, "pk": self.object.pk})
+
+
+class ClubLinkedNoteUpdateView(LoginRequiredMixin, UpdateView):
+    model = NoteActivity
+    form_class = NoteActivityForm
+
+    def get_context_data(self, **kwargs):
+        ctx = super().get_context_data(**kwargs)
+
+        ctx["club"] = Club.objects.get(pk=self.kwargs["club_pk"])
+
+        return ctx
+
+    def get_success_url(self):
+        return reverse_lazy('member:club_linked_note_detail',
+                            kwargs={"club_pk": self.object.club.pk, "pk": self.object.pk})
+
+
+class ClubLinkedNoteDetailView(LoginRequiredMixin, DetailView):
+    model = NoteActivity
+
+    def get_context_data(self, **kwargs):
+        ctx = super().get_context_data(**kwargs)
+
+        note = NoteActivity.objects.get(pk=self.kwargs["pk"])
+
+        transactions = Transaction.objects.all().filter(Q(source=note) | Q(destination=note))\
+            .filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view")).order_by("-id")
+        ctx['history_list'] = HistoryTable(transactions)
+        ctx["note"] = note
+        ctx["club"] = note.club
+
+        return ctx
diff --git a/apps/note/admin.py b/apps/note/admin.py
index 702d3350..f0dede17 100644
--- a/apps/note/admin.py
+++ b/apps/note/admin.py
@@ -6,7 +6,7 @@ from django.utils.translation import gettext_lazy as _
 from polymorphic.admin import PolymorphicChildModelAdmin, \
     PolymorphicChildModelFilter, PolymorphicParentModelAdmin
 
-from .models.notes import Alias, Note, NoteClub, NoteSpecial, NoteUser
+from .models.notes import Alias, Note, NoteClub, NoteSpecial, NoteUser, NoteActivity
 from .models.transactions import Transaction, TemplateCategory, TransactionTemplate, \
     RecurrentTransaction, MembershipTransaction
 
@@ -24,7 +24,7 @@ class NoteAdmin(PolymorphicParentModelAdmin):
     """
     Parent regrouping all note types as children
     """
-    child_models = (NoteClub, NoteSpecial, NoteUser)
+    child_models = (NoteClub, NoteSpecial, NoteUser, NoteActivity)
     list_filter = (
         PolymorphicChildModelFilter,
         'is_active',
@@ -74,6 +74,14 @@ class NoteSpecialAdmin(PolymorphicChildModelAdmin):
     readonly_fields = ('balance',)
 
 
+@admin.register(NoteActivity)
+class NoteActivityAdmin(PolymorphicChildModelAdmin):
+    """
+    Child for a special note, see NoteAdmin
+    """
+    readonly_fields = ('balance',)
+
+
 @admin.register(NoteUser)
 class NoteUserAdmin(PolymorphicChildModelAdmin):
     """
diff --git a/apps/note/api/serializers.py b/apps/note/api/serializers.py
index fbd12038..a445fef9 100644
--- a/apps/note/api/serializers.py
+++ b/apps/note/api/serializers.py
@@ -4,7 +4,7 @@
 from rest_framework import serializers
 from rest_polymorphic.serializers import PolymorphicSerializer
 
-from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias
+from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias, NoteActivity
 from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \
     RecurrentTransaction, SpecialTransaction
 
@@ -69,6 +69,22 @@ class NoteUserSerializer(serializers.ModelSerializer):
         return str(obj)
 
 
+class NoteActivitySerializer(serializers.ModelSerializer):
+    """
+    REST API Serializer for User's notes.
+    The djangorestframework plugin will analyse the model `NoteActivity` and parse all fields in the API.
+    """
+    name = serializers.SerializerMethodField()
+
+    class Meta:
+        model = NoteActivity
+        fields = '__all__'
+        read_only_fields = ('note', 'user', )
+
+    def get_name(self, obj):
+        return str(obj)
+
+
 class AliasSerializer(serializers.ModelSerializer):
     """
     REST API Serializer for Aliases.
@@ -90,7 +106,8 @@ class NotePolymorphicSerializer(PolymorphicSerializer):
         Note: NoteSerializer,
         NoteUser: NoteUserSerializer,
         NoteClub: NoteClubSerializer,
-        NoteSpecial: NoteSpecialSerializer
+        NoteSpecial: NoteSpecialSerializer,
+        NoteActivity: NoteActivitySerializer,
     }
 
     class Meta:
diff --git a/apps/note/models/notes.py b/apps/note/models/notes.py
index 9282bde9..64ec524f 100644
--- a/apps/note/models/notes.py
+++ b/apps/note/models/notes.py
@@ -4,11 +4,13 @@
 import unicodedata
 
 from django.conf import settings
+from django.contrib.auth.models import User
 from django.core.exceptions import ValidationError
 from django.core.validators import RegexValidator
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 from polymorphic.models import PolymorphicModel
+from member.models import Club
 
 """
 Defines each note types
@@ -174,6 +176,40 @@ class NoteSpecial(Note):
         return self.special_type
 
 
+class NoteActivity(Note):
+    """
+    A :model:`note.Note` for accounts that are not attached to a user neither to a club,
+    that only need to store and transfer money (notes for activities, departments, ...)
+    """
+
+    note_name = models.CharField(
+        verbose_name=_('name'),
+        max_length=255,
+        unique=True,
+    )
+
+    club = models.ForeignKey(
+        Club,
+        on_delete=models.PROTECT,
+        related_name="linked_notes",
+        verbose_name=_("club"),
+    )
+
+    controller = models.ForeignKey(
+        User,
+        on_delete=models.PROTECT,
+        related_name="+",
+        verbose_name=_("controller"),
+    )
+
+    class Meta:
+        verbose_name = _("common note")
+        verbose_name_plural = _("common notes")
+
+    def __str__(self):
+        return self.note_name
+
+
 class Alias(models.Model):
     """
     points toward  a :model:`note.NoteUser` or :model;`note.NoteClub` instance.
diff --git a/apps/note/tables.py b/apps/note/tables.py
index 0d83e3cc..2aba4684 100644
--- a/apps/note/tables.py
+++ b/apps/note/tables.py
@@ -9,7 +9,7 @@ from django.utils.html import format_html
 from django_tables2.utils import A
 from django.utils.translation import gettext_lazy as _
 
-from .models.notes import Alias
+from .models.notes import Alias, NoteActivity
 from .models.transactions import Transaction, TransactionTemplate
 from .templatetags.pretty_money import pretty_money
 
@@ -121,6 +121,24 @@ class AliasTable(tables.Table):
                                        attrs={'td': {'class': 'col-sm-1'}})
 
 
+class NoteActivityTable(tables.Table):
+    note_name = tables.LinkColumn(
+        "member:club_linked_note_detail",
+        args=[A("club.pk"), A("pk")],
+    )
+
+    def render_balance(self, value):
+        return pretty_money(value)
+
+    class Meta:
+        attrs = {
+            'class': 'table table-condensed table-striped table-hover'
+        }
+        model = NoteActivity
+        fields = ('note_name', 'balance',)
+        template_name = 'django_tables2/bootstrap4.html'
+
+
 class ButtonTable(tables.Table):
     class Meta:
         attrs = {
diff --git a/static/js/base.js b/static/js/base.js
index 22d1366a..7febd3d6 100644
--- a/static/js/base.js
+++ b/static/js/base.js
@@ -70,7 +70,7 @@ function refreshBalance() {
  * @param fun For each found note with the matched alias `alias`, fun(note, alias) is called.
  */
 function getMatchedNotes(pattern, fun) {
-    $.getJSON("/api/note/alias/?format=json&alias=" + pattern + "&search=user|club&ordering=normalized_name", fun);
+    $.getJSON("/api/note/alias/?format=json&alias=" + pattern + "&search=user|club|activity&ordering=normalized_name", fun);
 }
 
 /**
diff --git a/templates/member/club_detail.html b/templates/member/club_detail.html
index 979c0897..3ad29901 100644
--- a/templates/member/club_detail.html
+++ b/templates/member/club_detail.html
@@ -7,3 +7,12 @@
 {% block profile_content %}
 {% include "member/club_tables.html" %}
 {% endblock %}
+
+{% block extrajavascript %}
+    <script>
+    function refreshHistory() {
+        $("#history_list").load("{% url 'member:club_detail' pk=object.pk %} #history_list");
+        $("#profile_infos").load("{% url 'member:club_detail' pk=object.pk %} #profile_infos");
+    }
+    </script>
+{% endblock %}
diff --git a/templates/member/club_info.html b/templates/member/club_info.html
index 1c8e8661..907914be 100644
--- a/templates/member/club_info.html
+++ b/templates/member/club_info.html
@@ -34,7 +34,10 @@
             <dd class="col-xl-6 text-truncate">{{ object.note.alias_set.all|join:", " }}</dd>
 
             <dt class="col-xl-3">{% trans 'email'|capfirst %}</dt>
-            <dd class="col-xl-9"><a href="mailto:{{ club.email }}">{{ club.email}}</a></dd>
+            <dd class="col-xl-9"><a href="mailto:{{ club.email }}">{{ club.email }}</a></dd>
+
+            <dt class="col-xl-6"><a href="{% url 'member:club_linked_note_list' pk=club.pk %}">{% trans 'linked notes'|capfirst %}</a></dt>
+            <dd class="col-xl-6 text-truncate">{{ club.linked_notes.all|join:", " }}</dd>
         </dl>
     </div>
     <div class="card-footer text-center">
@@ -43,6 +46,6 @@
         <a class="btn btn-primary btn-sm my-1" href="{% url 'member:club_add_member' pk=club.pk %}"> {% trans "Add roles" %}</a>
         {% url 'member:club_detail' club.pk as club_detail_url %}
         {%if request.get_full_path != club_detail_url %}
-        <a class="btn btn-primary btn-sm my-1" href="{{ user_profile_url }}">{% trans 'View Profile' %}</a>
+        <a class="btn btn-primary btn-sm my-1" href="{{ club_detail_url }}">{% trans 'View Profile' %}</a>
         {% endif %}    </div>
 </div>
diff --git a/templates/member/noteowner_detail.html b/templates/member/noteowner_detail.html
index ad329aee..fc781549 100644
--- a/templates/member/noteowner_detail.html
+++ b/templates/member/noteowner_detail.html
@@ -19,7 +19,7 @@
 
 {% block extrajavascript %}
     <script>
-    function refreshhistory() {
+    function refreshHistory() {
         $("#history_list").load("{% url 'member:user_detail' pk=object.pk %} #history_list");
         $("#profile_infos").load("{% url 'member:user_detail' pk=object.pk %} #profile_infos");
     }
diff --git a/templates/note/noteactivity_detail.html b/templates/note/noteactivity_detail.html
new file mode 100644
index 00000000..731707f6
--- /dev/null
+++ b/templates/note/noteactivity_detail.html
@@ -0,0 +1,57 @@
+{% extends "member/noteowner_detail.html" %}
+
+{% load i18n %}
+{% load render_table from django_tables2 %}
+{% load pretty_money %}
+
+{% block profile_info %}
+{% include "member/club_info.html" %}
+{% endblock %}
+
+{% block profile_content %}
+    <div id="activity_info" class="card bg-light shadow">
+        <div class="card-header text-center">
+            <h4>{% trans "Linked note:" %} {{ note.note_name }}</h4>
+        </div>
+        <div class="card-body" id="profile_infos">
+            <dl class="row">
+                <dt class="col-xl-6">{% trans 'attached club'|capfirst %}</dt>
+                <dd class="col-xl-6"><a href="{% url 'member:club_detail' pk=club.pk %}">{{ club }}</a></dd>
+
+                <dt class="col-xl-6">{% trans 'controller'|capfirst %}</dt>
+                <dd class="col-xl-6"><a href="{% url 'member:user_detail' pk=note.controller.pk %}">{{ note.controller }}</a></dd>
+
+                <dt class="col-xl-6">{% trans 'balance'|capfirst %}</dt>
+                <dd class="col-xl-6">{{ note.balance|pretty_money }}</dd>
+            </dl>
+
+            <div class="card-footer text-center">
+                <a class="btn btn-primary btn-sm my-1" href="{% url 'member:club_linked_note_update' club_pk=club.pk pk=note.pk %}"> {% trans "Edit" %}</a>
+            </div>
+        </div>
+
+
+        <div class="card">
+            <div class="card-header position-relative" id="historyListHeading">
+                <a class="btn btn-link stretched-link collapsed font-weight-bold"
+                   data-toggle="collapse" data-target="#historyListCollapse"
+                   aria-expanded="false" aria-controls="historyListCollapse">
+                    <i class="fa fa-euro"></i> {% trans "Transaction history" %}
+                </a>
+            </div>
+            <div id="historyListCollapse" aria-labelledby="historyListHeading" data-parent="#accordionProfile">
+                <div id="history_list">
+                    {% render_table history_list %}
+                </div>
+            </div>
+        </div>
+{% endblock %}
+
+{% block extrajavascript %}
+    <script>
+    function refreshHistory() {
+        $("#history_list").load("{% url 'member:club_linked_note_detail' club_pk=club.pk pk=note.pk %} #history_list");
+        $("#profile_infos").load("{% url 'member:club_detail' pk=club.pk%} #profile_infos");
+    }
+    </script>
+{% endblock %}
diff --git a/templates/note/noteactivity_form.html b/templates/note/noteactivity_form.html
new file mode 100644
index 00000000..5088c790
--- /dev/null
+++ b/templates/note/noteactivity_form.html
@@ -0,0 +1,16 @@
+{% extends "member/noteowner_detail.html" %}
+
+{% load i18n %}
+{% load crispy_forms_tags %}
+
+{% block profile_info %}
+{% include "member/club_info.html" %}
+{% endblock %}
+
+{% block profile_content %}
+    <form method="post">
+    {% csrf_token %}
+    {{ form|crispy }}
+    <button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
+    </form>
+{% endblock %}
diff --git a/templates/note/noteactivity_list.html b/templates/note/noteactivity_list.html
new file mode 100644
index 00000000..b4d69536
--- /dev/null
+++ b/templates/note/noteactivity_list.html
@@ -0,0 +1,27 @@
+{% extends "member/noteowner_detail.html" %}
+
+{% load i18n %}
+{% load render_table from django_tables2 %}
+
+{% block profile_info %}
+{% include "member/club_info.html" %}
+{% endblock %}
+
+{% block profile_content %}
+<div class="row justify-content-center">   
+    <div class="col-md-10">
+        <div class="card card-border shadow">
+            <div class="card-header text-center">
+                <h5> {% trans "linked notes of club"|capfirst %} {{ club.name }}</h5>
+            </div>
+            <div class="card-body px-0 py-0" id="club_table">
+                {% render_table table %}
+            </div>
+        </div>
+
+        <a href="{% url 'member:club_linked_note_create' club_pk=club.pk %}">
+            <button class="btn btn-primary btn-block">{% trans "Add new note" %}</button>
+        </a>
+    </div>
+</div>
+{% endblock %}
-- 
GitLab