initial commit
0
core/__init__.py
Normal file
14
core/admin.py
Normal file
@@ -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
|
||||||
|
|
||||||
|
|
||||||
5
core/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CoreConfig(AppConfig):
|
||||||
|
name = 'core'
|
||||||
25
core/forms.py
Normal file
@@ -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
|
||||||
45
core/management/commands/import_csv.py
Normal file
@@ -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))
|
||||||
125
core/models.py
Normal file
@@ -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 "<Depense: %s>" % 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)
|
||||||
|
|
||||||
1
core/static/activity.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-activity"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>
|
||||||
|
After Width: | Height: | Size: 275 B |
1
core/static/chevrons-left.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevrons-left"><polyline points="11 17 6 12 11 7"></polyline><polyline points="18 17 13 12 18 7"></polyline></svg>
|
||||||
|
After Width: | Height: | Size: 311 B |
1
core/static/chevrons-right.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevrons-right"><polyline points="13 17 18 12 13 7"></polyline><polyline points="6 17 11 12 6 7"></polyline></svg>
|
||||||
|
After Width: | Height: | Size: 311 B |
1
core/static/delete.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-delete"><path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"></path><line x1="18" y1="9" x2="12" y2="15"></line><line x1="12" y1="9" x2="18" y2="15"></line></svg>
|
||||||
|
After Width: | Height: | Size: 374 B |
1
core/static/edit.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-edit"><path d="M20 14.66V20a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5.34"></path><polygon points="18 2 22 6 12 16 8 16 8 12 18 2"></polygon></svg>
|
||||||
|
After Width: | Height: | Size: 356 B |
1
core/static/minus-circle.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-minus-circle"><circle cx="12" cy="12" r="10"></circle><line x1="8" y1="12" x2="16" y2="12"></line></svg>
|
||||||
|
After Width: | Height: | Size: 308 B |
1
core/static/minus-square.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-minus-square"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="8" y1="12" x2="16" y2="12"></line></svg>
|
||||||
|
After Width: | Height: | Size: 330 B |
1
core/static/plus.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-plus"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||||
|
After Width: | Height: | Size: 297 B |
1
core/static/repeat.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-repeat"><polyline points="17 1 21 5 17 9"></polyline><path d="M3 11V9a4 4 0 0 1 4-4h14"></path><polyline points="7 23 3 19 7 15"></polyline><path d="M21 13v2a4 4 0 0 1-4 4H3"></path></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
1
core/static/rewind.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-rewind"><polygon points="11 19 2 12 11 5 11 19"></polygon><polygon points="22 19 13 12 22 5 22 19"></polygon></svg>
|
||||||
|
After Width: | Height: | Size: 319 B |
1
core/static/tag.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-tag"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path><line x1="7" y1="7" x2="7" y2="7"></line></svg>
|
||||||
|
After Width: | Height: | Size: 345 B |
43
core/templates/core/base.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{% load bootstrap4 staticfiles %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
{% bootstrap_css %}
|
||||||
|
{% bootstrap_javascript jquery=True %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Navbar //-->
|
||||||
|
<nav class="navbar sticky-top navbar-expand-lg navbar-dark bg-dark justify-content-between">
|
||||||
|
<a class="navbar-brand mb-0 col-2" href="{% url "index" %}">
|
||||||
|
<img src="{% static "activity.svg" %}" height="30" width="30"/>
|
||||||
|
Cresus
|
||||||
|
</a>
|
||||||
|
<span class="navbar-text col-8 text-center">{% block page_title %}Titre{% endblock %}</span>
|
||||||
|
|
||||||
|
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarMenu" aria-controls="navbarMenu" aria-expanded="false" aria-label="Toggle Navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse col-2" id="navbarMenu">
|
||||||
|
<div class="btn-group btn-group-sm mx-2" role="group" aria-label="Etiquettes">
|
||||||
|
<a class="btn btn-dark" href="{% url "etiquette-add" %}">
|
||||||
|
<img src="{% static "plus.svg" %}" height="18" width="18" />
|
||||||
|
</a>
|
||||||
|
<a href="{% url "etiquette-list" %}" class="btn btn-dark px-2">
|
||||||
|
<img src="{% static "tag.svg" %}" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group btn-group-sm mx-2" role="group" aria-label="Enregistrements récurrents">
|
||||||
|
<a class="btn btn-dark" href="{% url "recursif-add" %}"><img src="{% static "plus.svg" %}" height="18" width="18" /></a>
|
||||||
|
<a class="btn btn-dark" href="{% url "recursif-list" %}"><img src="{% static "repeat.svg" %}" /></a>
|
||||||
|
</div> </div>
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
<!-- Content //-->
|
||||||
|
{% block body_content %}
|
||||||
|
Page vide.
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
core/templates/core/confirm_delete.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends "core/base.html" %}
|
||||||
|
|
||||||
|
{% block page_title %}
|
||||||
|
Suppression de {{ object}}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body_content %}
|
||||||
|
<form action="" method="post" class="mt-3">{% csrf_token %}
|
||||||
|
<p>Êtes-vous sûr de vouloir supprimer définitivement {{ object }} ?</p>
|
||||||
|
<input type="submit" value="Confirm" class="btn btn-block btn-dark" />
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
37
core/templates/core/enregistrement_add.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "core/base.html" %}
|
||||||
|
{% load bootstrap4 staticfiles %}
|
||||||
|
|
||||||
|
{% block page_title %}
|
||||||
|
Ajouter une écriture
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body_content %}
|
||||||
|
<form action="" method="POST" class="mt-3">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group col-4">
|
||||||
|
<label class="control-label" for="id_montant">Montant</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-addon">
|
||||||
|
<img src="{% static "minus-square.svg"%}" />{{ form.est_negatif}}
|
||||||
|
</span>
|
||||||
|
<input class="form-control" type="number" id="id_montant" name="montant" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% bootstrap_field form.description form_group_class="form-group col-8" %}
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group col-4">
|
||||||
|
<label class="control-label" for="id_date">Date</label>
|
||||||
|
<div class="form-inline align-items-center">{{ form.date }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group col-8">
|
||||||
|
<label class="control-label" for="id_etiquette">Étiquette</label>
|
||||||
|
{{ form.etiquette }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row mt-3">
|
||||||
|
<button type="submit" class="btn btn-block btn-dark"><img src="{% static "plus.svg" %}" /> Ajouter</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
14
core/templates/core/enregistrement_update_form.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "core/base.html" %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
|
||||||
|
{% block page_title %}
|
||||||
|
Mise à jour de {{ object }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body_content %}
|
||||||
|
<form action="" method="post" class="mt-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
<button type="submit" class="btn btn-block btn-dark">Enregistrer</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
14
core/templates/core/enregistrementrecursif_add_form.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "core/base.html" %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
|
||||||
|
{% block page_title %}
|
||||||
|
Ajouter un enregistrement récursif
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body_content %}
|
||||||
|
<form action="" method="post" class="mt-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
<button type="submit" class="btn btn-block btn-dark">Enregistrer</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
14
core/templates/core/enregistrementrecursif_update_form.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "core/base.html" %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
|
||||||
|
{% block page_title %}
|
||||||
|
Mise à jour de {{ object }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body_content %}
|
||||||
|
<form action="" method="post" class="mt-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
<button type="submit" class="btn btn-block btn-dark">Enregistrer</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
14
core/templates/core/etiquette_add_form.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "core/base.html" %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
|
||||||
|
{% block page_title %}
|
||||||
|
Ajouter une étiquette
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body_content %}
|
||||||
|
<form action="" method="post" class="mt-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
<button type="submit" class="btn btn-block btn-dark">Enregistrer</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
23
core/templates/core/etiquette_list.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "core/base.html" %}
|
||||||
|
{% load staticfiles %}
|
||||||
|
|
||||||
|
{% block page_title%}
|
||||||
|
Liste des étiquettes
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body_content %}
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead class="thead-inverse">
|
||||||
|
<tr><th>Nom</th><th>Actions</th></tr>
|
||||||
|
<thead>
|
||||||
|
{% for etiquette in object_list %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ etiquette }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{% url "etiquette-update" pk=etiquette.pk %}"><img src="{% static "edit.svg" %}" /><span class="sr-only">Éditer</span></a>
|
||||||
|
<a href="{% url "etiquette-delete" pk=etiquette.pk %}"><img src="{% static "delete.svg" %}" /><span class="sr-only">Supprimer</span></a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
14
core/templates/core/etiquette_update_form.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "core/base.html" %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
|
||||||
|
{% block page_title %}
|
||||||
|
Mise à jour de "{{ object }}"
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body_content %}
|
||||||
|
<form action="" method="post" class="mt-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
<button type="submit" class="btn btn-block btn-dark">Enregistrer</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
62
core/templates/core/index.html
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{% extends "core/base.html" %}
|
||||||
|
{% load bootstrap4 staticfiles %}
|
||||||
|
{% block page_title %}
|
||||||
|
<a href="{{ prev_month_url }}" class="btn btn-dark btn-sm"><img src="{% static "chevrons-left.svg" %}" /></a>
|
||||||
|
Comptes de {{mois}} {{annee}}
|
||||||
|
<a href="{% url "add"%}?year={{year}}&month={{month}}" class="btn btn-dark btn-sm m-0"><img src="{% static "plus.svg" %}" /></a>
|
||||||
|
{% if next_month_url %}
|
||||||
|
<a href="{{ next_month_url}}" class="btn btn-dark btn-sm"><img src="{% static "chevrons-right.svg" %}" /></a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block body_content %}
|
||||||
|
<div class="row">
|
||||||
|
{% bootstrap_messages %}
|
||||||
|
<div class="col-md-9">
|
||||||
|
{% if not object_list %}
|
||||||
|
<p class="alert alert-warning m-3">Aucune donnée</p>
|
||||||
|
{% else %}
|
||||||
|
<table class="table table-striped table-hover">
|
||||||
|
<thead class="thead-inverse">
|
||||||
|
<tr>
|
||||||
|
<th width="25%">Date</th>
|
||||||
|
<th width="40%">Description</th>
|
||||||
|
<th width="20%">Etiquette</th>
|
||||||
|
<th width="10%">Montant</th>
|
||||||
|
<th width="5%">Actions</th></tr>
|
||||||
|
</thead>
|
||||||
|
{% for d in object_list %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ d.date }}</td>
|
||||||
|
<td>{{ d.description }}</td>
|
||||||
|
<td>{{ d.etiquette }}</td>
|
||||||
|
<td class="text-{% if d.montant > 0 %}success{% else %}danger{%endif%}">
|
||||||
|
<strong>{{ d.montant }}</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{% url "update" pk=d.pk %}"><img src="{% static "edit.svg" %}" /></a>
|
||||||
|
<a href="{% url "delete" pk=d.pk %}"><img src="{% static "delete.svg" %}" /></a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mt-3">
|
||||||
|
<h2 class="page-title">Balance
|
||||||
|
<span class="badge float-right badge-{% if data.balance > 0 %}success{% else %}danger{% endif%}">{{ data.balance }} €</span>
|
||||||
|
</h2>
|
||||||
|
<div class="card mt-3" >
|
||||||
|
<div class="card-header">
|
||||||
|
Dépenses
|
||||||
|
</div>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for label, montant in data.par_etiquette.items %}
|
||||||
|
<li class="list-group-item">{{ label }}
|
||||||
|
<span class="badge badge-dark p-2 align-middle float-right">{{ montant }} €</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
28
core/templates/core/recursif_list.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "core/base.html" %}
|
||||||
|
{% load staticfiles %}
|
||||||
|
|
||||||
|
{% block page_title %}
|
||||||
|
Enregistrement récursif
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body_content %}
|
||||||
|
<table class="table">
|
||||||
|
<thead class="thead-inverse">
|
||||||
|
<tr><th>Jour</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Étiquette</th>
|
||||||
|
<th>Montant</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{% for enr_rec in object_list %}
|
||||||
|
<tr><td>{{ enr_rec.jour }}</td>
|
||||||
|
<td>{{ enr_rec.description }}</td>
|
||||||
|
<td>{{ enr_rec.etiquette }}</td>
|
||||||
|
<td>{{ enr_rec.montant }}</td>
|
||||||
|
<td><a href="{% url "recursif-update" pk=enr_rec.pk %}"><img src="{% static "edit.svg" %}" /><span class="sr-only">Éditer</span></a>
|
||||||
|
<a href="{% url "recursif-delete" pk=enr_rec.pk %}"><img src="{% static "delete.svg" %}" /><span class="sr-only">Supprimer</span></a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
3
core/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
18
core/urls.py
Normal file
@@ -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<year>[0-9]{4})/(?P<month>[0-9]{2})/$', MonthView.as_view(), name="month-view"),
|
||||||
|
url(r'^add$', EnregistrementAddView.as_view(), name="add"),
|
||||||
|
url(r'^update/(?P<pk>[0-9]+)$', EnregistrementUpdateView.as_view(), name="update"),
|
||||||
|
url(r'^delete/(?P<pk>[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<pk>\w+)$', EtiquetteUpdateView.as_view(), name="etiquette-update"),
|
||||||
|
url(r'^etiquettes/delete/(?P<pk>\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<pk>[0-9]+)$', RecursifUpdateView.as_view(), name="recursif-update"),
|
||||||
|
url(r'^recursif/delete/(?P<pk>[0-9]+)$', RecursifDeleteView.as_view(), name="recursif-delete"),
|
||||||
|
]
|
||||||
182
core/views.py
Normal file
@@ -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")
|
||||||