Remaster (#38)

* setup new 'statistiques' module

* added 'graphos' package and created first test graph

* put graphos in requirements, deleted local folder

* added "load_csv" management command !

* added update of premiere_rencontre field in 'load_csv' management command

* added missing urls.py file

* added 'merge' action and view

* added 'info_completed' ratio

* linked sujets:merge views inside suivi:details

* added link to maraudes:details in notes table headers, if any

* Major reorganisation, moved 'suivi' and 'sujets' to 'notes', cleanup in 'maraudes', dropping 'website' mixins (mostly useless)

* small cleanup

* worked on Maraude and Sujet lists

* corrected missing line in notes.__init__

* restored 'details' view for maraudes and sujets insie 'notes' module

* worked on 'notes': added navigation between maraude's compte-rendu, right content in details, header to list tables

* changed queryset for CompteRenduDetailsView to all notes of same date, minor layout changes

* added right content to 'details-sujet', created 'statistiques' view and update templates

* restored 'statistiques' ajax view in 'details-sujet', fixed 'merge_two' util function

* added auto-creation of FicheStatistique (plus some tests), pagination for notes in 'details-sujet'

* added error-prone cases in paginator

* fixed non-working modals, added titles

* added UpdateStatistiques capacity in CompteRenduCreate view

* fixed missing AjaxTemplateMixin for CreateSujetView, worked on compte-rendu creation scripts

* fixed MaraudeManager.all_of() for common Maraudeurs, added color hints in planning

* re-instated statistiques module link and first test page

* added FinalizeView to send a mail before finalizing compte-rendu

* Added PieChart view for FicheStatistique fields

* small style updates, added 'age' and 'genre' fields from sujets in statistiques.PieChartView

* worked on statistiques, fixed small issues in 'notes' list views

* small theme change

* removed some dead code

* fixed notes.tests, fixed statistiques.info_completed display, added filter in SujetLisView

* added some tests

* added customised admin templates

* added authenticate in CustomAuthenticatationBackend, more verbose login thanks to messages

* added django-nose for test coverage

* Corrected raising exception on first migration

On first migration, qs.exists() would previously be called and raising an Exception, sot he migrations would fail.

* Better try block

* cleaned up custom settings.py, added some overrides of django base_settings

* corrected bad dictionnary key
This commit is contained in:
artus40
2017-06-11 17:16:17 +02:00
committed by GitHub
parent 0be59a61a7
commit be087464fc
155 changed files with 3568 additions and 1988 deletions

View File

@@ -41,8 +41,9 @@ class MaraudeAdmin(admin.ModelAdmin):
]
list_display = ('date', 'heure_debut', 'binome', 'est_passee', 'est_terminee')
list_filter = ['date', 'binome']
ordering = ['-date']
@admin.register(Planning)
class PlanningAdmin(admin.ModelAdmin):
list_display = ('week_day', 'horaire')

View File

@@ -4,23 +4,5 @@ from django.apps import AppConfig
class Config(AppConfig):
name = 'maraudes'
index_url = "/maraudes/"
menu_icon = "road"
def get_index_url(self):
return "/maraudes/"
from utilisateurs.models import Maraudeur
from website.decorators import Webpage
maraudes = Webpage('maraudes',
icon="road",
defaults={
'users': [Maraudeur],
'ajax': False,
'title': ('Maraudes','app'),
})
# Setting up some links
maraudes.app_menu.add_link(('Liste des maraudes', 'maraudes:liste', "list"))
maraudes.app_menu.add_link(('Planning', 'maraudes:planning', "calendar"), admin=True)

View File

@@ -1,82 +0,0 @@
from .models import Maraude
from collections import OrderedDict
import datetime
def split_by_12h_blocks(iterable):
""" Move object with given 'field' time under 12:00 to the end of stream.
Apart from this, order is untouched.
"""
to_end = []
for note in iterable:
if getattr(note, "created_time") <= datetime.time(12):
to_end.append(note)
else:
yield note
for note in to_end:
yield note
class CompteRendu(Maraude):
""" Proxy for Maraude objects.
Gives access to related Observation and Rencontre
"""
def rencontre_count(self):
return self.rencontres.count()
def observation_count(self):
count = 0
for r in self:
count += r.observations.count()
return count
def get_observations(self, order="heure_debut", reverse=False):
""" Returns list of all observations related to this instance """
observations = []
for r in self._iter(order=order, reverse=reverse):
observations += r.observations.get_queryset()
return list(split_by_12h_blocks(observations))
def __iter__(self):
""" Iterates on related 'rencontres' objects using default ordering """
return self._iter()
def reversed(self, order="heure_debut"):
return self._iter(order=order, reverse=True)
def _iter(self, order="heure_debut", reverse=False):
""" Iterator on related 'rencontre' queryset.
Optionnal :
- order : order by this field, default: 'heure_debut'
- reversed : reversed ordering, default: False
"""
if reverse:
order = "-" + order
for rencontre in self.rencontres.get_queryset().order_by(order):
yield rencontre
def as_list(self, **kwargs):
return [r for r in self._iter(**kwargs)]
def as_dict(self, key_field="lieu"):
""" Returns an 'OrderedDict' with given 'key_field' value as keys and
the corresponding (rencontre, observations) tuple
"""
condensed = OrderedDict()
for r, obs in self.__iter__():
val = getattr(r, key_field, None)
if not val:
pass
if not val in condensed:
condensed[val] = [(r, obs)]
else:
condensed[val].append((r, obs))
return condensed
class Meta:
proxy = True

View File

