diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/admin.py b/core/admin.py new file mode 100644 index 0000000..d489ae4 --- /dev/null +++ b/core/admin.py @@ -0,0 +1,14 @@ +from django.contrib import admin +from .models import (Enregistrement, Etiquette) + +# Register your models here. + +@admin.register(Etiquette) +class EtiquetteAdmin(admin.ModelAdmin): + pass + +@admin.register(Enregistrement) +class EnregistrementAdmin(admin.ModelAdmin): + pass + + diff --git a/core/apps.py b/core/apps.py new file mode 100644 index 0000000..26f78a8 --- /dev/null +++ b/core/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + name = 'core' diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 0000000..22e8450 --- /dev/null +++ b/core/forms.py @@ -0,0 +1,25 @@ +from django import forms +from .models import Enregistrement + +class EnregistrementForm(forms.ModelForm): + est_negatif = forms.BooleanField(required=False) + + class Meta: + model = Enregistrement + fields = ('montant', 'description', 'date', 'etiquette') + widgets = {'etiquette': forms.Select( + attrs={'class':'form-control custom-select'} + ), + 'date': forms.SelectDateWidget( + attrs={'class':'form-control custom-select mx-1'} + ) + } + + def clean(self): + data = super().clean() + print(data) + # Force un nombre négatif si 'est_negatif' est coché + if (data['est_negatif'] + and data['montant'] > 0): + self.cleaned_data['montant'] = 0 - data['montant'] + return self.cleaned_data diff --git a/core/management/commands/import_csv.py b/core/management/commands/import_csv.py new file mode 100644 index 0000000..9a93c75 --- /dev/null +++ b/core/management/commands/import_csv.py @@ -0,0 +1,45 @@ +import csv +import collections +from django.core.management.base import BaseCommand, CommandError +from core.models import Enregistrement, Etiquette + +class Command(BaseCommand): + help = "Importe des enregistrement à partir d'un fichier .csv" + mapping = ("date", "description", "etiquette", "montant") + + def add_arguments(self, parser): + parser.add_argument("files", nargs="+", type=str) + parser.add_argument("-t", "--test", action="store_true") + + def handle(self, *args, **options): + header = "== Importation au format .csv" + if options['test']: + header += " (test)" + header += " ==\n%s" % (self.mapping,) + self.stdout.write(self.style.MIGRATE_HEADING(header)) + + for filepath in options['files']: + with open(filepath, 'r') as f: + self.stdout.write(self.style.WARNING("[-] %s " % filepath), ending='') + reader = csv.reader(f) + results = list() + for row in reader: + result = collections.OrderedDict() + for i, field in enumerate(self.mapping): + result[field] = row[i] + results.append(result) + self.stdout.write(self.style.SUCCESS("Ok (%i results):" % len(results))) + + for result in results: + output = ", ".join(map(lambda t: "%s:%s" % (t[0][0:4], t[1]), result.items())) + if not options['test']: + # Création d'un nouvel enregistrement + date = result['date'] # TODO: format date ! + etiquette, created = Etiquette.objects.get_or_create(nom=result['etiquette']) + Enregistrement.objects.create(date=result['date'], + description=result['description'], + etiquette=etiquette, + montant=result['montant']) + output = "+" + output + if created: output += "(added %s)" % etiquette + self.stdout.write(self.style.SUCCESS(" " + output)) diff --git a/core/models.py b/core/models.py new file mode 100644 index 0000000..02be1d7 --- /dev/null +++ b/core/models.py @@ -0,0 +1,125 @@ +import datetime +from collections import defaultdict +from django.db import models + +# Create your models here. + +class Etiquette(models.Model): + nom = models.CharField(max_length=128, unique=True) + + def __str__(self): + return "%s" % self.nom + + +class MonthManager(models.Manager): + + + def get_queryset_for_month(self, month, year): + return super().get_queryset().filter(date__month=month, + date__year=year) + + def get_queryset(self, month=None, year=None): + """ Add custom 'month' and 'year' keyword arguments, used as filters. + Without argument, simply proxy to parent method. + + This method checks if month is newly created (first access). If + so, it spawns recursive records for this month. + """ + # Generic call + if not month and not year: + return super().get_queryset() + # Custom call + elif month and year: + recursifs = EnregistrementRecursif.objects.all() + if (not self.get_queryset_for_month(month, year).exists() + and recursifs.exists()): + print("LOG: Month is created so we populated it...") + for r in recursifs: + r.spawn(month, year) + return self.get_queryset_for_month(month, year) + # Invalid call + else: + raise TypeError( +"You must call with either none or \ +both 'month' and 'year' keyword arguments") + + def _populate_data(self, qs, data): + + def somme_par_etiquette(qs): + sommes = defaultdict(lambda:0) + for el in qs: + sommes[el.etiquette.nom] += el.montant + return dict(sommes) + + data['balance'] = sum(map(lambda t: t[0], qs.values_list('montant'))) + data['par_etiquette'] = somme_par_etiquette(qs) + + return data + + def calculate_data(self, qs): + #TODO: cached results + return self._populate_data(qs, {}) + + def get_month_with_data(self, month, year): + """ Retrieve the queryset for given month and year, plus + some data calculated on the set """ + + qs = self.get_queryset(month=month, year=year) + data = self.calculate_data(qs) + print(qs, data) + return qs, data + + +class Enregistrement(models.Model): + objects = MonthManager() + + date = models.DateField() + montant = models.FloatField() + etiquette = models.ForeignKey(Etiquette) + description = models.CharField(max_length=512) + + def __str__(self): + return "" % self.etiquette + + +class EnregistrementRecursif(models.Model): + jour = models.IntegerField() + montant = models.FloatField() + etiquette = models.ForeignKey(Etiquette) + description = models.CharField(max_length=512) + created_date = models.DateField() + + def is_older(self, month, year): + return (self.created_date.month <= month + and self.created_date.year <= year) + + def spawn(self, month, year, commit=True): + """ Spawn a new Enregistrement for given month and year. """ + + # Do not spawn for dates older than this instance creation date + if not self.is_older(month, year): + return None + # Create new Enregistrement from this instance values + date = datetime.date(year, month, self.jour) + new_object, created = Enregistrement.objects.get_or_create(date=date, + montant=self.montant, + etiquette=self.etiquette, + description=self.description) + if created and commit: + new_object.save() + return new_object + + def save(self, **kwargs): + """ Update 'created_date' on each save, this avoids conflict with older + spawned instances. + """ + self.created_date = tz.now().date() + return super().save(**kwargs) + + def __str__(self): + return "<[%i] %s (%s) : %s {%s}>" % (self.jour, + self.description, + self.etiquette, + self.montant, + self.created_date) + diff --git a/core/static/activity.svg b/core/static/activity.svg new file mode 100644 index 0000000..b5a1aaf --- /dev/null +++ b/core/static/activity.svg @@ -0,0 +1 @@ + diff --git a/core/static/chevrons-left.svg b/core/static/chevrons-left.svg new file mode 100644 index 0000000..ba56ec6 --- /dev/null +++ b/core/static/chevrons-left.svg @@ -0,0 +1 @@ + diff --git a/core/static/chevrons-right.svg b/core/static/chevrons-right.svg new file mode 100644 index 0000000..b3f2840 --- /dev/null +++ b/core/static/chevrons-right.svg @@ -0,0 +1 @@ + diff --git a/core/static/delete.svg b/core/static/delete.svg new file mode 100644 index 0000000..8c6074b --- /dev/null +++ b/core/static/delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/core/static/edit.svg b/core/static/edit.svg new file mode 100644 index 0000000..ed7fdfd --- /dev/null +++ b/core/static/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/core/static/minus-circle.svg b/core/static/minus-circle.svg new file mode 100644 index 0000000..80c0de1 --- /dev/null +++ b/core/static/minus-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/core/static/minus-square.svg b/core/static/minus-square.svg new file mode 100644 index 0000000..4862832 --- /dev/null +++ b/core/static/minus-square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/core/static/plus.svg b/core/static/plus.svg new file mode 100644 index 0000000..d6bf701 --- /dev/null +++ b/core/static/plus.svg @@ -0,0 +1 @@ + diff --git a/core/static/repeat.svg b/core/static/repeat.svg new file mode 100644 index 0000000..3274881 --- /dev/null +++ b/core/static/repeat.svg @@ -0,0 +1 @@ + diff --git a/core/static/rewind.svg b/core/static/rewind.svg new file mode 100644 index 0000000..7b0fa3d --- /dev/null +++ b/core/static/rewind.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/core/static/tag.svg b/core/static/tag.svg new file mode 100644 index 0000000..f6be70a --- /dev/null +++ b/core/static/tag.svg @@ -0,0 +1 @@ + diff --git a/core/templates/core/base.html b/core/templates/core/base.html new file mode 100644 index 0000000..563642b --- /dev/null +++ b/core/templates/core/base.html @@ -0,0 +1,43 @@ +{% load bootstrap4 staticfiles %} + + + + + + {% bootstrap_css %} + {% bootstrap_javascript jquery=True %} + + + + +
+ + {% block body_content %} +Page vide. + {% endblock %} +
+ + diff --git a/core/templates/core/confirm_delete.html b/core/templates/core/confirm_delete.html new file mode 100644 index 0000000..52dca01 --- /dev/null +++ b/core/templates/core/confirm_delete.html @@ -0,0 +1,12 @@ +{% extends "core/base.html" %} + +{% block page_title %} + Suppression de {{ object}} +{% endblock %} + +{% block body_content %} +
{% csrf_token %} +

Êtes-vous sûr de vouloir supprimer définitivement {{ object }} ?

+ +
+{% endblock %} diff --git a/core/templates/core/enregistrement_add.html b/core/templates/core/enregistrement_add.html new file mode 100644 index 0000000..d9b6bbe --- /dev/null +++ b/core/templates/core/enregistrement_add.html @@ -0,0 +1,37 @@ +{% extends "core/base.html" %} +{% load bootstrap4 staticfiles %} + +{% block page_title %} + Ajouter une écriture +{% endblock %} + +{% block body_content %} +
+ {% csrf_token %} +
+
+ +
+ + {{ form.est_negatif}} + + +
+
+ {% bootstrap_field form.description form_group_class="form-group col-8" %} +
+
+
+ +
{{ form.date }}
+
+
+ + {{ form.etiquette }} +
+
+
+ +
+
+{% endblock %} diff --git a/core/templates/core/enregistrement_update_form.html b/core/templates/core/enregistrement_update_form.html new file mode 100644 index 0000000..ca15993 --- /dev/null +++ b/core/templates/core/enregistrement_update_form.html @@ -0,0 +1,14 @@ +{% extends "core/base.html" %} +{% load bootstrap4 %} + +{% block page_title %} + Mise à jour de {{ object }} +{% endblock %} + +{% block body_content %} +
+ {% csrf_token %} + {% bootstrap_form form %} + +
+{% endblock %} diff --git a/core/templates/core/enregistrementrecursif_add_form.html b/core/templates/core/enregistrementrecursif_add_form.html new file mode 100644 index 0000000..41c0357 --- /dev/null +++ b/core/templates/core/enregistrementrecursif_add_form.html @@ -0,0 +1,14 @@ +{% extends "core/base.html" %} +{% load bootstrap4 %} + +{% block page_title %} + Ajouter un enregistrement récursif +{% endblock %} + +{% block body_content %} +
+ {% csrf_token %} + {% bootstrap_form form %} + +
+{% endblock %} diff --git a/core/templates/core/enregistrementrecursif_update_form.html b/core/templates/core/enregistrementrecursif_update_form.html new file mode 100644 index 0000000..ca15993 --- /dev/null +++ b/core/templates/core/enregistrementrecursif_update_form.html @@ -0,0 +1,14 @@ +{% extends "core/base.html" %} +{% load bootstrap4 %} + +{% block page_title %} + Mise à jour de {{ object }} +{% endblock %} + +{% block body_content %} +
+ {% csrf_token %} + {% bootstrap_form form %} + +
+{% endblock %} diff --git a/core/templates/core/etiquette_add_form.html b/core/templates/core/etiquette_add_form.html new file mode 100644 index 0000000..600ecca --- /dev/null +++ b/core/templates/core/etiquette_add_form.html @@ -0,0 +1,14 @@ +{% extends "core/base.html" %} +{% load bootstrap4 %} + +{% block page_title %} + Ajouter une étiquette +{% endblock %} + +{% block body_content %} +
+ {% csrf_token %} + {% bootstrap_form form %} + +
+{% endblock %} diff --git a/core/templates/core/etiquette_list.html b/core/templates/core/etiquette_list.html new file mode 100644 index 0000000..d00c7c7 --- /dev/null +++ b/core/templates/core/etiquette_list.html @@ -0,0 +1,23 @@ +{% extends "core/base.html" %} +{% load staticfiles %} + +{% block page_title%} + Liste des étiquettes +{% endblock %} + +{% block body_content %} + + + + +{% for etiquette in object_list %} + + + + +{% endfor %} + +{% endblock %} diff --git a/core/templates/core/etiquette_update_form.html b/core/templates/core/etiquette_update_form.html new file mode 100644 index 0000000..c7b6e22 --- /dev/null +++ b/core/templates/core/etiquette_update_form.html @@ -0,0 +1,14 @@ +{% extends "core/base.html" %} +{% load bootstrap4 %} + +{% block page_title %} + Mise à jour de "{{ object }}" +{% endblock %} + +{% block body_content %} + + {% csrf_token %} + {% bootstrap_form form %} + + +{% endblock %} diff --git a/core/templates/core/index.html b/core/templates/core/index.html new file mode 100644 index 0000000..f6969e9 --- /dev/null +++ b/core/templates/core/index.html @@ -0,0 +1,62 @@ +{% extends "core/base.html" %} +{% load bootstrap4 staticfiles %} +{% block page_title %} + + Comptes de {{mois}} {{annee}} + + {% if next_month_url %} + + {% endif %} +{% endblock %} +{% block body_content %} +
+ {% bootstrap_messages %} +
+ {% if not object_list %} +