@@ -1,22 +1,28 @@
from django import forms
from django.forms import inlineformset_factory
from notes.forms import *
from django.utils import timezone
from django_select2.forms import Select2Widget
# Models
from .models import *
from .notes import *
from notes.forms import UserNoteForm, SimpleNoteForm
from notes.models import Sujet, GENRE_CHOICES
class MaraudeAutoDateForm(forms.ModelForm):
""" Maraude ModelForm with disabled 'date' field """
def current_year_range():
""" Returns a range from year -1 to year + 2 """
year = timezone.now().date().year
return (year - 1, year, year + 1, year + 2)
class MaraudeHiddenDateForm(forms.ModelForm):
class Meta:
model = Maraude
fields = ['date', 'heure_debut', 'referent', 'binome']
widgets = {'date': forms.HiddenInput()}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['date'].disabled = True
@@ -29,34 +35,36 @@ class RencontreForm(forms.ModelForm):
}
ObservationInlineFormSet = inlineformset_factory( Rencontre, Observation,
ObservationInlineFormSet = forms.inlineformset_factory( Rencontre, Observation,
form=SimpleNoteForm,
extra = 1,
)
RencontreInlineFormSet = inlineformset_factory(
RencontreInlineFormSet = forms.inlineformset_factory(
Maraude, Rencontre,
form = RencontreForm,
extra = 0,
)
ObservationInlineFormSetNoExtra = inlineformset_factory(
ObservationInlineFormSetNoExtra = forms.inlineformset_factory(
Rencontre, Observation,
form = SimpleNoteForm,
extra = 0
)
class MonthSelectForm(forms.Form):
month = forms.ChoiceField(
month = forms.ChoiceField(label="Mois",
choices=[
(1, 'Janvier'), (2, 'Février'), (3, 'Mars'), (4, 'Avril'),
(5, 'Mai'), (6, 'Juin'), (7, 'Juillet'), (8, 'Août'),
(9, 'Septembre'),(10, 'Octobre'),(11, 'Novembre'),(12, 'Décembre')
],
)
year = forms.ChoiceField(
choices = [(y, y) for y in [2015, 2016, 2017, 2018]]
year = forms.ChoiceField(label="Année",
choices = [(y, y) for y in current_year_range()]
)
def __init__(self, *args, month=None, year=None, **kwargs):
@@ -64,3 +72,48 @@ class MonthSelectForm(forms.Form):
self.fields['month'].initial = month
self.fields['year'].initial = year
class AppelForm(UserNoteForm):
class Meta(UserNoteForm.Meta):
model = Appel
fields = ['sujet', 'text', 'entrant', 'created_date', 'created_time']
class SignalementForm(UserNoteForm):
nom = forms.CharField(64, required=False)
prenom = forms.CharField(64, required=False)
age = forms.IntegerField(required=False)
genre = forms.ChoiceField(choices=GENRE_CHOICES)
class Meta(UserNoteForm.Meta):
model = Signalement
fields = ['text', 'source', 'created_date', 'created_time']
def clean(self):
super().clean()
if not self.cleaned_data['nom'] and not self.cleaned_data['prenom']:
self.add_error('nom', '')
self.add_error('prenom', '')
raise forms.ValidationError("Entrez au moins un nom ou prénom")
def save(self, commit=True):
sujet = Sujet.objects.create(
nom=self.cleaned_data['nom'],
prenom=self.cleaned_data['prenom'],
genre=self.cleaned_data['genre'],
age=self.cleaned_data['age']
)
instance = super().save(commit=False)
instance.sujet = sujet
if commit:
instance.save()
return instance
class SendMailForm(forms.Form):
subject = forms.CharField(label="Objet")
message = forms.CharField(widget=forms.Textarea, label="Contenu")

View File

@@ -0,0 +1,216 @@
import datetime
import csv
def parse_rows(data_file):
reader = csv.DictReader(data_file, delimiter=";")
for i, row in enumerate(reader):
date = datetime.datetime.strptime(row['DATE'] + ".2016", "%d.%m.%Y").date()
lieu = row['LIEUX']
nom, prenom = row['NOM'], row['PRENOM']
prem_rencontre = True if row['1 ER CONTACT'].lower() == "oui" else False
yield i, date, lieu, nom, prenom, prem_rencontre
from django.core.management.base import BaseCommand, CommandError
class Command(BaseCommand):
help = "Load data for rencontre from csv files"
def add_arguments(self, parser):
parser.add_argument('file', help="path to the files to load", nargs="+", type=str)
parser.add_argument('--commit', help="commit changes to the database",
action="store_true", dest="commit", default=False)
parser.add_argument('--check', action='store_true', dest='check', default=False,
help="Check that all lines from file are written into database")
@property
def cache(self):
if not hasattr(self, '_cache'):
self._cache = {'maraude': {}, 'lieu': {}, 'sujet': {}, 'rencontre': [], 'observation': []}
return self._cache
def new_object(self, model, data, cache_key=None):
""" Create new object, add it to cache (in dict if cache_key is given, in list otherwise).
Save it only if --commit option is given
"""
obj = model(**data)
msg = "[%i]+ Created %s " % (self.cur_line, obj)
if self._commit:
obj.save()
msg += " successfully saved to db"
if cache_key:
self.cache[model.__qualname__.lower()][cache_key] = obj
msg += " and added to cache."
self.stdout.write(self.style.SUCCESS(msg))
return obj
@property
def referent_maraude(self):
if not hasattr(self, '_referent'):
from utilisateurs.models import Maraudeur
self._referent = Maraudeur.objects.get_referent()
return self._referent
def find_maraude(self, date):
from maraudes.models import Maraude
try: # First, try to retrieve from database
obj = Maraude.objects.get(date=date)
except Maraude.DoesNotExist:
# Try to retrieve from cache
try:
obj = self.cache['maraude'][date]
except KeyError:
# Create a new object and put it into cache
obj = self.new_object(
Maraude,
{'date':date, 'referent':self.referent_maraude, 'binome':self.referent_maraude},
cache_key=date)
return obj
def find_sujet(self, nom, prenom):
from sujets.models import Sujet
from watson import search
search_text = "%s %s" % (nom, prenom)
sujet = self.cache['sujet'].get(search_text, None)
while not sujet:
create = False #Set to True if creation is needed at and of loop
self.stdout.write(self.style.WARNING("In line %i, searching : %s. " % (self.cur_line, search_text)), ending='')
results = search.filter(Sujet, search_text)
if results.count() == 1: # Always ask to review result a first time
sujet = results[0]
self.stdout.write(self.style.SUCCESS("Found %s '%s' %s" % (sujet.nom, sujet.surnom, sujet.prenom)))
action = input("Confirm ? (y/n/type new search)> ")
if action == "n":
sujet = None
search_text = "%s %s" % (nom, prenom)
elif action == "y":
continue
else: # In case the result is no good at all !
sujet = None
search_text = action
elif results.count() > 1: # Ask to find the appropriate result
self.stdout.write(self.style.WARNING("Multiple results for %s" % search_text))
for i, result in enumerate(results):
self.stdout.write("%i. %s '%s' %s" % (i, result.nom, result.surnom, result.prenom))
choice = input("Choose the right number - Type new search - C to create '%s %s': " % (nom, prenom))
if choice == "C":
create = True
else:
try: sujet = results[int(choice)]
except (IndexError, ValueError):
search_text = str(choice) #New search
continue
else: # No results, try with name only, or ask for new search
if search_text == "%s %s" % (nom, prenom):
# Search only with nom
self.stdout.write(self.style.WARNING("Nothing, trying name only..."), ending='')
search_text = nom if nom else prenom
continue
else:
self.stdout.write(self.style.ERROR("No result !"))
action = input("New search or C to create '%s %s': " % (nom, prenom))
if action == "C":
create = True
else:
search_text = str(action)
if create:
sujet = self.new_object(
Sujet,
{'nom':nom, 'prenom':prenom}
)
self.stdout.write('Created, %s' % sujet)
# Always store sujet in cache because it may or may not be updated, safer to save in all cases.
self.cache['sujet']["%s %s" % (nom, prenom)] = sujet
return sujet
def find_lieu(self, nom):
from maraudes.models import Lieu
try:
lieu = Lieu.objects.get(nom=nom)
except Lieu.DoesNotExist:
lieu = self.cache['lieu'].get(nom, None)
while not lieu:
self.stdout.write(self.style.WARNING("At line %i, le lieu '%s' n'a pas été trouvé" % (self.cur_line, nom)))
action = input('%s (Créer/Sélectionner)> ' % nom)
if action == "C":
lieu = self.new_object(Lieu, {'nom': nom}, cache_key=nom)
elif action == "S":
choices = {l.pk:l.nom for l in Lieu.objects.all()}
for key, name in choices.items():
self.stdout.write("%i. %s" % (key, name))
while not lieu:
chosen_key = input('Choose a number: ')
try:
lieu = Lieu.objects.get(pk=chosen_key)
confirm = input("Associer %s à %s ? (o/n)> " % (nom, lieu.nom))
if confirm == "n":
lieu = None
else:
self.cache['lieu'][nom] = lieu
except (Lieu.DoesNotExist, ValueError):
lieu = None
else:
continue
return lieu
def add_rencontre(self, maraude, sujet, lieu):
from maraudes.models import Rencontre
from maraudes.notes import Observation
rencontre = self.new_object(Rencontre,
{'maraude':maraude, 'lieu':lieu, 'heure_debut':datetime.time(20, 0),
'duree':15})
observation = self.new_object(Observation, {'rencontre':rencontre,
'sujet':sujet,
'text':"Chargé depuis '%s'" % self._file.name})
self.cache['rencontre'].append(rencontre)
self.cache['observation'].append(observation)
def handle(self, **options):
""" Parsing all given files, look for existing objects and create new Rencontre
and Observation objects. Ask for help finding related object, creating new ones
if needed. All creation/updates are stored in cache and commited only after
user confirmation
"""
self._commit = options.get('commit', False)
for file_path in options['file']:
with open(file_path, 'r') as data_file:
self.stdout.write("Working with '%s'" % data_file.name)
self._file = data_file
for line, date, lieu, nom, prenom, prems in parse_rows(data_file):
self.cur_line = line
maraude = self.find_maraude(date)
lieu = self.find_lieu(lieu)
sujet = self.find_sujet(nom, prenom)
assert sujet is not None
assert lieu is not None
assert maraude is not None
if prems and self._commit:
sujet.premiere_rencontre = date
sujet.save()
self.stdout.write(self.style.SUCCESS("[%i]* Updated premiere_rencontre on %s" % (self.cur_line, sujet)))
self.add_rencontre(maraude, sujet, lieu)
#Summary
self.stdout.write(" ## %s : %i lines ##" % (data_file.name, self.cur_line))
self.stdout.write("Trouvé %s nouvelles observations" % len(self.cache['observation']))
self.stdout.write("Nécessite l'ajout/modification de : \n- %i maraudes\n- %i lieux\n- %i sujets" %
(len(self.cache['maraude']), len(self.cache['lieu']), len(self.cache['sujet'])))
view = input('Voulez-vous voir la liste des changements ? (o/n)> ')
if view == "o": self.stdout.write(" ## Changements ## \n%s" % self.cache)

View File

@@ -1,10 +1,11 @@
from django.db.models import Manager
import datetime
from django.utils import timezone
from django.utils.functional import cached_property
# TODO: What is really useful in there ??
class MaraudeManager(Manager):
""" Manager for Maraude objects """
@@ -20,24 +21,7 @@ class MaraudeManager(Manager):
if not maraudes_ref:
return maraudes_bin
cursor = 0
complete_list = []
for i, m in enumerate(maraudes_bin):
if cursor >= 0 and maraudes_ref[cursor].date < m.date:
complete_list.append(maraudes_ref[cursor])
complete_list.append(m)
if cursor < len(maraudes_ref) - 1:
cursor += 1
else:
cursor = -1
else:
complete_list.append(m)
# Don't lose remaining items of maraudes_ref
if cursor >= 0:
complete_list += maraudes_ref[cursor:]
return complete_list
return maraudes_bin | maraudes_ref
def get_next_of(self, maraudeur):
""" Retourne la prochaine maraude de 'maraudeur' """
@@ -47,22 +31,28 @@ class MaraudeManager(Manager):
'date'
).first()
def get_future(self):
def get_future(self, date=None):
""" Retourne la liste des prochaines maraudes """
if not date: date = self.today
return self.get_queryset().filter(
date__gte=datetime.date.today()
date__gte=date
).order_by(
'date'
)
def get_past(self):
def get_past(self, date=None):
""" Retourne la liste des maraudes passées """
if not date: date = self.today
return self.get_queryset().filter(
date__lt=datetime.date.today()
date__lt=date
).order_by(
'date'
)
@cached_property
def today(self):
return timezone.localtime(timezone.now()).date()
@cached_property
def next(self):
""" Prochaine maraude """

View File

@@ -1,27 +1,73 @@
import calendar
import datetime
from django.utils import timezone
from collections import OrderedDict
from django.utils import timezone
from django.db import models
from django.db.models import Count
from django.core.urlresolvers import reverse
from utilisateurs.models import Maraudeur
from . import managers
## Fonctions utiles
def get_referent_maraude():
""" Retourne l'administrateur et référent de la Maraude """
return Maraudeur.objects.get_referent()
def split_by_12h_blocks(iterable):
""" Move object with given 'field' time under 12:00 to the end of stream.
Apart from this, order is untouched.
"""
to_end = []
for note in iterable:
if getattr(note, "created_time") <= datetime.time(12):
to_end.append(note)
else:
yield note
for note in to_end:
yield note
## Constantes
# Jours de la semaine
WEEKDAYS = [
(0, "Lundi"),
(1, "Mardi"),
(2, "Mercredi"),
(3, "Jeudi"),
(4, "Vendredi"),
(5, "Samedi"),
(6, "Dimanche")
]
# Horaires
HORAIRES_APRESMIDI = datetime.time(16, 0)
HORAIRES_SOIREE = datetime.time(20, 0)
HORAIRES_CHOICES = (
(HORAIRES_APRESMIDI, 'Après-midi'),
(HORAIRES_SOIREE, 'Soirée')
)
# Durées
DUREE_CHOICES = (
(5, '5 min'),
(10, '10 min'),
(15, '15 min'),
(20, '20 min'),
(30, '30 min'),
(45, '45 min'),
(60, '1 heure'),
)
## Modèles
class Lieu(models.Model):
""" Lieu de rencontre """
nom = models.CharField(max_length=128)
def __str__(self):
@@ -32,9 +78,6 @@ class Lieu(models.Model):
class Maraude(models.Model):
""" Modèle pour une maraude
- date : jour de la maraude
@@ -53,13 +96,7 @@ class Maraude(models.Model):
"Date",
unique=True
)
# Horaires
HORAIRES_APRESMIDI = datetime.time(16, 0)
HORAIRES_SOIREE = datetime.time(20, 0)
HORAIRES_CHOICES = (
(HORAIRES_APRESMIDI, 'Après-midi'),
(HORAIRES_SOIREE, 'Soirée')
)
heure_debut = models.TimeField(
"Horaire",
choices=HORAIRES_CHOICES,
@@ -98,13 +135,10 @@ class Maraude(models.Model):
('view_maraudes', "Accès à l'application 'maraudes'"),
)
# TODO: A remplacer !
JOURS = ["Lundi", "Mardi", "Mercredi", "Jeudi",
"Vendredi", "Samedi", "Dimanche"]
MOIS = ["Jan.", "Fév.", "Mars", "Avr.", "Mai", "Juin",
"Juil.", "Août", "Sept.", "Oct.", "Nov.", "Déc."]
def __str__(self):
return '%s %i %s' % (self.JOURS[self.date.weekday()],
return '%s %i %s' % (WEEKDAYS[self.date.weekday()][1], # Retrieve text inside tuple
self.date.day,
self.MOIS[self.date.month - 1])
@@ -123,27 +157,14 @@ class Maraude(models.Model):
est_passee.boolean = True
est_passee.short_description = 'Passée ?'
def get_observations(self):
raise Warning("Deprecated ! Should use CompteRendu proxy object")
def get_absolute_url(self):
return reverse('maraudes:details', kwargs={'pk': self.id})
return reverse('notes:details-maraude', kwargs={'pk': self.id})
class Rencontre(models.Model):
""" Une Rencontre dans le cadre d'une maraude
"""
# Choices
DUREE_CHOICES = (
(5, '5 min'),
(10, '10 min'),
(15, '15 min'),
(20, '20 min'),
(30, '30 min'),
(45, '45 min'),
(60, '1 heure'),
)
# Fields
maraude = models.ForeignKey(
@@ -195,14 +216,44 @@ class Rencontre(models.Model):
return [o.sujet for o in self.observations.all()]
WEEKDAYS = [
(0, "Lundi"),
(1, "Mardi"),
(2, "Mercredi"),
(3, "Jeudi"),
(4, "Vendredi"),
(5, "Samedi"),
]
class CompteRendu(Maraude):
""" Proxy for Maraude objects.
Gives access to related Observation and Rencontre
"""
def observations_count(self):
return self.rencontres.aggregate(Count("observations"))['observations__count']
def get_observations(self, order="heure_debut", reverse=False):
""" Returns list of all observations related to this instance """
observations = []
for r in self._iter(order=order, reverse=reverse):
observations += r.observations.get_queryset()
return list(split_by_12h_blocks(observations))
def __iter__(self):
""" Iterates on related 'rencontres' objects using default ordering """
return self._iter()
def reversed(self, order="heure_debut"):
return self._iter(order=order, reverse=True)
def _iter(self, order="heure_debut", reverse=False):
""" Iterator on related 'rencontre' queryset.
Optionnal :
- order : order by this field, default: 'heure_debut'
- reversed : reversed ordering, default: False
"""
if reverse:
order = "-" + order
for rencontre in self.rencontres.get_queryset().order_by(order):
yield rencontre
class Meta:
proxy = True
class FoyerAccueil(Lieu):
""" Foyer d'hébergement partenaire """
@@ -220,11 +271,12 @@ class Planning(models.Model):
"""
week_day = models.IntegerField(
primary_key=True,
choices=WEEKDAYS,
)
horaire = models.TimeField(
"Horaire",
choices=Maraude.HORAIRES_CHOICES,
choices=HORAIRES_CHOICES,
)
class Meta:

View File

@@ -21,5 +21,18 @@ class Observation(Note):
def note_labels(self): return [self.rencontre.lieu, self.rencontre.heure_debut]
def note_bg_colors(self): return ("info", "info")
class Appel(Note):
entrant = models.BooleanField( "Appel entrant ?")
def note_labels(self): return ["Reçu" if self.entrant else "Émis", self.created_by]
def note_bg_colors(self): return ("warning", "info")
class Signalement(Note):
source = models.ForeignKey("utilisateurs.Organisme")
def note_labels(self): return [self.source, self.created_by]
def note_bg_colors(self): return ('danger', 'info')

View File

@@ -1,15 +0,0 @@
{% load notes %}
<div class="col-lg-6 col-md-12">
<table class="table table-bordered">
{% for note in notes %}
{% inline_table note header="sujet" %}
{% endfor %}
</table>
</div>
<div class="col-lg-6 col-md-12">
<div class="well bg-info">
<p><strong>Informations</strong></p>
<p>Rencontres : {{ maraude.rencontre_count}}</p>
<p>Personnes rencontrées : {{ maraude.observation_count}}</p>
</div>
</div>

View File

@@ -1,104 +0,0 @@
{% load bootstrap3 %}{% load staticfiles %}
<script type="text/javascript" src="{% static "scripts/jquery.formset.js" %}"></script>
<script type="text/javascript">
/* Dynamic Formsets */
$(function() {
$.fn.onAddForm = function(row) {
// Load django_select2 fields
row.find('.django-select2').djangoSelect2();
var button = row.find('a.btn-delete')
var text = button.text()
button.html('<span class="glyphicon glyphicon-minus"></span> ' + text);
};
$.fn.onDeleteForm = function(row) {
/*
* Custom code when deleting dynamic form
*/
};
});
$(function() {
$('.dynamic-formset').formset({
prefix: '{{ inline_formset.prefix }}',
addText: 'Ajouter une personne',
deleteText: 'Supprimer',
addCssClass: 'btn btn-link btn-add',
deleteCssClass: 'btn btn-link btn-delete',
added: $.fn.onAddForm,
removed: $.fn.onDeleteForm
});
var text = $('a.btn-add').text()
$('a.btn-add').html('<span class="glyphicon glyphicon-plus"></span> ' + text)
text = $('a.btn-delete:first').text()
$('a.btn-delete').html('<span class="glyphicon glyphicon-minus"></span> ' + text);
});
</script>
<div class="row">
<div class="col-lg-6 col-md-12">
<!-- Modal buttons -->
<div class="well well-sm text-right"><strong>Créer un nouvel objet :</strong>
<div class="btn-group" role="group" aria-label="...">
<button id="new-sujet" class="btn btn-sm btn-primary">{% bootstrap_icon "user" %} Sujet</button>
<button id="new-lieu" class="btn btn-sm btn-primary">{% bootstrap_icon "globe" %} Lieu</button>
</div>
</div>
<form method="post" action="{% url 'maraudes:create' maraude.pk %}?finalize=False">
{% csrf_token %}
<div class="panel panel-primary panel-collapse">
<div class="panel-heading">
<h4 class="panel-title">Nouvelle rencontre</h4>
</div>
<div class="panel-body">
{% include "compte_rendu/compterendu_form.html" %}
</div>
<div class="panel-footer text-right">
{% bootstrap_button "Enregistrer" icon="save" button_type="submit" button_class="btn btn-success btn-sm" %}
</div>
</div>
</form>
{{ form.media.js }}{{ form.media.css }}
</div>
<div class="col-lg-6 col-md-12">
<div class="panel panel-default">
<div class="panel-heading"><h4 class="panel-title">Enregistrées</h4></div>
<table class="table">
{% for rencontre in rencontres %}<tr><th colspan="2" class="active">{{ rencontre }}</th></tr>
{% for observation in rencontre.observations.all %}<tr>
<td>{{observation.sujet}}</td>
<td>{{observation.text}}</td>
</tr>{% endfor %}{% endfor %}
</table>
<div class="panel-footer text-right">
<a class="btn btn-danger btn-sm" href="{% url 'maraudes:create' maraude.pk %}?finalize=True">
{% bootstrap_icon "ok-circle" %} Finaliser</a>
</div>
</div>
</div>
</div>
<!-- Modal and button linking -->
<div class="modal fade" id="form-modal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Modal title</h4>
</div>
<div id="form-modal-body" class="modal-body">
<div class="alert alert-warning">Content should be there...</div>
</div>
</div>
</div>
</div>
<script type="text/javascript" src="{% static 'scripts/bootstrap-modal.js' %}"></script>
<script type="text/javascript">
$.fn.openModalEvent('new-sujet',
'{% url "sujets:create" %}?next={% url "maraudes:create" pk=maraude.pk %}',
'Nouveau sujet');
$.fn.openModalEvent('new-lieu',
'{% url "maraudes:lieu-create" %}?next={% url "maraudes:create" pk=maraude.pk %}',
'Nouveau lieu');
</script>

View File

@@ -1,22 +0,0 @@
{% load bootstrap3 %}
<form method="post" action="{% url 'maraudes:update' maraude.pk %}?continue=True">
{% csrf_token %}
{{ base_formset.management_form }}
<div class="col-md-12 text-center well">
<div class="btn-group">
<button type="submit" class="btn btn-sm btn-success" formaction="{% url 'maraudes:update' maraude.pk %}?continue=False">
{% bootstrap_icon "refresh" %} Mettre à jour</button>
{% bootstrap_button "Enregistrer et quitter" button_type="submit" button_class="btn-primary btn-sm" icon="ok-circle" %}
</div>
</div>
{% for form, inline_formset in forms %}
<div class="col-md-6 col-sd-12">
<div class="panel panel-default">
<div class="panel-body">
{% include "compte_rendu/compterendu_form.html" %}
</div>
<div class="panel-footer text-right">{% bootstrap_field form.DELETE %}</div>
</div>
</div>
{% endfor %}
</form>

View File

@@ -0,0 +1,14 @@
{% extends "base_site.html" %}
{% block title %} Maraudes > {% endblock %}
{% block breadcrumbs %}
<li><a href="{% url "maraudes:index" %}">{{ date }}</a></li>
{% endblock %}
{% block sidebar %}
<div class="panel panel-default">
<div class="panel-body">
{% include "maraudes/menu.html" %}
</div>
</div>{% endblock %}

View File

@@ -0,0 +1,169 @@
{% extends "maraudes/base.html" %}
{% load bootstrap3 staticfiles %}
{% block title %} {{ block.super }} Compte-rendu du {{ object.date }} {% endblock %}
{% block breadcrumbs %}
<li><a href="{% url "maraudes:index" %}">{{ object.date }}</a></li>
<li>Compte-rendu</li>
{% endblock %}
{% block sidebar %}
{{ block.super }}
<div class="panel panel-primary">
<div class="panel-body text-right">
<h4>{% bootstrap_icon "plus" %} Création</h4>
<div class="btn-group" role="group" aria-label="...">
<button id="new-sujet" class="btn btn-default">{% bootstrap_icon "user" %} Sujet</button>
<button id="new-lieu" class="btn btn-default">{% bootstrap_icon "globe" %} Lieu</button>
</div>
<hr />
<h4>Finaliser</h4>
<div class="pull-right"><a class="btn btn-primary" href="{% url 'maraudes:finalize' maraude.pk %}">
{% bootstrap_icon "ok-circle" %} Finaliser</a></div>
</div>
</div>
{% endblock %}
{% block page_content %}
<script type="text/javascript" src="{% static "scripts/jquery.formset.js" %}"></script>
<script type="text/javascript">
/* Dynamic Formsets */
$(function() {
$.fn.onAddForm = function(row) {
// Load django_select2 fields
row.find('.django-select2').djangoSelect2();
var button = row.find('a.btn-delete')
var text = button.text()
button.html('<span class="glyphicon glyphicon-minus"></span> ' + text);
};
$.fn.onDeleteForm = function(row) {
/*
* Custom code when deleting dynamic form
*/
};
});
$(function() {
$('.dynamic-formset').formset({
prefix: '{{ inline_formset.prefix }}',
addText: 'Ajouter une personne',
deleteText: 'Supprimer',
addCssClass: 'btn btn-link btn-add',
deleteCssClass: 'btn btn-link btn-delete',
added: $.fn.onAddForm,
removed: $.fn.onDeleteForm
});
var text = $('a.btn-add').text()
$('a.btn-add').html('<span class="glyphicon glyphicon-plus"></span> ' + text)
text = $('a.btn-delete:first').text()
$('a.btn-delete').html('<span class="glyphicon glyphicon-minus"></span> ' + text);
});
</script>
<div class="row">
<div class="col-lg-7 col-md-12">
<form method="post" action="{% url 'maraudes:create' maraude.pk %}">
{% csrf_token %}
<div class="panel panel-primary panel-collapse">
<div class="panel-heading">
<h4 class="panel-title">Nouvelle rencontre</h4>
</div>
<div class="panel-body">
{% include "maraudes/compterendu_form.html" %}
</div>
<div class="panel-footer text-right">
{% bootstrap_button "Enregistrer" icon="save" button_type="submit" button_class="btn btn-success btn-sm" %}
</div>
</div>
</form>
{{ form.media.js }}{{ form.media.css }}
</div>
<div class="col-lg-5 col-md-12">
<div id="saved-rencontres">
<h4 class="page-header">Enregistrées</h4>
<table class="table table-bordered">
{% for rencontre in rencontres %}<tr><th colspan="2" class="active">{{ rencontre }}</th></tr>
{% for observation in rencontre.observations.all %}<tr>
<td>
<a href="{% url "notes:details-sujet" observation.sujet.pk %}" id="sujet-name-{{observation.sujet.pk}}">{{observation.sujet}}</a>
<a class="btn btn-link btn-xs show-stats-btn" href="#" value="{{observation.sujet.pk}}">
{% bootstrap_icon "stats" %} Mise à jour</a>
</td></tr>
<tr><td>{{observation.text}}</td></tr>{% endfor %}{% endfor %}
</table>
</div>
<div id="update-stats">
<h4 class="page-header"><span id="sujet-name"></span> <small>Fiche statistiques</small>
<div class="pull-right" id="update-buttons">
<label for="submit-form" class="btn btn-primary" id="update-stats-btn" pk="">{% bootstrap_icon "floppy-save" %} Enregistrer</label>
<span class="btn btn-primary btn-sm" id="cancel">{% bootstrap_icon "remove" %}Annuler</span>
</div></h4>
<div id="fiche-stats" class="well well-sm">
</div>
</div>
</div>
</div>
<!-- Modal and button linking -->
<div class="modal fade" id="form-modal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Modal title</h4>
</div>
<div id="form-modal-body" class="modal-body">
<div class="alert alert-warning">Content should be there...</div>
</div>
</div>
</div>
</div>
<script type="text/javascript" src="{% static 'scripts/bootstrap-modal.js' %}"></script>
<script type="text/javascript">
$(function(){
function UpdateStats(pk) {
var name = $("#sujet-name-" + pk).text();
console.log("Update stats for ", pk, ":", name);
$("#fiche-stats").load("/statistiques/update/" + pk);
$("#sujet-name").text(name);
$("#saved-rencontres").hide();
$("#update-stats-btn").attr("pk", pk);
$("#update-stats").show();
};
$("#update-stats").hide();
$(".show-stats-btn").click(function(e) {
var value = $(this).attr("value");
UpdateStats(value);
});
$("#update-stats-btn").click(function(e) {
e.preventDefault();
var pk = $(this).attr("pk");
$.post("/statistiques/update/" + pk + "/", $("#update-stats-form").serialize());
$("#fiche-stats").html("");
$("#saved-rencontres").show();
$("#update-stats").hide();
});
$("#cancel").click(function() {
$("#fiche-stats").html("");
$("#saved-rencontres").show();
$("#update-stats").hide();
});
$.fn.openModalEvent('new-sujet',
'{% url "notes:create-sujet" %}?next={% url "maraudes:create" pk=maraude.pk %}',
'Nouveau sujet');
$.fn.openModalEvent('new-lieu',
'{% url "maraudes:lieu-create" %}?next={% url "maraudes:create" pk=maraude.pk %}',
'Nouveau lieu');
});
</script>
{% endblock %}

View File

@@ -6,9 +6,11 @@
<span class="input-group-btn">
<button id="minus-5" class="btn btn-default btn-sm" type="button"><strong>-5</strong></button>
<button id="plus-5" class="btn btn-default btn-sm" type="button"><strong>+5</strong></button>
</span></div>
</span>
</div>
{% bootstrap_field form.duree layout="inline" size="small" %}
</div>
<div class="form-horizontal">
{{ inline_formset.management_form }}
{% for form in inline_formset %}
@@ -23,5 +25,4 @@
</div>
{% endfor %}
</div>
<script type="text/javascript" src="{% static "scripts/update_time.js" %}"></script>

View File

@@ -1,7 +0,0 @@
{% if maraude.est_terminee %}
{% include "compte_rendu/compterendu.html" %}
{% else %}
{% if perms.maraudes.can_add_compterendu %}<a class="btn btn-primary" href="{% url 'maraudes:create' maraude.pk %}">Écrire le compte-rendu</a>
{% else %} <p class="alert alert-info">Le compte-rendu n'a pas encore été écrit</p>{% endif %}
{% endif %}

View File

@@ -0,0 +1,46 @@
{% extends "maraudes/base.html" %}
{% load bootstrap3 %}
{% block title %} {{ block.super }} Compte-rendu du {{ object.date }} {% endblock %}
{% block breadcrumbs %}
<li><a href="{% url "maraudes:index" %}">{{ object.date }}</a></li>
<li>Transmission</li>
{% endblock %}
{% block page_content %}
{% if object.est_terminee %}<div class="alert alert-warning"><p>Ce compte-rendu a déjà été finalisé !</p></div>
{% else %}
<div class="col-md-12 col-lg-7">
<form method="post" action="{% url 'maraudes:finalize' maraude.pk %}">
{% csrf_token %}
<div class="panel panel-primary panel-collapse">
<div class="panel-heading">
<h4 class="panel-title">Envoyer un message</h4>
</div>
<div class="panel-body">
{% bootstrap_form form %}
</div>
<div class="panel-footer text-right">
{% bootstrap_button "Pas de message" icon="remove" button_type="link" href="?no_mail=True" button_class="btn btn-danger btn-sm" %}
{% bootstrap_button "Envoyer" icon="send" button_type="submit" button_class="btn btn-success btn-sm" %}
</div>
</div>
</form>
</div>
<div class="col-md-12 col-lg-5">
<h4 class="page-header">Rencontres</h4>
<table class="table table-bordered">
{% for rencontre in object.rencontres.all %}<tr><th colspan="2" class="active">{{ rencontre }}</th></tr>
{% for observation in rencontre.observations.all %}<tr>
<td>
<a href="{% url "notes:details-sujet" observation.sujet.pk %}" id="sujet-name-{{observation.sujet.pk}}">{{observation.sujet}}</a>
<a class="btn btn-link btn-xs" nohref onclick="UpdateStats({{observation.sujet.pk}});return false;">
{% bootstrap_icon "stats" %} Mise à jour</a>
</td></tr>
<tr><td>{{observation.text}}</td></tr>{% endfor %}{% endfor %}
</table>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,5 +1,15 @@
{% extends "maraudes/base.html" %}
{% block title %} {{ block.super }} Tableau de bord {% endblock %}
{% block breadcrumbs %}
{{ block.super }}
<li>Tableau de bord</li>
{% endblock %}
{% block page_content %}
{% load tables %}
<div class="col-md-6">
<div class="col-lg-6 col-md-12">
<div class="panel panel-primary">
<div class="panel-heading">
<h4 class="panel-title">Votre prochaine maraude</h4>
@@ -11,24 +21,32 @@
avec {% if user.is_superuser %}{{prochaine_maraude.binome}}{%else%}{{prochaine_maraude.referent}}{%endif%}.
</strong></p>
<hr />
{% if prochaine_maraude.est_terminee %}
<a class="btn btn-sm btn-primary" href="{% url 'maraudes:details' pk=prochaine_maraude.pk %}">
Voir le compte-rendu
</a>{%else%}
<a class="btn btn-sm btn-primary" href="{% url 'maraudes:create' pk=prochaine_maraude.pk %}">
Rédiger le compte-rendu
</a>{% endif %}
{% else %}<p>Aucune maraude prévue.</p>{% endif %}
</div>
</div>
</div>
{% if user.is_superuser %}
<div class="col-md-6">
{% if derniers_sujets_rencontres %}
<div class="panel panel-warning">
<div class="panel-heading">
<h4 class="panel-title">Ces derniers temps...</h4>
</div>
{% table derniers_sujets_rencontres cols=3 cell_template="maraudes/table_cell_derniers_sujets.html" %}
</div>
{% endif %}
{% if user.is_superuser and missing_cr %}
<div class="panel panel-warning">
<div class="panel-heading">
<h4 class="panel-title">Compte-rendus en retard</h4>
</div>
{% table missing_cr cols=2 cell_template="maraudes/missing_cr_table_cell.html" %}
{% table missing_cr cols=2 cell_template="maraudes/table_cell_missing_cr.html" %}
</div>
</div>
{% endif %}
</div>
<div class="col-md-12 col-lg-6">
<h4><strong>Nouvelle note :</strong></h4>
<div class="panel-group" id="accordion" role="tablist" aria-multiselectable="true">
{% include "notes/form_appel.html" with form=appel_form %}
{% include "notes/form_signalement.html" with form=signalement_form %}
</div>
</div>
{% endblock %}

View File

@@ -1 +1,8 @@
{% include "maraudes/lieu_create_inner.html" %}
{% load bootstrap3 %}
<form class="form form-horizontal" action="{% url "maraudes:lieu-create" %}" method="post">{% csrf_token %}
{% bootstrap_form form layout="horizontal"%}
<div class="pull-right">
{% bootstrap_button "Ajouter un lieu" button_type="submit" button_class="btn btn-primary" icon="plus" %}
</div>
<input type="text" hidden=True name="next" value="{{ next }}" />
</form>

View File

@@ -1,8 +0,0 @@
{% load bootstrap3 %}
<form class="form form-horizontal" action="{% url "maraudes:lieu-create" %}" method="post">{% csrf_token %}
{% bootstrap_form form layout="horizontal"%}
<div class="pull-right">
{% bootstrap_button "Ajouter un lieu" button_type="submit" button_class="btn btn-primary" icon="plus" %}
</div>
<input type="text" hidden=True name="next" value="{{ next }}" />
</form>

View File

@@ -1,9 +0,0 @@
{% if object.est_terminee %}<a href="{% url 'maraudes:details' object.id %}" class="btn btn-link">
{% else %}<a href="#" class="btn btn-link disabled">{% endif %}
{{object.date}}</a>
<div class="pull-right">
<span class="label label-info">{{ object.binome }} & {{ object.referent }}</span>
{% if object.est_terminee %}
<span class="label label-success">{{object.rencontres.count}} rencontres</span>
{% endif %}
</div>

View File

@@ -1,32 +0,0 @@
{% load bootstrap3 %}
{% load tables %}
<div class="col-lg-10">
<div class="panel panel-primary">
<!-- Default panel contents -->
<div class="panel-heading text-center">
<h3 class="panel-title">Maraudes passées</h3>
</div>
<!-- Table -->
{% table object_list cols=3 cell_template="maraudes/list_table_cell.html" %}
{% if is_paginated %}
<div class="panel-footer text-center">
<ul class="pagination">
{% for num in page_obj.paginator.page_range %}
<li {% if page_obj.number == num %} class="active" {%endif%}><a href="?page={{num}}">{{num}}</a></li>
{%endfor%}
</ul>
</div>
{% endif %}
</div>
</div>
<div class="pull-left">
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Filtrer <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a href="{% url 'maraudes:liste' %}">Pas de filtre</a></li>
<li><a href="?filter=month-only">Ce mois-ci</a></li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,25 @@
{% load navbar %}
<ul class="nav nav-pills nav-stacked text-right">
<li role="presentation" {% active namespace="maraudes" viewname="index" %}>
<a href="{% url "maraudes:index" %}">Tableau de bord&nbsp;
<span class="glyphicon glyphicon-dashboard"></span>
</a>
</li>
<li role="presentation" {% active namespace="maraudes" viewname="create" %}
{% if not prochaine_maraude.date == date %} class="disabled">
<a href="#">Compte-rendu&nbsp;
<span class="glyphicon glyphicon-pencil"></span></a>
</li> {% else %}>
<a href="{% if prochaine_maraude.est_terminee %}
{% url 'notes:details-maraude' pk=prochaine_maraude.pk %}
{%else%}
{% url 'maraudes:create' pk=prochaine_maraude.pk %}
{% endif %}">Compte-rendu&nbsp;
<span class="glyphicon glyphicon-pencil"></span></a>
</li> {% endif %}
<li role="presentation" {% active namespace="maraudes" viewname="planning" %}>
<a href="{% url "maraudes:planning" %}">Planning&nbsp;
<span class="glyphicon glyphicon-calendar"></span>
</a>
</li>
</ul>

View File

@@ -1,3 +0,0 @@
<a href="{% url 'maraudes:create' pk=object.pk %}" class="btn btn-link">{{object.date}}</a>
<span class="label label-info">{{object.referent}} & {{object.binome}}</span>

View File

@@ -0,0 +1,61 @@
{% extends "maraudes/base.html" %}
{% load bootstrap3 %}
{% block title %} {{ block.super }} Planning {% endblock %}
{% block breadcrumbs %}
{{ block.super }}
<li>Planning</li>
{% endblock %}
{% block sidebar %}
{{ block.super }}
<div class="panel panel-primary text-center">
<div class="panel-body">
<label for="submit-form" class="btn btn-primary">{% bootstrap_icon "floppy-save" %} Enregistrer</label>
<hr />
<form action="" method="get" class="form-horizontal">
<strong>{% bootstrap_icon "calendar" %} Choisir une autre période : </strong>
{% bootstrap_form select_form layout='horizontal' %}
{% bootstrap_button "Choisir" button_type="submit" button_class="btn btn-primary btn-sm" %}
</form>
</div>
</div>
{% endblock %}
{% block page_content %}
<form method="post" action="{% url 'maraudes:planning' %}?month={{month}}&year={{year}}">
<input type="submit" id="submit-form" class="hidden" />
{% csrf_token %}
{{ formset.management_form }}
<table class="table table-bordered">
<tr class="active">
{% for weekday in weekdays %}<th>{{weekday}}</th>{% endfor %}
</tr>
{% for week in weeks %}
<tr>
{% for day, form in week %}
<td style="min-width:5%;" {% if form %}class="{% if form.instance.id %}success{%else%}warning{%endif%}"{% endif %}>{% if day %}{% if form %}
<div class="row">
<div class="col-lg-2">{% endif %}
<strong>{{ day }}</strong>
{% if form %}</div>
<div class="col-lg-10">
{% bootstrap_field form.id %}
{% bootstrap_field form.date %}
{% bootstrap_field form.heure_debut layout="inline" size="small" %}
</div>
</div>
<div class="form-horizontal">
{% bootstrap_field form.binome layout="horizontal" size="small" show_label=False %}
{% bootstrap_field form.referent layout="horizontal" size="small" show_label=False %}
</div>
{% endif %}
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</table>
</form>
{% endblock %}

View File

@@ -0,0 +1 @@
<a href="{% url "notes:details-sujet" object.pk %}">{{ object }}</a>

View File

@@ -0,0 +1 @@
<a class="btn btn-sm btn-primary" href="{% url "maraudes:create" pk=object.pk %}"><span class="glyphicon glyphicon-pencil"></span> {{ object }}</a>

View File

@@ -1,30 +0,0 @@
{% load bootstrap3 %}
<div class="well col-md-12">
<form action="" method="get" class="form-inline text-center">
<label>Période : </label>
{% bootstrap_form select_form layout='inline' %}
{% bootstrap_button "Choisir" button_type="submit" %}
</form>
</div>
<form method="post" action="{% url 'maraudes:planning' %}?month={{month}}&year={{year}}">
{% csrf_token %}
{{ formset.management_form }}
{% for form in formset %}<div class="col-md-4 col-xs-6">
<div class="panel {% if form.instance.pk %}panel-info{%else%}panel-warning{%endif%}">
<div class="panel-heading text-center">
<div class="form-inline">{% if form.id %}{{ form.id }}{% endif %}
{% bootstrap_field form.date size="small" show_label=False %}
{% bootstrap_field form.heure_debut layout="inline" size="small" %}
</div>
</div>
<div class="panel-body">
<div class="form-horizontal">
{% bootstrap_field form.binome layout="horizontal" %}
{% bootstrap_field form.referent layout="horizontal" size="small" %}
</div>
</div>
</div>
</div>{% endfor %}
{% bootstrap_button "Enregistrer" button_type="submit" button_class="btn-primary" %}
</form>

View File

@@ -4,7 +4,10 @@ import random
from calendar import monthrange
from django.test import TestCase
from .models import Maraude, Maraudeur
from .models import (
Maraude, Maraudeur, Planning,
WEEKDAYS, HORAIRES_SOIREE,
)
# Create your tests here.
from maraudes_project.base_data import MARAUDEURS
@@ -31,62 +34,101 @@ def get_maraude_days(start, end):
return maraude_days
class MaraudeManagerTestCase(TestCase):
class PlanningTestCase(TestCase):
def setUp(self):
for maraudeur in MARAUDEURS:
Maraudeur.objects.create(
**maraudeur
)
self.maraudeurs = Maraudeur.objects.all()
#Set up Référent de la Maraude
ref = self.maraudeurs[0]
Maraudeur.objects.set_referent(ref.first_name, ref.last_name)
for i, is_maraude in enumerate(MARAUDE_DAYS):
if is_maraude:
Planning.objects.create(week_day=i, horaire=HORAIRES_SOIREE)
l = len(self.maraudeurs)
today = datetime.date.today()
start_date = today.replace(month=today.month - 1 if today.month > 1 else 12,
day=1)
end_date = today.replace(month=today.month + 1 if today.month < 12 else 1,
day=28)
for i, date in enumerate(get_maraude_days(start_date, end_date)):
i = i % l
if i == 0:
replacement = random.randint(1, l-1)
binome = random.randint(1, l-1)
while binome == replacement:
binome = random.randint(1, l-1)
def test_get_planning(self):
maraudes = {i for i in range(7) if MARAUDE_DAYS[i]}
test_maraudes = set()
for p in Planning.get_planning():
test_maraudes.add(p.week_day)
self.assertEqual(p.horaire, HORAIRES_SOIREE)
self.assertEqual(maraudes, test_maraudes)
Maraude.objects.create(
date=date,
referent=self.maraudeurs[replacement],
binome=self.maraudeurs[binome], # Avoid 0 = referent
)
def test_get_maraudes_days_for_month(self):
test_values = [
{'year': 2017, 'month': 2,
'test': [(day, HORAIRES_SOIREE) for day in (2,3,6,7,9,10,13,14,16,17,20,21,23,24,27,28)] },
{'year': 2016, 'month': 3,
'test': [(day, HORAIRES_SOIREE) for day in (1,3,4,7,8,10,11,14,15,17,18,21,22,24,25,28,29,31)] },
]
for test in test_values:
self.assertEqual(test['test'], list(Planning.get_maraudes_days_for_month(test['year'], test['month'])))
class MaraudeManagerTestCase(TestCase):
maraudeurs = [{"first_name": "Astérix", "last_name": "Le Gaulois"}, {"first_name": "Obélix", "last_name": "et Idéfix"}]
def setUp(self):
first = True
for maraudeur in self.maraudeurs:
if first:
first = False
self.referent = Maraudeur.objects.set_referent(*list(maraudeur.values()))
else:
Maraude.objects.create(
date=date,
referent=ref,
binome=self.maraudeurs[i]
self.binome = Maraudeur.objects.create(
**maraudeur
)
def test_future_maraudes(self):
self.today = datetime.date.today()
self.past_dates = [self.today - datetime.timedelta(d) for d in (1, 3, 5)]
self.future_dates = [self.today + datetime.timedelta(d) for d in (2, 4, 6)]
for date in [self.today,] + self.past_dates + self.future_dates:
Maraude.objects.create(
date = date,
referent = self.referent,
binome = self.binome
)
def retrieve_date(self, maraude):
return maraude.date
def test_all_of(self):
_all = set([self.today, ] + self.past_dates + self.future_dates)
for maraudeur in self.maraudeurs:
maraudeur = Maraudeur.objects.get(**maraudeur)
self.assertEqual(
set(map(self.retrieve_date, Maraude.objects.all_of(maraudeur))),
_all
)
def test_future_maraudes_no_args(self):
""" La liste des futures maraudes """
pass
test_set = set(self.future_dates + [self.today,])
check_set = set(map(self.retrieve_date, Maraude.objects.get_future()))
self.assertEqual(test_set, check_set)
def test_past_maraudes(self):
pass
def test_future_maraudes_are_sorted_by_date(self):
check_generator = iter(sorted(self.future_dates + [self.today,]))
for maraude in Maraude.objects.get_future():
self.assertEqual(maraude.date, next(check_generator))
def test_get_next_maraude(self):
pass
def test_past_maraudes_are_sorted_by_date(self):
check_generator = iter(sorted(self.past_dates))
for maraude in Maraude.objects.get_past():
self.assertEqual(maraude.date, next(check_generator))
def test_past_maraudes_no_args(self):
check_set = set(self.past_dates)
test_set = set(map(self.retrieve_date, Maraude.objects.get_past()))
self.assertEqual(test_set, check_set)
def test_next_property(self):
self.assertEqual(self.retrieve_date(Maraude.objects.next), self.today)
def test_last_property(self):
self.assertEqual(self.retrieve_date(Maraude.objects.last), max(self.past_dates))
def test_get_next_of(self):
pass
self.assertEqual(self.retrieve_date(Maraude.objects.get_next_of(self.binome)), self.today)
def test_all_of_with_referent(self):
pass
def test_all_of_with_maraudeur(self):
pass
class MaraudeTestCase(TestCase):

View File

@@ -7,10 +7,7 @@ from . import views
urlpatterns = [
url(r'^$', views.IndexView.as_view(), name="index"),
url(r'^planning/$', views.PlanningView.as_view(), name="planning"),
url(r'^liste/$', views.MaraudeListView.as_view(), name="liste"),
url(r'^lieu/create/$', views.LieuCreateView.as_view(), name="lieu-create"),
# Compte-rendus de maraude
url(r'^(?P<pk>[0-9]+)/$', views.MaraudeDetailsView.as_view(), name="details"),
url(r'^(?P<pk>[0-9]+)/update/$', views.CompteRenduUpdateView.as_view(), name="update"),
url(r'^(?P<pk>[0-9]+)/create/$', views.CompteRenduCreateView.as_view(), name="create"),
url(r'^(?P<pk>[0-9]+)/finalize/$', views.FinalizeView.as_view(), name="finalize"),
]

View File

@@ -1,116 +1,84 @@
import datetime
import calendar
from django.utils import timezone
from django.contrib import messages
from django.shortcuts import render, redirect
# Views
from django.views import generic
import logging
logger = logging.getLogger(__name__)
from django.utils import timezone
from django.shortcuts import redirect, reverse
from django.views import generic
from django.core.mail import send_mail
from django.forms import modelformset_factory
from django.contrib import messages
from utilisateurs.mixins import MaraudeurMixin
# Models
from .models import ( Maraude, Maraudeur,
CompteRendu,
Rencontre, Lieu,
Planning, )
from .compte_rendu import CompteRendu
from notes.models import Note
# Forms
from django import forms
from django.forms import inlineformset_factory, modelformset_factory, modelform_factory
from django.forms.extras import widgets
from django_select2.forms import Select2Widget
from .forms import ( RencontreForm, RencontreInlineFormSet,
ObservationInlineFormSet, ObservationInlineFormSetNoExtra,
MaraudeAutoDateForm, MonthSelectForm, )
from django.core.mail import send_mail
from .apps import maraudes
from .forms import ( RencontreForm,
ObservationInlineFormSet,
MaraudeHiddenDateForm, MonthSelectForm,
AppelForm, SignalementForm,
SendMailForm )
from notes.mixins import NoteFormMixin
@maraudes.using(title=('La Maraude', 'Tableau de bord'))
class IndexView(generic.TemplateView):
def derniers_sujets_rencontres():
""" Renvoie le 'set' des sujets rencontrés dans les deux dernières maraudes """
sujets = set()
for cr in list(CompteRendu.objects.filter(heure_fin__isnull=False))[-2:]:
for obs in cr.get_observations():
sujets.add(obs.sujet)
return list(sujets)
class IndexView(NoteFormMixin, MaraudeurMixin, generic.TemplateView):
template_name = "maraudes/index.html"
#NoteFormMixin
forms = {
'appel': AppelForm,
'signalement': SignalementForm,
}
def get_initial(self):
now = timezone.localtime(timezone.now())
return {'created_date': now.date(),
'created_time': now.time()}
def get_success_url(self):
return reverse('maraudes:index')
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(**kwargs)
context['prochaine_maraude_abs'] = self.get_prochaine_maraude()
context['prochaine_maraude'] = self.get_prochaine_maraude_for_user()
context['prochaine_maraude'] = Maraude.objects.get_next_of(self.request.user)
context['derniers_sujets_rencontres'] = derniers_sujets_rencontres()
if self.request.user.is_superuser:
context['missing_cr'] = CompteRendu.objects.get_queryset().filter(
heure_fin__isnull=True,
date__lte = timezone.localtime(timezone.now()).date()
date__lt = timezone.localtime(timezone.now()).date()
)
return context
def get_prochaine_maraude_for_user(self):
""" Retourne le prochain objet Maraude auquel
l'utilisateur participe, ou None """
try: #TODO: Clean up this ugly thing
self.maraudeur = Maraudeur.objects.get(username=self.request.user.username)
except:
self.maraudeur = None
if self.maraudeur:
return Maraude.objects.get_next_of(self.maraudeur)
return None
def get_prochaine_maraude(self):
return Maraude.objects.next
## MARAUDES
@maraudes.using(title=('{{maraude.date}}', 'compte-rendu'))
class MaraudeDetailsView(generic.DetailView):
""" Vue détaillé d'un compte-rendu de maraude """
model = CompteRendu
context_object_name = "maraude"
template_name = "maraudes/details.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['notes'] = self.object.get_observations()
return context
@maraudes.using(title=('Liste des maraudes',))
class MaraudeListView(generic.ListView):
""" Vue de la liste des compte-rendus de maraude """
model = CompteRendu
template_name = "maraudes/liste.html"
paginate_by = 30
def get_queryset(self):
current_date = timezone.localtime(timezone.now()).date()
qs = super().get_queryset().filter(
date__lte=current_date
).order_by('-date')
filtre = self.request.GET.get('filter', None)
if filtre == "month-only":
return qs.filter(date__month=current_date.month)
#Other cases...
else:
return qs
## COMPTE-RENDU DE MARAUDE
@maraudes.using(title=('{{maraude.date}}', 'rédaction'))
class CompteRenduCreateView(generic.DetailView):
class CompteRenduCreateView(MaraudeurMixin, generic.DetailView):
""" Vue pour la création d'un compte-rendu de maraude """
model = CompteRendu
template_name = "compte_rendu/compterendu_create.html"
template_name = "maraudes/compterendu.html"
context_object_name = "maraude"
form = None
inline_formset = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
#WARNING: Overrides app_menu and replace it
self._user_menu = ["compte_rendu/menu/creation.html"]
def get_forms(self, *args, initial=None):
self.form = RencontreForm(*args,
initial=initial)
@@ -119,25 +87,6 @@ class CompteRenduCreateView(generic.DetailView):
instance=self.form.instance
)
def finalize(self):
print('finalize !')
maraude = self.get_object()
maraude.heure_fin = timezone.now()
maraude.save()
# Redirect to a new view to edit mail ??
# Add text to some mails ? Transmission, message à un référent, etc...
# Send mail to Maraudeurs
_from = maraude.referent.email
# Shall select only Maraudeur where 'is_active' is True !
recipients = [m for m in Maraudeur.objects.all() if m not in (maraude.referent, maraude.binome)]
objet = "Compte-rendu de maraude : %s" % maraude.date
message = "Sujets rencontrés : ..." #TODO: Mail content
send_mail(objet, message, _from, recipients)
return redirect("maraudes:details",
pk=maraude.pk
)
def post(self, request, *args, **kwargs):
self.get_forms(request.POST, request.FILES)
if self.form.has_changed():
@@ -151,8 +100,6 @@ class CompteRenduCreateView(generic.DetailView):
return redirect('maraudes:create', pk=self.get_object().pk)
def get(self, request, new_form=True, *args, **kwargs):
if request.GET.get('finalize', False) == "True":
return self.finalize()
def calculate_end_time(debut, duree):
end_minute = debut.minute + duree
@@ -186,76 +133,85 @@ class CompteRenduCreateView(generic.DetailView):
context['form'] = self.form
context['inline_formset'] = self.inline_formset
context['rencontres'] = self.get_object().rencontres.order_by("-heure_debut")
# Link there so that "Compte-rendu" menu item is not disabled
context['prochaine_maraude'] = self.object
return context
@maraudes.using(title=('{{maraude.date}}', 'mise à jour'))
class CompteRenduUpdateView(generic.DetailView):
""" Vue pour mettre à jour le compte-rendu de la maraude """
class FinalizeView( MaraudeurMixin,
generic.detail.SingleObjectMixin,
generic.edit.FormView):
model = CompteRendu
context_object_name = "maraude"
template_name = "compte_rendu/compterendu_update.html"
template_name = "maraudes/finalize.html"
model = Maraude
form_class = SendMailForm
success_url = "/maraudes/"
base_formset = None
inline_formsets = []
rencontres_queryset = None
forms = None
def get(self, *args, **kwargs):
print(self.request.GET)
if bool(self.request.GET.get("no_mail", False)) == True:
messages.warning(self.request, "Aucun compte-rendu n'a été envoyé !")
return self.finalize()
return super().get(*args, **kwargs)
def get_forms_with_inline(self, *args):
self.base_formset = RencontreInlineFormSet(
*args,
instance=self.get_object(),
prefix="rencontres"
)
def get_initial(self):
maraude = self.get_object()
objet = "%s - Compte-rendu de maraude" % maraude.date
sujets_rencontres = set()
for r in maraude.rencontres.all():
for s in r.get_sujets():
sujets_rencontres.add(s)
message = "Nous avons rencontré : " + ", ".join(map(str, sujets_rencontres)) + ".\n\n"
return {
"subject": objet,
"message": message
}
self.inline_formsets = []
for i, instance in enumerate(self.get_object()):
inline_formset = ObservationInlineFormSetNoExtra(
*args,
instance = instance,
prefix = "observation-%i" % i
)
self.inline_formsets.append(inline_formset)
def finalize(self):
maraude = self.get_object()
maraude.heure_fin = timezone.localtime(timezone.now()).time()
maraude.save()
return redirect(self.get_success_url())
# Aucun nouveau formulaire de 'Rencontre' n'est inclus.
self.forms = [(self.base_formset[i], self.inline_formsets[i]) for i in range(len(self.inline_formsets))]
def form_valid(self, form):
# Send mail
maraude = self.get_object()
recipients = Maraudeur.objects.filter(
is_active=True
).exclude(
pk__in=(maraude.referent.pk,
maraude.binome.pk)
)
result = send_mail(
form.cleaned_data['subject'],
form.cleaned_data['message'],
maraude.referent.email,
[m.email for m in recipients],
)
def post(self, request, *args, **kwargs):
self.get_forms_with_inline(request.POST, request.FILES)
self.errors = False
if self.base_formset.is_valid():
for inline_formset in self.inline_formsets:
if inline_formset.is_valid():
inline_formset.save()
self.base_formset.save()
if result == 1:
messages.success(self.request, "Le compte-rendu a été transmis à %s" % ", ".join(map(str, recipients)))
else:
self.errors = True
messages.error(self.request, "Erreur lors de l'envoi du message !")
return self.finalize()
if self.errors or request.GET['continue'] == "False": # Load page to display errors
return self.get(request, *args, **kwargs)
return redirect('maraudes:details', pk=self.get_object().pk)
def get(self, request, *args, **kwargs):
self.get_forms_with_inline()
return super().get(request, *args, **kwargs)
def get_context_data(self, *args, **kwargs):
def get_context_data(self, **kwargs):
self.object = self.get_object()
context = super().get_context_data(**kwargs)
context['base_formset'] = self.base_formset
context['forms'] = self.forms
if self.object.est_terminee is True:
context['form'] = None#Useless form
return context
# Link there so that "Compte-rendu" menu item is not disabled
context['prochaine_maraude'] = self.object
return context
## PLANNING
@maraudes.using(title=('Planning',))
class PlanningView(generic.TemplateView):
""" Display and edit the planning of next Maraudes """
template_name = "planning/planning.html"
class PlanningView(MaraudeurMixin, generic.TemplateView):
""" Vue d'édition du planning des maraudes """
template_name = "maraudes/planning.html"
def _parse_request(self):
self.current_date = datetime.date.today()
@@ -287,7 +243,7 @@ class PlanningView(generic.TemplateView):
self._calculate_initials()
return modelformset_factory(
Maraude,
form = MaraudeAutoDateForm,
form = MaraudeHiddenDateForm,
extra = len(self.initials),
)(
*args,
@@ -300,24 +256,54 @@ class PlanningView(generic.TemplateView):
for form in self.formset.forms:
if form.is_valid():
form.save()
return redirect('maraudes:index')
else:
logger.info("Form was ignored ! (%s)" % (form.errors.as_data()))
return redirect('maraudes:planning')
def get(self, request):
self.formset = self.get_formset()
return super().get(request)
def get_weeks(self):
""" List of (day, form) tuples, split by weeks """
def form_generator(forms):
""" Yields None until the generator receives the day of
next form.
"""
forms = iter(sorted(forms, key=lambda f: f.initial['date']))
day = yield
for form in forms:
while day != form.initial['date'].day:
day = yield None
day = yield form
while True: # Avoid StopIteration
day = yield None
form_or_none = form_generator(self.formset)
form_or_none.send(None)
return [
[(day, form_or_none.send(day)) for day in week]
for week in calendar.monthcalendar(self.year, self.month)
]
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['weekdays'] = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
context['weeks'] = self.get_weeks()
context['formset'] = self.formset
context['select_form'] = MonthSelectForm(month=self.month, year=self.year)
context['month'], context['year'] = self.month, self.year
return context
## LIEU
@maraudes.using(ajax=True)
class LieuCreateView(generic.edit.CreateView):
""" Vue de création d'un lieu """
model = Lieu
template_name = "maraudes/lieu_create.html"
fields = "__all__"

View File

@@ -0,0 +1 @@
default_app_config = 'notes.apps.NotesConfig'

38
notes/actions.py Normal file
View File

@@ -0,0 +1,38 @@
from .models import Sujet
from statistiques.models import FicheStatistique, NSP
def merge_stats(main, merged):
""" Merge stats of two sujets according to priority order : main, then merged """
# TODO: replace hardcoded field names with more flexible getters
# Fields of 'Sujet' model
for field in ('nom', 'prenom', 'surnom', 'age',):
if not getattr(main, field):
setattr(main, field, getattr(merged, field, None))
# Première rencontre : retenir la plus ancienne
if merged.premiere_rencontre:
if not main.premiere_rencontre or main.premiere_rencontre > merged.premiere_rencontre:
main.premiere_rencontre = merged.premiere_rencontre
# Fields of 'FicheStatistique' model
# NullBoolean fields
for field in ('prob_psychiatrie', 'prob_somatique',
'prob_administratif', 'prob_addiction',
'connu_siao', 'lien_familial'):
if not getattr(main.statistiques, field): # Ignore if already filled
setattr(main.statistiques, field, getattr(merged.statistiques, field, None))
# Choice fields, None is NSP
for field in ('habitation', 'ressources', 'parcours_de_vie'):
if getattr(main.statistiques, field) == NSP: # Ignore if already filled
setattr(main.statistiques, field, getattr(merged.statistiques, field, NSP))
def merge_two(main, merged):
""" Merge 'merged' sujet into 'main' one """
merge_stats(main, merged) # Merge statistics and informations
for note in merged.notes.all(): # Move all notes
note.sujet = main
note.save()
main.save()
merged.delete()

View File

@@ -3,6 +3,15 @@ from django.contrib import admin
from .models import *
# Register your models here.
@admin.register(Sujet)
class SujetAdmin(admin.ModelAdmin):
fieldsets = [
('Identité', {'fields': [('nom', 'prenom'), 'genre']}),
('Informations', {'fields': ['age', ]}),
]
@admin.register(Note)
class NoteAdmin(admin.ModelAdmin):
@@ -16,4 +25,4 @@ class NoteAdmin(admin.ModelAdmin):
]
list_display = ['created_date', 'sujet', 'child_class', 'text']
list_filter = ('sujet', 'created_date', 'created_by')
list_filter = ('created_date', 'created_by')

View File

@@ -1,5 +1,9 @@
from django.apps import AppConfig
from watson import search as watson
class NotesConfig(AppConfig):
name = 'notes'
def ready(self):
Sujet = self.get_model("Sujet")
watson.register(Sujet, fields=('nom', 'prenom', 'surnom'))

View File

@@ -1,11 +1,12 @@
from .models import Note
import datetime
from .models import Note, Sujet
from utilisateurs.models import Professionnel
from django import forms
from django_select2.forms import Select2Widget
from django.forms import Textarea
### NOTES
class NoteForm(forms.ModelForm):
""" Generic Note form """
@@ -14,7 +15,7 @@ class NoteForm(forms.ModelForm):
fields = ['sujet', 'text', 'created_by', 'created_date', 'created_time']
widgets = {
'sujet': Select2Widget(),
'text': Textarea(attrs={'rows':4}),
'text': forms.Textarea(attrs={'rows':4}),
}
@@ -54,6 +55,8 @@ class UserNoteForm(NoteForm):
instance.save()
return instance
class AutoNoteForm(UserNoteForm):
class Meta(UserNoteForm.Meta):
fields = ['text']
@@ -68,3 +71,24 @@ class AutoNoteForm(UserNoteForm):
if commit:
inst.save()
return inst
### SUJETS
current_year = datetime.date.today().year
YEAR_CHOICE = tuple(year - 2 for year in range(current_year, current_year + 10))
class SujetCreateForm(forms.ModelForm):
class Meta:
model = Sujet
fields = ['nom', 'surnom', 'prenom', 'genre', 'premiere_rencontre']
widgets = {
'premiere_rencontre': forms.SelectDateWidget( empty_label=("Année", "Mois", "Jour"),
years = YEAR_CHOICE,
),
}
class SelectSujetForm(forms.Form):
sujet = forms.ModelChoiceField(queryset=Sujet.objects.all(), widget=Select2Widget)

View File

@@ -1,9 +1,74 @@
import logging
from django.utils import timezone
from django.utils.html import format_html
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.db import models
from . import managers
logger = logging.getLogger(__name__)
HOMME = 'M'
FEMME = 'Mme'
GENRE_CHOICES = (
(HOMME, 'Homme'),
(FEMME, 'Femme'),
)
class Sujet(models.Model):
""" Personne faisant l'objet d'un suivi par la maraude
"""
genre = models.CharField("Genre",
max_length=3,
choices=GENRE_CHOICES,
default=HOMME)
nom = models.CharField(max_length=32, blank=True)
prenom = models.CharField(max_length=32, blank=True)
surnom = models.CharField(max_length=64, blank=True)
premiere_rencontre = models.DateField(
blank=True, null=True,
default=timezone.now
)
age = models.SmallIntegerField(
blank=True, null=True
)
# referent = models.ForeignKey("utilisateurs.Professionnel", related_name="suivis")
def __str__(self):
string = '%s ' % self.genre
if self.nom: string += '%s ' % self.nom
if self.surnom: string += '"%s" ' % self.surnom
if self.prenom: string += '%s' % self.prenom
return string
def clean(self):
if not any([self.nom, self.prenom, self.surnom]):
raise ValidationError("Vous devez remplir au moins un nom, prénom ou surnom")
return super().clean()
def save(self, *args, **kwargs):
self.clean()
if not self.id:
from statistiques.models import FicheStatistique
super().save(*args, **kwargs)
fiche = FicheStatistique.objects.create(sujet=self)
else:
return super().save(*args, **kwargs)
class Meta:
verbose_name = "Sujet"
ordering = ('surnom', 'nom', 'prenom')
def get_absolute_url(self):
return reverse("notes:details-sujet", kwargs={"pk": self.pk })
class Note(models.Model):
""" Note relative à un sujet.
@@ -19,11 +84,12 @@ class Note(models.Model):
objects = managers.NoteManager()
sujet = models.ForeignKey(
'sujets.Sujet',
Sujet,
related_name="notes",
on_delete=models.CASCADE
)
text = models.TextField("Texte")
created_by = models.ForeignKey(
'utilisateurs.Professionnel',
blank=True,
@@ -42,7 +108,11 @@ class Note(models.Model):
return super().save(*args, **kwargs)
def __str__(self):
return "%s" % (self.child_class.__qualname__)
return "<%s: %s>" % (self.child_class.__qualname__, self.sujet)
@classmethod
def __str__(cls):
return "<%s>" % cls.__qualname__
def note_author(self):
return None

View File

@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Notes >{% endblock %}
{% block breadcrumbs %}
<li>Notes</li>
{% endblock %}
{% block sidebar %}
<div class="panel panel-default">
<div class="panel-body">
{% include "notes/menu.html" %}
</div>
</div>{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends "notes/base.html" %}
{% load bootstrap3 notes %}
{% block page_content %}
<div class="col-lg-8 col-md-12">
<div class="panel-group" id="accordion" role="tablist" aria-multiselectable="true">
<div class="panel panel-primary">
<div class="panel-heading" role="tab" id="notesHeading">
<h3 class="panel-title">
<a role="button" data-toggle="collapse" data-parent="#accordion" href="#collapseNotes" aria-expanded="true" aria-controls="collapseOne">
Notes</a>
</h3>
</div>
{% block pre_content %}{% endblock %}
<table class="table table-striped table-bordered">
{% for note in notes %}
{% if maraude %}
{% inline_table note header="sujet" %}
{% elif sujet %}
{% inline_table note header="date" %}
{% endif %}
{% endfor %}
</table>
{% block post_content %}{% endblock %}
</div>
</div>
</div>
<div class="col-lg-4 col-md-12">
{% block right_column %}{% endblock %}
</div>
{% endblock %}

View File

@@ -0,0 +1,47 @@
{% extends "notes/details.html" %}
{% block title %}
{{ block.super }} {{ maraude }}
{% endblock %}
{% block breadcrumbs %}
{{ block.super }}
<li><a href="{% url "notes:liste-maraude" %}">Maraudes</a></li>
<li>{{ maraude }}</li>
{% endblock %}
{% block sidebar %}
<div class="panel panel-primary text-right">
<div class="panel-body text-right">
<h4 class="panel-title">Navigation</h4>
<nav aria-label="Maraudes navigation">
<ul class="pagination">
<li {% if not prev_maraude %}class="disabled"{% endif %}>
<a href="{% if prev_maraude %}{% url "notes:details-maraude" prev_maraude.pk %}{% else %}#{% endif %}" aria-label="Previous">
&nbsp; <span aria-hidden="true" class="glyphicon glyphicon-chevron-left"></span>
</a>
</li>
<li {% if not next_maraude %}class="disabled"{% endif %}>
<a href="{% if next_maraude %}{% url "notes:details-maraude" next_maraude.pk %}{%else%}#{% endif %}" aria-label="Next">
<span aria-hidden="true" class="glyphicon glyphicon-chevron-right"></span> &nbsp;
</a>
</li>
</ul>
</nav>
</div>
</div>
{% endblock %}
{% block right_column %}
<div class="panel panel-default">
<div class="panel-heading" role="tab" id="notesSujetHeading">
<h3 class="panel-title">
Informations</h3>
</div>
<div class="panel-body">
<p><strong>Maraudeurs :</strong>&nbsp; {{ maraude.binome }} & {{ maraude.referent }}</p>
<p><strong>Nombre de rencontres</strong>&nbsp; {{ maraude.rencontres.count }}</p>
<p><strong>Nombre de personnes rencontrées</strong>&nbsp; {{ maraude.observations_count }}</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,103 @@
{% extends "notes/details.html" %}
{% load bootstrap3 %}
{% block title %}
{{ block.super }} {{ sujet }}
{% endblock %}
{% block breadcrumbs %}
{{ block.super }}
<li><a href="{% url "notes:liste-sujet" %}">Sujets</a></li>
<li>{{ sujet }}</li>
{% endblock %}
{% block pre_content %}
<div id="collapseNotes" class="panel-collapse collapse in" role="tabpanel" aria-labelledby="notesHeading">
{% endblock %}
{% block post_content %}
{% if notes.has_other_pages %}<div class="panel-footer text-center">
<ul class="pagination">
{% for num in notes.paginator.page_range %}
<li {% if notes.number == num %} class="active" {%endif%}><a href="?page={{num}}">{{num}}</a></li>
{%endfor%}
</ul>
</div>{% endif %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading" role="tab" id="notesAjoutHeading">
<h4 class="panel-title">
<a class="collapsed" role="button" data-toggle="collapse" data-parent="#accordion" href="#collapseNotesAjout" aria-expanded="false" aria-controls="collapseTwo">
{% bootstrap_icon "plus" %} Ajouter une note
</a>
</h4>
</div>
<div id="collapseNotesAjout" class="panel-collapse collapse" role="tabpanel" aria-labelledby="notesAjoutHeading">
<div class="panel-body">
<form method="POST" action="">{% csrf_token %}
{% bootstrap_form note_form show_label=False %}
</div>
<div class="panel-footer text-right">
{% bootstrap_button "Enregistrer" button_type="submit" %}
</form>
</div>
</div>
{% endblock %}
{% block right_column %}
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">Informations</h4>
</div>
{% include "notes/details_sujet_inner.html" %}
</div>
<div class="panel panel-default">
<div class="panel-heading"><h4 class="panel-title">Statistiques</h4></div>
<div id="stats-content">
{% include "statistiques/fiche_stats_details.html" with object=sujet.statistiques %}
</div>
<div class="panel-footer text-right">
<span class="text-right" id="normal-buttons">
<p>{% bootstrap_icon "tasks" %} {{ sujet.statistiques.info_completed }}%&nbsp;
<span class="btn btn-primary btn-sm" id="update-stats">Mettre à jour</span></p>
</span>
<span class="text-right" id="update-buttons">
<label for="submit-form" class="btn btn-primary">{% bootstrap_icon "floppy-save" %} Enregistrer</label>
<span class="btn btn-primary btn-sm" id="cancel">Annuler</span>
</span>
</div>
</div>
<script type="text/javascript">
$(function() {
$("#update-buttons").hide();
$("#update-stats").click(function() {
$("#stats-content").load("{% url "statistiques:update" sujet.pk %}");
$("#normal-buttons").hide();
$("#update-buttons").show();
});
$("#cancel").click(function() {
$("#stats-content").load("{% url "statistiques:details" sujet.pk %}");
$("#update-buttons").hide();
$("#normal-buttons").show();
});
});
</script>
{% endblock %}
{% block sidebar %}
{{ block.super }}
<hr />
{% if user.is_superuser %}
<div class="panel panel-primary text-right"><div class="panel-heading"><h4 class="panel-title"><strong>Administration :</strong></h4></div>
<div class="panel-body">
<div class="btn-group" role="group" aria-label="...">
<a href="{% url 'admin:notes_note_changelist' %}?sujet__exact={{sujet.pk}}" class="btn btn-primary">{% bootstrap_icon "wrench" %} Éditer les notes</a>
<a href="{% url 'notes:sujets-merge' pk=object.pk %}" class="btn btn-default">{% bootstrap_icon "paste" %} Fusionner</a>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,22 @@
<div id="sujet-content">
<table class="table table-striped">
{% with "-" as none %}
<tr><th>Nom</th><th>Surnom</th><th>Prénom</th></tr>
<tr><td>{{ sujet.nom|default:none }}</td><td>{{ sujet.surnom|default:none}}</td><td>{{ sujet.prenom|default:none}}</td></tr>
<tr><th>Âge</th><th colspan="2">Première rencontre</th></tr>
<tr><td>{{ sujet.age|default_if_none:none }}</td><td colspan="2">{{ sujet.premiere_rencontre|default_if_none:none }}</td></tr>
{% endwith %}
</table>
<div class="panel-footer text-right" id="sujet-buttons">
<span class="text-right"><span class="btn btn-primary btn-sm" id="update-sujet">Mettre à jour</a></span>
</div>
</div>
<script type="text/javascript">
$(function() {
$("#update-sujet").click(function() {
console.log("update !")
$("#sujet-content").load("{% url "notes:update-sujet" sujet.pk %}");
});
});
</script>

View File

@@ -0,0 +1,24 @@
{% load bootstrap3 %}
<form action="{% url "notes:update-sujet" form.instance.pk %}" method="post">{% csrf_token %}
<table class="table table-striped">
{% with "-" as none %}
<tr><th>Nom</th><th>Surnom</th><th>Prénom</th></tr>
<tr><td>{% bootstrap_field form.nom show_label=False %}</td><td>{% bootstrap_field form.surnom show_label=False %}</td><td>{% bootstrap_field form.prenom show_label=False %}</td></tr>
<tr><th>Âge</th><th>Genre</th><th>Première rencontre</th></tr>
<tr><td>{% bootstrap_field form.age show_label=False %}</td><td>{% bootstrap_field form.genre show_label=False %}</td><td>{% bootstrap_field form.premiere_rencontre show_label=False %}</td></tr>
{% endwith %}
</table>
<div class="panel-footer text-right">
<span class="text-right">{% bootstrap_button "Enregistrer" button_type="submit" %}
<span class="btn btn-primary btn-sm" id="cancel">Annuler</span></span>
</div>
</form>
<script type="text/javascript">
$(function() {
$("#cancel").click(function() {
console.log("cancelled !")
$("#sujet-content").load("{% url "notes:sujet" form.instance.pk %}");
});
});
</script>

View File

@@ -1,12 +1,12 @@
<div class="panel panel-primary">
<div class="panel panel-default">
<div class="panel-heading" role="tab" id="appelHeading">
<h4 class="panel-title">
<a role="button" data-toggle="collapse" data-parent="#accordion" href="#appelCollapse" aria-expanded="true" aria-controls="collapseOne">
Appel</a></h4>
<span class="glyphicon glyphicon-earphone"></span> Appel</a></h4>
</div>
<div id="appelCollapse" class="panel-collapse collapse" role="tabpanel" aria-labelledby="appelHeading">
<div class="panel-body">
{% include "suivi/appel_form_inner.html" %}</div>
{% include "notes/form_appel_inner.html" %}</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
{% load bootstrap3 %}
<form action="" method="POST">{% csrf_token %}
{% with "inline" as layout %}
<div class="form-{{layout}} well col-md-10 col-md-offset-2">
<div class="form-{{layout}} well well-sm text-center">
{% bootstrap_field form.created_date layout=layout %}
{% bootstrap_field form.created_time layout=layout %}
{% bootstrap_field form.entrant layout=layout %}
@@ -14,3 +14,4 @@
<div class="pull-right">{% bootstrap_button "Enregistrer l'appel" button_type="submit" %}</div>
</form>
{{ form.media.js }}{{ form.media.css }}

View File

@@ -1,12 +1,12 @@
<div class="panel panel-warning">
<div class="panel panel-default">
<div class="panel-heading" role="tab" id="signalementHeading">
<h4 class="panel-title">
<a role="button" data-toggle="collapse" data-parent="#accordion" href="#signalementCollapse" aria-expanded="true" aria-controls="collapseOne">
Signalement</a></h4>
<span class="glyphicon glyphicon-warning-sign"></span> Signalement</a></h4>
</div>
<div id="signalementCollapse" class="panel-collapse collapse" role="tabpanel" aria-labelledby="signalementHeading">
<div class="panel-body">
{% include "suivi/signalement_form_inner.html" %}
{% include "notes/form_signalement_inner.html" %}
</div>
</div>
</div>

View File

@@ -0,0 +1,16 @@
{% extends "notes/base.html" %}
{% block title %}{{block.super}} Tableau de bord{% endblock %}
{% block breadcrumbs %}
{{ block.super }}
<li>Tableau de bord</li>
{% endblock %}
{% block page_content %}
<div class="col-md-12 col-lg-6">
<h2 class="page-header">TODO</h2>
<p>Liste des sujets qui ont été ajoutés depuis la dernière connexion</p>
<p>Liste des compte-rendus qui ont été ajoutés depuis la dernière connexion</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,34 @@
{% extends "notes/base.html" %}
{% load tables %}
{% block sidebar %}
{{ block.super }}
{% block sidebar_insert %}{% endblock %}
{% if filters %}
<div class="well">
<h4 class="text-right"><span class="glyphicon glyphicon-filter"></span> <strong>Filtres</strong></h4>
<ul class="nav nav-pills nav-stacked text-right">
{% for filter in filters %}
<li role="presentation" {% if filter.active %} class="active" {% endif %}>
<a href="?filter={{filter.parameter_name}}">{{ filter.title }}</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endblock %}
{% block page_content %}
{% block search %}{% endblock %}
<!-- Table -->
{% table object_list cols=3 cell_template=table_cell_template header=table_header %}
{% if is_paginated %}
<div class="text-center">
<ul class="pagination">{% with request.GET.filter as filter %}
{% for num in page_obj.paginator.page_range %}
<li {% if page_obj.number == num %} class="active" {%endif%}><a href="?{% if filter %}filter={{ filter }}&{%endif%}page={{num}}">{{num}}</a></li>
{%endfor%}{% endwith %}
</ul>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends "notes/liste.html" %}
{% block title %}
{{ block.super }} Liste des maraudes
{% endblock %}
{% block breadcrumbs %}
{{block.super}}
<li><a href="{% url "notes:liste-maraude" %}">Maraudes</a></li>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% extends "notes/liste.html" %}
{% block title %}{{block.super}} Liste des sujets {% endblock %}
{% block breadcrumbs %}
{{ block.super }}
<li><a href="{% url "notes:liste-sujet" %}">Sujets</a></li>
{% if query_text %}<li>'{{query_text}}'</li>{% endif %}
{% endblock %}
{% block sidebar_insert %}
<div class="panel panel-primary text-right">
<div class="panel-body">
<h4><strong>Rechercher</strong></h4>
<form action="{% url "notes:liste-sujet" %}" method="POST" class="form form-group">{% csrf_token %}
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Chercher un sujet" aria-describedby="basic-addon1">
<span class="input-group-btn">
<button type="submit" class="btn btn-primary"><span class=" glyphicon glyphicon-search"></span> </button>
</span>
</div>
</form>
<hr/>
<h4><strong>Outils</strong></h4>
<a class="btn btn-primary" href="{% url "notes:create-sujet" %}">
<span class="glyphicon glyphicon-plus"></span> Ajouter un sujet</a> </div></div>
{% endblock %}
{% block search %}
{% if query_text %}<div class="well well-sm text-center">
<h4><span class="label label-primary">'{{query_text}}'</span>
<span class="label label-danger">
{% if not object_list %}Aucun résultat
{% else %} {{ object_list.count }} résultats
{% endif %}
</span></h4>
</div>{% endif %}
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% load navbar %}
<ul class="nav nav-pills nav-stacked text-right">
<li role="presentation" {% active namespace="notes" viewname="liste-sujet" %}>
<a href="{% url "notes:liste-sujet" %}">Par sujet&nbsp;
<span class="glyphicon glyphicon-user"></span>
</a>
</li>
<li role="presentation" {% active namespace="notes" viewname="liste-maraude" %}>
<a href="{% url "notes:liste-maraude" %}">Par maraude&nbsp;
<span class="glyphicon glyphicon-road"></span>
</a>
</li>
</ul>

View File

@@ -0,0 +1,9 @@
{% extends "notes/base.html" %}
{% block page_content %}
<div class="row">
<div class="col-md-12">
{% include "notes/sujet_create_inner.html" %}
</div>
</div>
{% endblock %}

View File

@@ -1,5 +1,5 @@
{% load bootstrap3 %}
<form class="form-horizontal" action="{% url "sujets:create" %}" method="post">{% csrf_token %}
<form class="form-horizontal" action="{% url "notes:create-sujet" %}" method="post">{% csrf_token %}
{% bootstrap_form form layout="horizontal"%}
<div class="pull-right">
{% bootstrap_button "Ajouter un sujet" button_type="submit" button_class="btn btn-primary" icon="plus" %}

View File

@@ -0,0 +1,12 @@
{% extends "notes/base.html" %}
{% block breadcrumbs %}
{{ block.super }}
<li><a href="{% url "notes:liste-sujet" %}">Sujets</a></li>
<li><a href="{% url "notes:details-sujet" object.pk %}">{{ object }}</a></li>
<li>Fusionner vers...</li>
{% endblock %}
{% block page_content %}
{% include 'notes/sujet_merge_inner.html' %}
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% load bootstrap3 %}
<p> Vous allez fusionner la fiche de <strong>{{object}}</strong> et ses {{object.notes.count}} notes vers :</p>
<form action="{% url 'notes:sujets-merge' pk=object.pk %}" method="POST">
{% csrf_token %}
{{ form.media }}
{% bootstrap_field form.sujet %}
<div class="pull-right">{% bootstrap_button 'Fusionner' button_type='submit' icon='paste' %}</div>
</form>

View File

@@ -0,0 +1,6 @@
{% if object.est_terminee %}<a href="{% url 'notes:details-maraude' object.id %}" class="btn btn-link">
{% else %}<a href="#" class="btn btn-link disabled">{% endif %}{{ object }}</a>
<div class="pull-right">
<span class="label label-info">{{ object.binome }} & {{ object.referent }}</span>
{% if object.est_terminee %}<span class="label label-success">{{object.rencontres.count}} rencontres</span>{% endif %}
</div>

View File

@@ -0,0 +1,9 @@
<a href="{% url 'notes:details-sujet' object.pk %}" class="btn btn-link">{{object}}</a>
<div class="pull-right" style="padding-right: 20px;">
<span class="label label-info">{{ object.notes.count }} notes</span>
{% with object.statistiques.info_completed as completed %}
<span class="label label-{% if completed <= 80 %}warning{%else%}success{%endif%}">{{ completed }} %</span>
{% endwith %}
</div>

View File

@@ -5,6 +5,7 @@ register = template.Library()
@register.inclusion_tag("notes/table_inline.html")
def inline_table(note, header=None):
from maraudes.models import Maraude
bg_color, bg_label_color = note.bg_colors
if not header:
@@ -14,10 +15,14 @@ def inline_table(note, header=None):
if header == "date":
header_field = "created_date"
link = None
try:
maraude = Maraude.objects.get(date=note.created_date)
link = maraude.get_absolute_url()
except Maraude.DoesNotExist:
link = None
elif header == "sujet":
header_field = "sujet"
link = reverse("suivi:details", kwargs={'pk': note.sujet.pk})
link = note.sujet.get_absolute_url()
header = getattr(note, header_field)

View File

@@ -1,7 +1,28 @@
from django.core.exceptions import ValidationError
from django.test import TestCase
from .models import Note
from .models import Note, Sujet
# Create your tests here.
# TODO: test 'actions.py'
class SujetModelTestCase(TestCase):
def setUp(self):
pass
def test_statistiques_is_autocreated(self):
new_sujet = Sujet.objects.create(prenom="Astérix")
self.assertIsNotNone(new_sujet.statistiques)
def test_at_least_one_in_name_surname_firstname(self):
self.assertIsInstance(Sujet.objects.create(nom="DeGaulle"), Sujet)
self.assertIsInstance(Sujet.objects.create(surnom="Le Gaulois"), Sujet)
self.assertIsInstance(Sujet.objects.create(prenom="Astérix"), Sujet)
def test_raises_validation_error_if_no_name(self):
with self.assertRaises(ValidationError):
Sujet.objects.create(age=25)
class NoteManagerTestCase(TestCase):
""" managers.NoteManager Test Case """

16
notes/urls.py Normal file
View File

@@ -0,0 +1,16 @@
from django.conf.urls import url
from . import views
urlpatterns = [
url(r'^$', views.IndexView.as_view(), name="index"),
url(r'sujets/$', views.SujetListView.as_view(), name="liste-sujet"),
url(r'sujets/(?P<pk>[0-9]+)/$', views.SuiviSujetView.as_view(), name="details-sujet"),
url(r'sujets/(?P<pk>[0-9]+)/merge/$', views.MergeView.as_view(), name="sujets-merge"),
url(r'maraudes/$', views.MaraudeListView.as_view(), name="liste-maraude"),
url(r'maraudes/(?P<pk>[0-9]+)/$', views.CompteRenduDetailsView.as_view(), name="details-maraude"),
# Manage Sujet
url(r'sujets/create/$', views.SujetCreateView.as_view(), name="create-sujet"),
url(r'sujet/(?P<pk>[0-9]+)/$', views.SujetAjaxDetailsView.as_view(), name="sujet"),
url(r'sujet/(?P<pk>[0-9]+)/update/$', views.SujetAjaxUpdateView.as_view(), name="update-sujet"),
]

254
notes/views.py Normal file
View File

@@ -0,0 +1,254 @@
import logging
import datetime
from django.shortcuts import redirect, reverse
from django.views import generic
from django.utils import timezone
from django.contrib import messages
from django.http.response import HttpResponseNotAllowed
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from utilisateurs.mixins import MaraudeurMixin
from maraudes.models import Maraude, CompteRendu
from .models import Sujet, Note
from .forms import SujetCreateForm, AutoNoteForm, SelectSujetForm
from .mixins import NoteFormMixin
from .actions import merge_two
logger = logging.getLogger(__name__)
# Create your views here.
class IndexView(MaraudeurMixin, generic.TemplateView):
template_name = "notes/index.html"
def get(self, *args, **kwargs):
return redirect("notes:liste-sujet")
class Filter:
def __init__(self, title, name, filter_func):
self.title = title
self.parameter_name = name
self.active = False
self._filter_func = filter_func
def filter(self, qs):
return self._filter_func(qs)
class ListView(MaraudeurMixin, generic.ListView):
""" Base ListView for Maraude and Sujet lists """
paginate_by = 30
cell_template = None
filters = []
active_filter = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._filters = {}
if self.filters:
for i, (title, func) in enumerate(self.filters):
_id = "filter_%i" % i
self._filters[_id] = Filter(title, _id, func)
def get(self, request, **kwargs):
filter_name = self.request.GET.get('filter', None)
if filter_name:
self.active_filter = self._filters.get(filter_name, None)
if self.active_filter:
self.active_filter.active = True
return super().get(request, **kwargs)
def get_queryset(self):
qs = super().get_queryset()
if self.active_filter:
qs = self.active_filter.filter(qs)
return qs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["filters"] = self._filters.values()
context["table_cell_template"] = getattr(self, 'cell_template', None)
context["table_header"] = getattr(self, 'table_header', None)
return context
class MaraudeListView(ListView):
""" Vue de la liste des compte-rendus de maraude """
model = CompteRendu
template_name = "notes/liste_maraudes.html"
cell_template = "notes/table_cell_maraudes.html"
table_header = "Liste des maraudes"
queryset = Maraude.objects.get_past().order_by("-date")
filters = [
("Ce mois-ci", lambda qs: qs.filter(date__month=timezone.now().date().month)),
]
class SujetListView(ListView):
#ListView
model = Sujet
template_name = "notes/liste_sujets.html"
cell_template = "notes/table_cell_sujets.html"
table_header = "Liste des sujets"
def info_completed_filter(qs):
excluded_set = set()
for sujet in qs:
if sujet.statistiques.info_completed >= 50:
excluded_set.add(sujet.pk)
return qs.exclude(pk__in=excluded_set)
filters = [
("Rencontré(e)s cette année", lambda qs: qs.filter(premiere_rencontre__year=timezone.now().date().year)),
("Statistiques incomplètes", info_completed_filter),
]
def post(self, request, **kwargs):
from watson import search as watson
search_text = request.POST.get('q')
results = watson.filter(Sujet, search_text)
#logger.warning("SEARCH for %s : %s" % (search_text, results))
if results.count() == 1:
return redirect(results[0].get_absolute_url())
self.queryset = results
return self.get(request, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['query_text'] = self.request.POST.get('q', None)
return context
class DetailView(MaraudeurMixin, generic.DetailView):
template_name = "notes/details.html"
class CompteRenduDetailsView(DetailView):
""" Vue détaillé d'un compte-rendu de maraude """
model = CompteRendu
context_object_name = "maraude"
template_name = "notes/details_maraude.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['notes'] = sorted(Note.objects.get_queryset().filter(created_date=self.object.date), key=lambda n: n.created_time)
context['next_maraude'] = Maraude.objects.get_future(
date=self.object.date + datetime.timedelta(1)
).filter(
heure_fin__isnull=False
).first()
context['prev_maraude'] = Maraude.objects.get_past(
date=self.object.date
).filter(
heure_fin__isnull=False
).last()
return context
class SuiviSujetView(NoteFormMixin, DetailView):
#NoteFormMixin
forms = {
'note': AutoNoteForm,
}
def get_success_url(self):
return reverse('notes:details-sujet', kwargs={'pk': self.get_object().pk})
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['sujet'] = self.get_object()
return kwargs
#DetailView
model = Sujet
template_name = "notes/details_sujet.html"
context_object_name = "sujet"
# Paginator
per_page = 5
def get(self, *args, **kwargs):
self.paginator = Paginator(
self.get_object().notes.by_date(reverse=True),
self.per_page
)
self.page = self.request.GET.get('page', 1)
return super().get(*args, **kwargs)
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
try:
notes = self.paginator.page(self.page)
except PageNotAnInteger:
notes = self.paginator.page(1)
except EmptyPage:
notes = self.paginator.page(self.paginator.num_pages)
context['notes'] = notes
return context
### Sujet Management Views
class SujetAjaxDetailsView(generic.DetailView):
#DetailView
template_name = "notes/details_sujet_inner.html"
model = Sujet
http_method_names = ["get"]
def get(self, *args, **kwargs):
""" Redirect to complete details view if request is not ajax """
if not self.request.is_ajax():
return redirect("notes:details-sujet", pk=self.get_object().pk)
return super().get(*args, **kwargs)
class SujetAjaxUpdateView(generic.edit.UpdateView):
#UpdateView
template_name = "notes/details_sujet_update.html"
model = Sujet
fields = '__all__'
def get_success_url(self):
return reverse("notes:details-sujet", kwargs={'pk': self.object.pk})
from website.mixins import AjaxTemplateMixin
class SujetCreateView(AjaxTemplateMixin, generic.edit.CreateView):
#CreateView
template_name = "notes/sujet_create.html"
form_class = SujetCreateForm
def post(self, request, *args, **kwargs):
if 'next' in self.request.POST:
self.success_url = self.request.POST["next"]
return super().post(self, request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
try: context['next'] = self.request.GET['next']
except:context['next'] = None
return context
class MergeView(generic.DetailView, generic.FormView):
""" Implement actions.merge_two as a view """
template_name = "notes/sujet_merge.html"
model = Sujet
form_class = SelectSujetForm
def form_valid(self, form):
slave = self.get_object()
master = form.cleaned_data['sujet']
try:
merge_two(master, slave)
except Exception as e:
logger.error("Merge: ", e)
messages.error(self.request, "La fusion vers %s a échoué !" % master)
return redirect(slave)
messages.success(self.request, "%s vient d'être fusionné" % slave)
return redirect(master)

View File

@@ -3,3 +3,5 @@
django-bootstrap3
django-select2
django-watson
django-graphos
django-nose

View File

@@ -1,26 +1,80 @@
import os
from .base_settings import *
# Added settings
""" These are the default settings.
You may set them up to your needs.
"""
# Localisation settings
LANGUAGE_CODE = 'fr-fr'
TIME_ZONE = 'Europe/Paris'
# Default settings for created Maraudeur objects.
MARAUDEURS = {
# Default password, TODO: users shall be asked to change it on first login.
'password': "test",
# The institution to which professionnals belongs.
'organisme': {
'nom': "ALSA",
'email': "direction@alsa68.org"
},
}
# END OF SETTINGS
""" Custom settings for 'maraudes_project' application.
DO NOT MODIFY the following settings,
unless you know what you are doing.
"""
LOGIN_URL = 'index'
if DEBUG:
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
else: #TODO: configure a real backend
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
AUTHENTICATION_BACKENDS = [
'utilisateurs.backends.CustomUserAuthentication'
]
# Extra settings to default template engine.
# Context processors
TEMPLATES[0]['OPTIONS']['context_processors'] += [
"website.context_processors.website_processor",
]
# Template directories
TEMPLATES[0]['DIRS'] += [
os.path.join(BASE_DIR, "templates"), # Custom admin templates
]
# Applications
INSTALLED_APPS += [
# Design
'bootstrap3',
'django_select2',
# Search Engine
'watson',
# Graph package
'graphos',
# Tests
'django_nose',
# Project apps
'maraudes',
'sujets',
'notes',
'suivi',
'utilisateurs',
'website',
'maraudes',
'notes',
'utilisateurs',
'statistiques',
]
# django-nose
TEST_RUNNER = "django_nose.NoseTestSuiteRunner"
NOSE_ARGS = [
"--with-coverage",
"--cover-package=website,maraudes,notes,utilisateurs",
]
LOGIN_URL = 'index'
# bootstrap3
BOOTSTRAP3 = {
# The URL to the jQuery JavaScript file
'base_url': os.path.join(STATIC_URL, 'css', 'bootstrap/'),
@@ -32,13 +86,6 @@ BOOTSTRAP3 = {
'horizontal_field_class': 'col-md-10',
}
# Django-select2 Configuration
# django-select2
SELECT2_JS = 'scripts/select2.min.js'
SELECT2_CSS = 'css/select2.min.css'
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
AUTHENTICATION_BACKENDS = [
'website.backends.MyBackend'
]

8
statistiques/apps.py Normal file
View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class StatistiquesConfig(AppConfig):
name = 'statistiques'

70
statistiques/charts.py Normal file
View File

@@ -0,0 +1,70 @@
from django.db.models import (Field, CharField, NullBooleanField,
Count,
)
from graphos.sources.simple import SimpleDataSource
from graphos.renderers import gchart
class PieWrapper(gchart.PieChart):
""" A wrapper around gchart.PieChart that generates a graph from :
- a queryset and a model field (NullBooleanField or field with choices)
- a data object and title
"""
height=400
width=800
labels = {
NullBooleanField: {True: "Oui", False: "Non", None:"Ne sait pas"},
}
def __init__( self, queryset=None, field=None,
data=None, title=None,
null_values=[],**kwargs):
if not data:
if not isinstance(field, Field):
raise TypeError(field, 'must be a child of django.models.db.fields.Field !')
if not queryset:
raise TypeError("You must give either a queryset and field or data")
if field.__class__ in PieWrapper.labels:
labels = PieWrapper.labels[field.__class__]
elif field.choices:
labels = dict(field.choices)
else:
raise ValueError("Could not guess labels for", field)
data = ([(field.name, 'count')] + # Headers
[(labels[item[field.name]],
item['nbr']) for item in queryset.values(
field.name
).annotate(
nbr=Count('pk')
).order_by()
if (not null_values
or item[field] not in null_values)
])
super().__init__(
SimpleDataSource(
data=data
),
options={
'title': getattr(field, 'verbose_name', title),
'is3D': True,
'pieSliceText': 'value',
'legend': {'position': 'labeled', 'maxLines': 3, 'textStyle': {'fontSize': 16,}},
},
width=kwargs.get('width', self.width), height=kwargs.get('height', self.height),
)
def get_js_template(self):
return "statistiques/gchart/pie_chart.html"
def get_html_template(self):
return "statistiques/gchart/html.html"
class ColumnWrapper(gchart.ColumnChart):
pass

42
statistiques/forms.py Normal file
View File

@@ -0,0 +1,42 @@
from django import forms
from django.db.utils import OperationalError
from .models import FicheStatistique
class StatistiquesForm(forms.ModelForm):
class Meta:
model = FicheStatistique
exclude = ["sujet"]
def get_year_range():
qs = FicheStatistique.objects.filter(
sujet__premiere_rencontre__isnull=False
).order_by(
'sujet__premiere_rencontre'
)
year = lambda f: f.sujet.premiere_rencontre.year
# Need to call exists() in a try block
# to avoid raising exception on first migration
try:
qs_is_not_empty = qs.exists()
except OperationalError:
qs_is_not_empty = False
if qs_is_not_empty:
return range(year(qs.first()), year(qs.last()) + 1)
else:
return ()
class SelectRangeForm(forms.Form):
year = forms.ChoiceField(label="Année", choices=[(0, 'Toutes')] + [(i, str(i)) for i in get_year_range()])
month = forms.ChoiceField(label="Mois",
choices=[(0, 'Tous'),
(1, 'Janvier'), (2, 'Février'), (3, 'Mars'), (4, 'Avril'),
(5, 'Mai'), (6, 'Juin'), (7, 'Juillet'), (8, 'Août'),
(9, 'Septembre'),(10, 'Octobre'),(11, 'Novembre'),(12, 'Décembre')
],
)

88
statistiques/models.py Normal file
View File

@@ -0,0 +1,88 @@
from django.db import models
from django.shortcuts import reverse
# Create your models here.
NSP = "Ne sait pas"
# Item: Parcours institutionnel
PARCOURS_INSTITUTIONNEL = "Institutionnel"
PARCOURS_FAMILIAL = "Familial"
PARCOURS_DE_VIE_CHOICES = (
(NSP, "Ne sait pas"),
(PARCOURS_FAMILIAL, "Parcours familial"),
(PARCOURS_INSTITUTIONNEL, "Parcours institutionnel"),
)
#Item: Type d'habitation
HABITATION_SANS = "Sans Abri"
HABITATION_LOGEMENT = "Logement"
HABITATION_TIERS = "Hébergement"
HABITATION_MAL_LOGEMENT = "Mal logé"
HABITATION_CHOICES = (
(NSP, "Ne sait pas"),
(HABITATION_SANS, "Sans abri"),
(HABITATION_TIERS, "Hébergé"),
(HABITATION_LOGEMENT, "Logé"),
(HABITATION_MAL_LOGEMENT, "Mal logé"),
)
#Item: Ressources
RESSOURCES_RSA = "RSA"
RESSOURCES_AAH = "AAH"
RESSOURCES_POLE_EMPLOI = "Pôle Emploi"
RESSOURCES_AUTRES = "Autres"
RESSOURCES_SANS = "Pas de ressources"
RESSOURCES_CHOICES = (
(NSP, "Ne sait pas"),
(RESSOURCES_AAH, "AAH"),
(RESSOURCES_RSA, "RSA"),
(RESSOURCES_SANS, "Aucune"),
(RESSOURCES_POLE_EMPLOI, "Pôle emploi"),
(RESSOURCES_AUTRES, "Autres ressources"),
)
class FicheStatistique(models.Model):
sujet = models.OneToOneField('notes.Sujet',
on_delete=models.CASCADE,
primary_key=True,
related_name="statistiques")
# Logement
habitation = models.CharField("Type d'habitat", max_length=64,
choices=HABITATION_CHOICES,
default=NSP)
ressources = models.CharField("Ressources", max_length=64,
choices=RESSOURCES_CHOICES,
default=NSP)
connu_siao = models.NullBooleanField("Connu du SIAO ?")
# Problématiques
prob_psychiatrie = models.NullBooleanField("Psychiatrie")
prob_administratif = models.NullBooleanField("Administratif")
prob_addiction = models.NullBooleanField("Addiction")
prob_somatique = models.NullBooleanField("Somatique")
lien_familial = models.NullBooleanField("Lien Familial")
parcours_de_vie = models.CharField("Parcours de vie",
max_length=64,
choices=PARCOURS_DE_VIE_CHOICES,
default=NSP)
def get_absolute_url(self):
return reverse('notes:details-sujet', kwargs={'pk': self.sujet.pk})
@property
def info_completed(self):
observed = ('prob_psychiatrie', 'prob_addiction',
'prob_administratif', 'prob_somatique', 'habitation', 'ressources',
'connu_siao', 'lien_familial', 'parcours_de_vie')
completed = 0
for f in observed:
if getattr(self, f) == None or getattr(self, f) == NSP:
continue
else:
completed += 1
return int(completed / len(observed) * 100)

View File

@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% load static %}
{% block extrahead %}
<script type="text/javascript" src="{% static "scripts/jquery.flot.min.js" %}"></script>
<script type="text/javascript" src="https://www.google.com/jsapi"></script>
<script type="text/javascript">
google.load("visualization", "1", {packages:["corechart"]});
</script>
{% endblock %}
{% block title %}Statistiques >{% endblock %}
{% block breadcrumbs %}
<li><a href="{% url "statistiques:index" %}">Statistiques</a></li>
{% endblock %}
{% block sidebar %}
{% include "statistiques/menu.html" %}
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% load boolean_icons bootstrap3 %}
<table class="table">
<tr>
<th colspan="4" class="active">Problématiques</th>
</tr>
<tr>
<th>Psychiatrique</th>
<td>{{ object.prob_psychiatrie|as_icon }}</td>
<th>Addiction</th>
<td>{{ object.prob_addiction|as_icon }}</td>
</tr>
<tr>
<th>Administratif</th>
<td>{{ object.prob_administratif|as_icon }}</td>
<th>Somatique</th>
<td>{{ object.prob_somatique|as_icon }}</td>
</tr>
<tr>
<th colspan="4" class="active">Habitation</th>
</tr>
<tr>
<th>Type</th>
<td>{{ object.habitation }}</td>
<th>Connu du SIAO</th>
<td>{{ object.connu_siao|as_icon }}</td>
</tr>
<tr>
<th colspan="4" class="active">Ressources</th>
</tr>
<tr>
<td colspan="4">{{ object.ressources }}</td>
</tr>
<tr>
<th colspan="4" class="active">Parcours de vie</th>
</tr>
<tr>
<td colspan="2">{{ object.parcours_de_vie }}</td>
<th>Lien familial</th>
<td>{{ object.lien_familial|as_icon }}</td>
</tr>
</table>

View File

@@ -0,0 +1,44 @@
{% load bootstrap3 %}
<form action="{% url "statistiques:update" form.instance.pk %}" method="post" id="update-stats-form">{% csrf_token%}
<table class="table">
<tr>
<th colspan="4" class="active">Problématiques</th>
</tr>
<tr>
<th>Psychiatrique</th>
<td>{% bootstrap_field form.prob_psychiatrie show_label=False size="small" %}</td>
<th>Addiction</th>
<td>{% bootstrap_field form.prob_addiction show_label=False size="small" %}</td>
</tr>
<tr>
<th>Administratif</th>
<td>{% bootstrap_field form.prob_administratif show_label=False size="small" %}</td>
<th>Somatique</th>
<td>{% bootstrap_field form.prob_somatique show_label=False size="small" %}</td>
</tr>
<tr>
<th colspan="4" class="active">Habitation</th>
</tr>
<tr>
<th>Type</th>
<td>{% bootstrap_field form.habitation show_label=False size="small" %}</td>
<th>Connu du SIAO</th>
<td>{% bootstrap_field form.connu_siao show_label=False size="small" %}</td>
</tr>
<tr>
<th colspan="4" class="active">Ressources</th>
</tr>
<tr>
<td colspan="4">{% bootstrap_field form.ressources show_label=False size="small" %}</td>
</tr>
<tr>
<th colspan="4" class="active">Parcours de vie</th>
</tr>
<tr>
<td colspan="2">{% bootstrap_field form.parcours_de_vie show_label=False size="small" %}</td>
<th>Lien familial</th>
<td>{% bootstrap_field form.lien_familial show_label=False size="small" %}</td>
</tr>
</table>
<input type="submit" id="submit-form" class="hidden"></input>
</form>

View File

@@ -0,0 +1,7 @@
{% load bootstrap3 %}
<h4>Période</h4>
<form action="" method="get">
{% bootstrap_form form layout="inline" %}
{% bootstrap_button "Ok" button_type="submit" %}
</form>

View File

@@ -0,0 +1,5 @@
<div class="row text-center" id="wrapper-{{ chart.get_html_id}}">
<div id="{{ chart.get_html_id }}" style="width: {{ chart.width }}px; height: {{ chart.height }}px;"></div>
<a class="btn btn-sm btn-default" href="#" id="image-{{ chart.get_html_id }}"><span class="glyphicon glyphicon-save-file"></span> Télécharger l'image</a>
</div>

View File

@@ -0,0 +1,13 @@
{% extends "graphos/gchart/base.html" %}
{% block create_chart %}
var chart_data = data
var chart_div = document.getElementById('{{ chart.get_html_id }}');
var chart = new google.visualization.PieChart(chart_div);
// Wait for the chart to finish drawing before calling the getImageURI() method.
google.visualization.events.addListener(chart, 'ready', function () {
$("#image-{{ chart.get_html_id }}").attr("href", chart.getImageURI());
$("#wrapper-{{ chart.get_html_id}}").hide();
});
{% endblock %}

View File

@@ -0,0 +1,60 @@
{% extends "statistiques/base.html" %}
{% load static %}
{% block title %}{{ block.super }} Maraudes{% endblock %}
{% block sidebar %}
{{ block.super }}
<div class="panel panel-primary">
<div class="panel-body text-right">
{% include "statistiques/filter_form.html" %}
</div>
</div>
{% endblock %}
{% block breadcrumbs %}
{{ block.super }}
<li>Maraudes</li>
{% endblock %}
{% block page_content %}
<div class="alert alert-info alert-dismissible">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<p>Voici les données permettant une analyse statistiques des maraudes.</p>
<p>Vous pouvez sélectionner une période particulière ou l'ensemble des données</p>
<p>Les données sont réparties en trois catégories, accessibles par le menu sur la gauche</p>
</div>
<div class="col-lg-4">
<h3 class="page-header">Données générales</h3>
<ul class="list-group">
<li class="list-group-item list-group-item-danger">
<span class="badge">{{ nbr_maraudes }}</span>
Nombre de maraudes
</li>
<li class="list-group-item">
<span class="badge">{{ nbr_maraudes_jour }}</span>
dont, Maraudes de journée
</li>
<li class="list-group-item list-group-item-danger">
<span class="badge">{{ nbr_rencontres }}</span>
Nombre total de rencontres
</li>
<li class="list-group-item">
<span class="badge">{{ moy_rencontres }}</span>
soit, en <strong>moyenne</strong> par maraude
</li>
<li class="list-group-item list-group-item-danger">
<span class="badge">{{ nbr_sujets_rencontres }}</span>
Nombre de sujets rencontrés
</li>
</ul>
</div>
{% if rencontres_par_mois %}
<div class="col-lg-8">
<h3 class="page-header">Rencontres par mois</h3>
{{ rencontres_par_mois.as_html }}
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% load navbar %}
<ul class="nav nav-pills nav-stacked text-right">
<li role="presentation" {% active namespace="statistiques" viewname="index" %}>
<a href="{% url "statistiques:index" %}?year={{year|default:0}}{% if month %}&month={{month}}{% endif %}">Maraudes&nbsp;
<span class="glyphicon glyphicon-road"></span>
</a>
</li>
<li role="presentation" {% active namespace="statistiques" viewname="pies" %}>
<a href="{% url "statistiques:pies" %}?year={{year|default:0}}{% if month %}&month={{month}}{% endif %}">Typologie du public&nbsp;
<span class="glyphicon glyphicon-adjust"></span>
</a>
</li>
</ul>
<hr />

View File

@@ -0,0 +1,46 @@
{% extends "statistiques/base.html" %}
{% block title %}{{block.super}} Typologie{% endblock %}
{% block breadcrumbs %}{{block.super}}<li>Typologie</li>{% endblock %}
{% block sidebar %}
{{ block.super }}
<hr />
<div class="panel panel-primary">
<div class="panel-body text-right">
{% include "statistiques/filter_form.html" %}
<hr />
<p>Échantillon : {{ queryset.count }} sujets</p>
</div>
</div>
{% endblock %}
{% block page_content %}
<script type="text/javascript">
function hideAll() {
{% for _, graph in graphs %}{% with graph.get_html_id as id %}
$("#tab-{{id}}").attr("class", "");
$("#wrapper-{{id}}").hide();
{% endwith %}{% endfor %}
}
function showGraph(id) {
hideAll();
$("#tab-" + id).attr("class", "active");
$("#wrapper-" + id).show();
}
/*$( function() {
hideAll();
});*/
</script>
<ul class="nav nav-tabs">
{% for title, graph in graphs %}<li role="presentation" id="tab-{{graph.get_html_id}}"><a href="#" onclick="showGraph('{{graph.get_html_id}}');">{{ title }}</a></li>{% endfor %}
</ul>
{% for title, graph in graphs %}
{{ graph.as_html }}
{% endfor %}
{% endblock %}

7
statistiques/tests.py Normal file
View File

@@ -0,0 +1,7 @@
from django.test import TestCase
# Create your tests here.
# MANDATORY FEATURES
# FicheStatistique primary key IS it's foreign sujet pk

11
statistiques/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.conf.urls import url
from . import views
urlpatterns = [
url('^$', views.DashboardView.as_view(), name="index"),
url('^charts/$', views.PieChartView.as_view(), name="pies"),
url(r'^details/(?P<pk>[0-9]+)/$', views.StatistiquesDetailsView.as_view(), name="details"),
url(r'^update/(?P<pk>[0-9]+)/$', views.StatistiquesUpdateView.as_view(), name="update"),
]

204
statistiques/views.py Normal file
View File

@@ -0,0 +1,204 @@
import datetime
from django.shortcuts import render, redirect
from django.contrib import messages
from django.views import generic
from django.db.models import (Field, CharField, NullBooleanField,
Count,
)
from django.db.models.functions.datetime import ExtractMonth
from graphos.sources.simple import SimpleDataSource
from graphos.renderers import gchart
from .models import FicheStatistique
from .forms import StatistiquesForm, SelectRangeForm
from .charts import PieWrapper, ColumnWrapper
from maraudes.notes import Observation
from maraudes.models import Maraude
from notes.models import Sujet
###
nom_mois = {
1: "Janvier",
2: "Février",
3: "Mars",
4: "Avril",
5: "Mai",
6: "Juin",
7: "Juillet",
8: "Août",
9: "Septembre",
10: "Octobre",
11: "Novembre",
12: "Décembre"
}
class FilterMixin(generic.edit.FormMixin):
form_class = SelectRangeForm
def get_initial(self):
return {'month': self.request.GET.get('month', 0), 'year': self.request.GET.get('year', 0) }
def get(self, *args, **kwargs):
self.year = int(self.request.GET.get('year', 0))
self.month = int(self.request.GET.get('month', 0))
return super().get(self, *args, **kwargs)
def _filters(self, prefix):
return {'%s__%s' % (prefix, attr): getattr(self, attr) for attr in ('year', 'month')
if getattr(self, attr) > 0 }
def get_observations_queryset(self):
return Observation.objects.filter(**self._filters('created_date'))
def get_maraudes_queryset(self):
return Maraude.objects.filter(**self._filters('date'))
def get_fichestatistiques_queryset(self):
return FicheStatistique.objects.filter(pk__in=self.get_observations_queryset().values_list('sujet'))
def get_sujets_queryset(self):
return Sujet.objects.filter(pk__in=self.get_observations_queryset().values_list('sujet'))
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['year'] = self.year
context['month'] = self.month
return context
NO_DATA = "Aucune donnée"
class DashboardView(FilterMixin, generic.TemplateView):
template_name = "statistiques/index.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
maraudes = self.get_maraudes_queryset()
rencontres = self.get_observations_queryset()
context['nbr_maraudes'] = maraudes.count() or NO_DATA
context['nbr_maraudes_jour'] = maraudes.filter(
heure_debut=datetime.time(16,00)
).count() or NO_DATA
context['nbr_rencontres'] = rencontres.count() or NO_DATA
try:
context['moy_rencontres'] = int(context['nbr_rencontres'] / context['nbr_maraudes'])
except (ZeroDivisionError, TypeError):
context['moy_rencontres'] = NO_DATA
if self.year and not self.month: #Show rencontres_par_mois graph
par_mois = rencontres.order_by().annotate(
mois=ExtractMonth('created_date')
).values(
'mois'
).annotate(
nbr=Count('pk')
)
context['rencontres_par_mois'] = ColumnWrapper(
SimpleDataSource(
[("Mois", "Rencontres")] +
[(nom_mois[item['mois']], item['nbr']) for item in par_mois]
),
options = {
"title": "Nombre de rencontres par mois"
}
)
# Graph: Fréquence de rencontres par sujet
nbr_rencontres = rencontres.values('sujet').annotate(nbr=Count('pk')).order_by()
context['nbr_sujets_rencontres'] = nbr_rencontres.count()
categories = (
('Rencontre unique', (1,)),
('Entre 2 et 5 rencontres', range(2,6)),
('Entre 6 et 20 rencontres', range(6,20)),
('Plus de 20 rencontres', range(20,999)),
)
get_count_for_range = lambda rg: nbr_rencontres.filter(nbr__in=rg).count()
context['graph_rencontres'] = PieWrapper(
data= [('Type de rencontre', 'Nombre de sujets')] +
[(label, get_count_for_range(rg)) for label, rg in categories],
title= 'Fréquence de rencontres'
)
return context
class PieChartView(FilterMixin, generic.TemplateView):
template_name = "statistiques/typologie.html"
def get_graphs(self):
sujets = self.get_sujets_queryset()
# Insertion des champs 'âge' et 'genre' du modèle notes.Sujet
for field in Sujet._meta.fields:
if field.name == 'genre':
yield str(field.verbose_name), PieWrapper(sujets, field)
if field.name == 'age':
categories = (
('Mineurs', range(0,18)),
('18-24', range(18,25)),
('25-34', range(25,35)),
('35-44', range(35,45)),
('45-54', range(45,55)),
('+ de 55', range(55,110)),
)
nbr_sujets = lambda rg: sujets.filter(age__in=rg).count()
yield "Âge", PieWrapper(
data=[("age", "count")] +
[(label, nbr_sujets(rg))
for label, rg in categories] +
[("Ne sait pas", sujets.filter(age=None).count())],
title="Âge des sujets")
# Puis des champs du modèle statistiques.FicheStatistique
# dans leur ordre de déclaration
queryset = self.get_fichestatistiques_queryset()
for field in FicheStatistique._meta.fields:
if field.__class__ in (NullBooleanField, CharField):
yield str(field.verbose_name), PieWrapper(queryset, field)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['graphs'] = [(title, graph) for title, graph in self.get_graphs()]
context['queryset'] = self.get_fichestatistiques_queryset()
return context
# AjaxMixin
class AjaxOrRedirectMixin:
""" For view that should be retrieved by Ajax only. If not,
redirects to the primary view where these are displayed """
def get(self, *args, **kwargs):
""" Redirect to complete details view if request is not ajax """
if not self.request.is_ajax():
return redirect("notes:details-sujet", pk=self.get_object().pk)
return super().get(*args, **kwargs)
class StatistiquesDetailsView(AjaxOrRedirectMixin, generic.DetailView):
model = FicheStatistique
template_name = "statistiques/fiche_stats_details.html"
class StatistiquesUpdateView(AjaxOrRedirectMixin, generic.UpdateView):
model = FicheStatistique
form_class = StatistiquesForm
template_name = "statistiques/fiche_stats_update.html"

View File

@@ -1,16 +0,0 @@
from django.apps import AppConfig
class SuiviConfig(AppConfig):
name = 'suivi'
from utilisateurs.models import Maraudeur
from website.decorators import Webpage
suivi = Webpage("suivi", icon="eye-open", defaults={
'restricted': [Maraudeur],
'ajax': False,
}
)
suivi.app_menu.add_link(('Liste des sujets', 'suivi:liste', 'list'))

View File

@@ -1,40 +0,0 @@
from .notes import *
from notes.forms import *
from sujets.models import Sujet, GENRE_CHOICES
from django import forms
class AppelForm(UserNoteForm):
class Meta(UserNoteForm.Meta):
model = Appel
fields = ['sujet', 'text', 'entrant', 'created_date', 'created_time']
class SignalementForm(UserNoteForm):
nom = forms.CharField(64, required=False)
prenom = forms.CharField(64, required=False)
age = forms.IntegerField(required=False)
genre = forms.ChoiceField(choices=GENRE_CHOICES)
class Meta(UserNoteForm.Meta):
model = Signalement
fields = ['text', 'source', 'created_date', 'created_time']
def clean(self):
super().clean()
if not self.cleaned_data['nom'] and not self.cleaned_data['prenom']:
self.add_error('nom', '')
self.add_error('prenom', '')
raise forms.ValidationError("Entrez au moins un nom ou prénom")
def save(self, commit=True):
sujet = Sujet.objects.create(
nom=self.cleaned_data['nom'],
prenom=self.cleaned_data['prenom'],
genre=self.cleaned_data['genre'],
age=self.cleaned_data['age']
)
instance = super().save(commit=False)
instance.sujet = sujet
if commit:
instance.save()
return instance

View File

@@ -1,3 +0,0 @@
from django.db import models
# Create your models here.

View File

@@ -1,20 +0,0 @@
from django.db import models
from notes.models import Note
class Appel(Note):
entrant = models.BooleanField( "Appel entrant ?")
def note_labels(self): return ["Reçu" if self.entrant else "Émis", self.created_by]
def note_bg_colors(self): return ("warning", "info")
class Signalement(Note):
source = models.ForeignKey("utilisateurs.Organisme")
def note_labels(self): return [self.source, self.created_by]
def note_bg_colors(self): return ('warning', 'info')

View File

@@ -1,26 +0,0 @@
{% load bootstrap3 %}
<div class="col-lg-6 col-md-12">
<div class="panel-group" id="accordion" role="tablist" aria-multiselectable="true">
{% include "suivi/sujet_suivi.html" %}
<div class="panel panel-default">
<div class="panel-heading" role="tab" id="notesAjoutHeading">
<h4 class="panel-title">
<a class="collapsed" role="button" data-toggle="collapse" data-parent="#accordion" href="#collapseNotesAjout" aria-expanded="false" aria-controls="collapseTwo">
{% bootstrap_icon "plus" %} Ajouter une note
</a>
</h4>
</div>
<div id="collapseNotesAjout" class="panel-collapse collapse" role="tabpanel" aria-labelledby="notesAjoutHeading">
<div class="panel-body">
<form method="POST" action="">{% csrf_token %}
{% bootstrap_form note_form show_label=False %}
</div>
<div class="panel-footer text-right">
{% bootstrap_button "Enregistrer" button_type="submit" %}
</form>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-6 col-md-12"> {% include "sujets/sujet_details.html" %}</div>

View File

@@ -1,14 +0,0 @@
<div class="col-md-12 col-lg-6">
<h4>Ces derniers temps</h4>
<p> Nous avons rencontré {{ derniers_sujets }}. </p>
<h4>Vigilance</h4>
</div>
<div class="col-md-12 col-lg-6">
<h4><strong>Créer une note :</strong></h4>
<div class="panel-group" id="accordion" role="tablist" aria-multiselectable="true">
{% include "suivi/appel_form.html" with form=appel_form %}
{% include "suivi/signalement_form.html" with form=signalement_form %}
</div>
</div>

View File

@@ -1,16 +0,0 @@
{% load bootstrap3 %}
<li class="app-menu">
<a href="{% url 'sujets:liste' %}">Liste des sujets
<span class="pull-right">{% bootstrap_icon "list" %}</span></a>
</li>
{% if user.is_superuser %}
<li class="dropdown app-menu">
<a class="dropdown-toggle" data-toggle="dropdown" href="#">
Administration <b class="caret"></b>
</a>
<ul class="dropdown-menu">
<li><a href="{% url 'sujets:create' %}">{% bootstrap_icon "plus" %} Nouveau sujet</a></li>
<li><a href="{% url 'admin:app_list' app_label='sujets' %}">
{% bootstrap_icon "wrench" %} Gérer les sujets</a></li>
{% endif %}
</ul>

View File

@@ -1,16 +0,0 @@
{% load notes %}
<div class="panel panel-primary">
<div class="panel-heading" role="tab" id="notesSujetHeading">
<h3 class="panel-title">
<a role="button" data-toggle="collapse" data-parent="#accordion" href="#collapseNotesSujet" aria-expanded="true" aria-controls="collapseOne">
Notes</a><span class="pull-right label">Total : {{ notes.count }}</span></h3>
</div>
<div id="collapseNotesSujet" class="panel-collapse collapse in" role="tabpanel" aria-labelledby="notesSujetHeading">
<table class="table table-striped table-bordered">
{% for note in notes %}
{% inline_table note header="date" %}
{% endfor %}
</table>
</div>
</div>

View File

@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -1,9 +0,0 @@
from django.conf.urls import url
from . import views
urlpatterns = [
url(r'^$', views.IndexView.as_view(), name="index"),
url(r'liste/$', views.SujetListView.as_view(), name="liste"),
url(r'(?P<pk>[0-9]+)/$', views.SuiviSujetView.as_view(), name="details"),
]

View File

@@ -1,90 +0,0 @@
from django.shortcuts import reverse, redirect
from django.views import generic
from django.utils import timezone
from sujets.models import Sujet
from .forms import *
from notes.mixins import NoteFormMixin
from notes.forms import AutoNoteForm
# Create your views here.
from maraudes.compte_rendu import CompteRendu
def derniers_sujets_rencontres():
""" Renvoie le 'set' des sujets rencontrés dans les deux dernières maraudes """
sujets = set()
# Issue: Récupère des comptes-rendus, même s'il n'ont pas été rédigé. Ne devrait pas
# être un souci si on reste à jour, mais sinon...
for cr in list(CompteRendu.objects.all())[-2:]:
for obs in cr.get_observations():
sujets.add(obs.sujet)
return sujets
from .apps import suivi
@suivi.using(title=("Suivi", "Tableau de bord"))
class IndexView(NoteFormMixin, generic.TemplateView):
#NoteFormMixin
forms = {
'appel': AppelForm,
'signalement': SignalementForm,
}
def get_initial(self):
return {'created_date': timezone.localtime(timezone.now()).date(),
'created_time': timezone.localtime(timezone.now()).time()}
def get_success_url(self):
return reverse('suivi:index')
#TemplateView
template_name = "suivi/index.html"
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['derniers_sujets'] = ", ".join(map(str, derniers_sujets_rencontres()))
return context
@suivi.using(title=('Liste des sujets',))
class SujetListView(generic.ListView):
#ListView
model = Sujet
template_name = "sujets/sujet_liste.html"
paginate_by = 30
def post(self, request, **kwargs):
from watson import search as watson
search_text = request.POST.get('q')
results = watson.filter(Sujet, search_text)
if results.count() == 1:
return redirect(results[0].get_absolute_url())
self.queryset = results
return self.get(request, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['query_text'] = self.request.POST.get('q', None)
return context
@suivi.using(title=('{{sujet}}', 'suivi'))
class SuiviSujetView(NoteFormMixin, generic.DetailView):
#NoteFormMixin
forms = {
'note': AutoNoteForm,
}
def get_success_url(self):
return reverse('suivi:details', kwargs={'pk': self.get_object().pk})
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['sujet'] = self.get_object()
return kwargs
#DetailView
model = Sujet
template_name = "suivi/details.html"
context_object_name = "sujet"
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['notes'] = self.object.notes.by_date(reverse=True)
return context

View File

@@ -1 +0,0 @@
default_app_config = 'sujets.apps.SujetsConfig'

View File

@@ -1,14 +0,0 @@
from django.contrib import admin
from .models import Sujet
@admin.register(Sujet)
class SujetAdmin(admin.ModelAdmin):
fieldsets = [
('Identité', {'fields': [('nom', 'prenom'), 'genre']}),
('Informations', {'fields': ['age', ]}),
]

View File

@@ -1,9 +0,0 @@
from django.apps import AppConfig
from watson import search as watson
class SujetsConfig(AppConfig):
name = 'sujets'
def ready(self):
Sujet = self.get_model("Sujet")
watson.register(Sujet, fields=('nom', 'prenom', 'surnom'))

View File

@@ -1,18 +0,0 @@
from django.forms import ModelForm
from django.forms.extras.widgets import SelectDateWidget
from .models import Sujet
import datetime
current_year = datetime.date.today().year
YEAR_CHOICE = tuple(year - 2 for year in range(current_year, current_year + 10))
class SujetCreateForm(ModelForm):
class Meta:
model = Sujet
fields = ['nom', 'surnom', 'prenom', 'genre', 'premiere_rencontre']
widgets = {
'premiere_rencontre': SelectDateWidget( empty_label=("Année", "Mois", "Jour"),
years = YEAR_CHOICE,
),
}

View File

@@ -1,135 +0,0 @@
from django.utils import timezone
from django.core.urlresolvers import reverse
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext_lazy as _
from django.db import models
### Item choices
# Item: Parcours institutionnel
PARCOURS_INSTITUTIONNEL = "Institutionnel"
PARCOURS_FAMILIAL = "Familial"
PARCOURS_NR = "Non renseigné"
PARCOURS_DE_VIE_CHOICES = (
(PARCOURS_FAMILIAL, "Parcours familial"),
(PARCOURS_INSTITUTIONNEL, "Parcours institutionnel"),
(PARCOURS_NR, "Ne sait pas"),
)
#Item: Type d'habitation
HABITATION_SANS = "Sans Abri"
HABITATION_LOGEMENT = "Logement"
HABITATION_TIERS = "Hébergement"
HABITATION_MAL_LOGEMENT = "Mal logé"
HABITATION_NR = "Non renseigné"
HABITATION_CHOICES = (
(HABITATION_SANS, "Sans abri"),
(HABITATION_TIERS, "Hébergé"),
(HABITATION_LOGEMENT, "Logé"),
(HABITATION_MAL_LOGEMENT, "Mal logé"),
(HABITATION_NR, "Ne sait pas"),
)
#Item: Ressources
RESSOURCES_RSA = "RSA"
RESSOURCES_AAH = "AAH"
RESSOURCES_POLE_EMPLOI = "Pôle Emploi"
RESSOURCES_AUTRES = "Autres"
RESSOURCES_SANS = "Pas de ressources"
RESSOURCES_NR = "Non renseigné"
RESSOURCES_CHOICES = (
(RESSOURCES_AAH, "AAH"),
(RESSOURCES_RSA, "RSA"),
(RESSOURCES_SANS, "Aucune"),
(RESSOURCES_POLE_EMPLOI, "Pôle emploi"),
(RESSOURCES_AUTRES, "Autres ressources"),
(RESSOURCES_NR, "Ne sait pas")
)
### Models
# - Personne
# - Sujet
HOMME = 'M'
FEMME = 'Mme'
GENRE_CHOICES = (
(HOMME, 'Homme'),
(FEMME, 'Femme'),
)
class Personne(models.Model):
""" Modèle de base d'une personne
- genre
- nom
- prénom
"""
genre = models.CharField(max_length=3,
choices=GENRE_CHOICES,
default=HOMME)
nom = models.CharField(max_length=32, blank=True)
prenom = models.CharField(max_length=32, blank=True)
surnom = models.CharField(max_length=64, blank=True)
def __str__(self):
string = '%s ' % self.genre
if self.nom: string += '%s ' % self.nom
if self.surnom: string += '"%s" ' % self.surnom
if self.prenom: string += '%s' % self.prenom
return string
def clean(self):
if not any([self.nom, self.prenom, self.surnom]):
raise ValidationError(_("Vous devez remplir au moins un nom, prénom ou surnom"))
return super().clean()
#TODO:
# Il serait préférable de séparer le Sujet (nom, prénom, age)
# des données utilisées pour les statistiques
# Solution : nouveau modèle "Informations" avec OneToOneRelation vers Sujet
# Cette classe pourra être déplacée dans le module 'statistiques'
class Sujet(Personne):
""" Personne faisant l'objet d'un suivi par la maraude
"""
# referent = models.ForeignKey("utilisateurs.Professionnel", related_name="suivis")
premiere_rencontre = models.DateField(
blank=True, null=True,
default=timezone.now
)
age = models.SmallIntegerField(
blank=True, null=True
)
lien_familial = models.NullBooleanField("Lien Familial")
parcours_de_vie = models.CharField(max_length=64,
choices=PARCOURS_DE_VIE_CHOICES,
default=PARCOURS_NR)
# Problématiques
prob_psychiatrie = models.NullBooleanField("Psychiatrie")
prob_administratif = models.NullBooleanField("Administratif")
prob_addiction = models.NullBooleanField("Addiction")
prob_somatique = models.NullBooleanField("Somatique")
# Logement
habitation = models.CharField("Type d'habitat", max_length=64,
choices=HABITATION_CHOICES,
default=HABITATION_NR)
ressources = models.CharField("Ressources", max_length=64,
choices=RESSOURCES_CHOICES,
default=RESSOURCES_NR)
connu_siao = models.NullBooleanField("Connu du SIAO ?")
class Meta:
verbose_name = "Sujet"
ordering = ('surnom', 'nom', 'prenom')
permissions = (
('view_sujets', "Accès à l'application 'sujets'"),
)
def get_absolute_url(self):
return reverse('suivi:details', kwargs={'pk': self.id})

View File

@@ -1,6 +0,0 @@
{% extends "base_site.html" %}
{% block title %}{% endblock %}
{% block page_header %}{% endblock %}

View File

@@ -1,6 +0,0 @@
<a href="{% url 'suivi:details' object.pk %}" class="btn btn-link">{{object}}</a>
<div class="pull-right" style="padding-right: 20px;">
<span class="label label-info">{{ object.notes.count }} notes</span>
</div>

Some files were not shown because too many files have changed in this diff Show More