Aucune donnée

+ {% else %} +
NomActions
{{ etiquette }} + Éditer + Supprimer +
+ + + + + + + + + {% for d in object_list %} + + + + + + + + {% endfor %} +
DateDescriptionEtiquetteMontantActions
{{ d.date }}{{ d.description }}{{ d.etiquette }} + {{ d.montant }} + + + +
+ {% endif %} + +
+

Balance + {{ data.balance }} € +

+
+
+ Dépenses +
+
    + {% for label, montant in data.par_etiquette.items %} +
  • {{ label }} + {{ montant }} € +
  • + {% endfor %} +
+
+
+ +{% endblock %} diff --git a/core/templates/core/recursif_list.html b/core/templates/core/recursif_list.html new file mode 100644 index 0000000..57f817e --- /dev/null +++ b/core/templates/core/recursif_list.html @@ -0,0 +1,28 @@ +{% extends "core/base.html" %} +{% load staticfiles %} + +{% block page_title %} + Enregistrement récursif +{% endblock %} + +{% block body_content %} + + + + + + + + + + {% for enr_rec in object_list %} + + + + + + + {% endfor %} +
JourDescriptionÉtiquetteMontantActions
{{ enr_rec.jour }}{{ enr_rec.description }}{{ enr_rec.etiquette }}{{ enr_rec.montant }}Éditer + Supprimer
+{% endblock %} diff --git a/core/tests.py b/core/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/core/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 0000000..7603eee --- /dev/null +++ b/core/urls.py @@ -0,0 +1,18 @@ +from django.conf.urls import url +from .views import * + +urlpatterns = [ + url(r'^$', redirect_to_current_month, name="index"), + url(r'^(?P[0-9]{4})/(?P[0-9]{2})/$', MonthView.as_view(), name="month-view"), + url(r'^add$', EnregistrementAddView.as_view(), name="add"), + url(r'^update/(?P[0-9]+)$', EnregistrementUpdateView.as_view(), name="update"), + url(r'^delete/(?P[0-9]+)$', EnregistrementDeleteView.as_view(), name="delete"), + url(r'^etiquettes$', EtiquetteListView.as_view(), name="etiquette-list"), + url(r'^etiquettes/add$', EtiquetteAddView.as_view(), name="etiquette-add"), + url(r'^etiquettes/update/(?P\w+)$', EtiquetteUpdateView.as_view(), name="etiquette-update"), + url(r'^etiquettes/delete/(?P\w+)$', EtiquetteDeleteView.as_view(), name="etiquette-delete"), + url(r'^recursif$', RecursifListView.as_view(), name="recursif-list"), + url(r'^recursif/add$', RecursifAddView.as_view(), name="recursif-add"), + url(r'^recursif/update/(?P[0-9]+)$', RecursifUpdateView.as_view(), name="recursif-update"), + url(r'^recursif/delete/(?P[0-9]+)$', RecursifDeleteView.as_view(), name="recursif-delete"), +] diff --git a/core/views.py b/core/views.py new file mode 100644 index 0000000..c892f39 --- /dev/null +++ b/core/views.py @@ -0,0 +1,182 @@ +from collections import defaultdict +from django.shortcuts import render, redirect +from django.urls import reverse, reverse_lazy +from django.http import HttpResponseRedirect +from django.utils import timezone as tz +from django.utils.dates import MONTHS +from django import views +from django.contrib import messages + +from .models import (Enregistrement, Etiquette, EnregistrementRecursif,) +from .forms import EnregistrementForm + +# Utils + +def get_current_date(): + return tz.localtime(tz.now()).date() + +def redirect_to_current_month(request): + current_date = get_current_date() + return HttpResponseRedirect(reverse("month-view", + kwargs={ + 'year': current_date.year, + 'month': "%02d" % current_date.month, + })) + +# Views + +class MonthView(views.generic.ListView): + template_name = "core/index.html" + + def get(self, request, year=None, month=None, *args, **kwargs): + self.current_date = get_current_date() + self.year = int(year) + self.month = int(month) + if self.is_in_future: + messages.warning(request, "Trying to go into the future ? Well, you've been teleported back to now...") + return redirect_to_current_month(request) + return super().get(request, *args, **kwargs) + + def get_queryset(self): + """ Use custom manager method to get associated data and store it """ + qs, data = Enregistrement.objects.get_month_with_data(self.month, + self.year) + self.data = data + return qs + + def get_prev_month(self): + """ Get url for previous month (no limit) """ + if self.month > 1: + return reverse("month-view", + kwargs={'year': self.year, + 'month': "%02d" % (self.month - 1)}) + else: + return reverse("month-view", + kwargs={'year': self.year - 1, + 'month': 12}) + + @property + def is_in_future(self): + return (self.year > self.current_date.year + or (self.year == self.current_date.year + and self.month > self.current_date.month)) + + + def get_next_month(self): + """ Get url for next month, but no going into the future ! """ + if self.is_in_future or (self.year == self.current_date.year + and self.month == self.current_date.month): + return None + + if self.month < 12: + return reverse("month-view", + kwargs={'year': self.year, + 'month': "%02d" % (self.month + 1)}) + else: + return reverse("month-view", + kwargs={'year': self.year + 1, + 'month': "01"}) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['mois'], context['month'] = MONTHS[self.month], self.month + context['annee'], context['year'] = self.year, self.year + context['data'] = self.data + context['prev_month_url'] = self.get_prev_month() + context['next_month_url'] = self.get_next_month() + return context + + +class EnregistrementAddView(views.generic.edit.CreateView): + model = Enregistrement + template_name_suffix = "_add" + form_class = EnregistrementForm + + def get_initial(self): + # Build the initial date + date = get_current_date() + try: date = date.replace(int(self.request.GET['year'])) + except KeyError: pass + try: date = date.replace(date.year, int(self.request.GET['month'])) + except KeyError: pass + # Initials + return {'date': date, + 'est_negatif': True, + } + + def get_success_url(self): + date = self.object.date + return reverse("month-view", + kwargs={'year': date.year, + 'month': "%02d" % date.month}) + + +class EnregistrementUpdateView(views.generic.edit.UpdateView): + model = Enregistrement + template_name_suffix = "_update_form" + fields = "__all__" + + def get_success_url(self): + return reverse("month-view", + kwargs={'year': self.object.date.year, + 'month': "%02d" % self.object.date.month}) + + + +class EnregistrementDeleteView(views.generic.edit.DeleteView): + model = Enregistrement + template_name = "core/confirm_delete.html" + + def get_success_url(self): + return reverse("month-view", + kwargs={'year': self.object.date.year, + 'month': "%02d" % self.object.date.month}) + +class EtiquetteListView(views.generic.ListView): + model = Etiquette + template_name = "core/etiquettes.html" + + +class EtiquetteAddView(views.generic.edit.CreateView): + model = Etiquette + fields = "__all__" + template_name_suffix = "_add_form" + success_url = reverse_lazy('etiquette-list') + + +class EtiquetteUpdateView(views.generic.edit.UpdateView): + model = Etiquette + fields = "__all__" + template_name_suffix = "_update_form" + success_url = reverse_lazy('etiquette-list') + + +class EtiquetteDeleteView(views.generic.edit.DeleteView): + model = Etiquette + template_name = "core/confirm_delete.html" + success_url = reverse_lazy("etiquette-list") + + +class RecursifListView(views.generic.ListView): + model = EnregistrementRecursif + template_name = "core/recursif_list.html" + + +class RecursifAddView(views.generic.edit.CreateView): + model = EnregistrementRecursif + fields = ('jour', 'description', 'etiquette', 'montant') + template_name_suffix = "_add_form" + success_url = reverse_lazy('recursif-list') + + +class RecursifUpdateView(views.generic.edit.UpdateView): + model = EnregistrementRecursif + fields = ('jour', 'description', 'etiquette', 'montant') + template_name_suffix = "_update_form" + success_url = reverse_lazy('recursif-list') + + +class RecursifDeleteView(views.generic.edit.DeleteView): + model = EnregistrementRecursif + template_name = "core/confirm_delete.html" + success_url = reverse_lazy("recursif-list")