From be087464fce9b0f1a91e1cd25e84952a8112a949 Mon Sep 17 00:00:00 2001 From: artus40 Date: Sun, 11 Jun 2017 17:16:17 +0200 Subject: [PATCH] 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 --- maraudes/admin.py | 3 +- maraudes/apps.py | 18 -- maraudes/compte_rendu.py | 82 ----- maraudes/forms.py | 79 ++++- maraudes/management/commands/load_csv.py | 216 +++++++++++++ maraudes/managers.py | 36 +-- maraudes/models.py | 132 +++++--- maraudes/notes.py | 13 + .../templates/compte_rendu/compterendu.html | 15 - .../compte_rendu/compterendu_create.html | 104 ------ .../compte_rendu/compterendu_update.html | 22 -- maraudes/templates/maraudes/base.html | 14 + maraudes/templates/maraudes/compterendu.html | 169 ++++++++++ .../compterendu_form.html | 5 +- maraudes/templates/maraudes/details.html | 7 - maraudes/templates/maraudes/finalize.html | 46 +++ maraudes/templates/maraudes/index.html | 44 ++- maraudes/templates/maraudes/lieu_create.html | 9 +- .../templates/maraudes/lieu_create_inner.html | 8 - .../templates/maraudes/list_table_cell.html | 9 - maraudes/templates/maraudes/liste.html | 32 -- maraudes/templates/maraudes/menu.html | 25 ++ .../maraudes/missing_cr_table_cell.html | 3 - maraudes/templates/maraudes/planning.html | 61 ++++ .../maraudes/table_cell_derniers_sujets.html | 1 + .../maraudes/table_cell_missing_cr.html | 1 + maraudes/templates/planning/planning.html | 30 -- maraudes/tests.py | 130 +++++--- maraudes/urls.py | 5 +- maraudes/views.py | 306 +++++++++--------- notes/__init__.py | 1 + notes/actions.py | 38 +++ notes/admin.py | 11 +- notes/apps.py | 6 +- notes/forms.py | 32 +- notes/models.py | 74 ++++- notes/templates/notes/base.html | 14 + notes/templates/notes/details.html | 33 ++ notes/templates/notes/details_maraude.html | 47 +++ notes/templates/notes/details_sujet.html | 103 ++++++ .../templates/notes/details_sujet_inner.html | 22 ++ .../templates/notes/details_sujet_update.html | 24 ++ .../templates/notes/form_appel.html | 6 +- .../templates/notes/form_appel_inner.html | 3 +- .../templates/notes/form_signalement.html | 6 +- .../notes/form_signalement_inner.html | 0 notes/templates/notes/index.html | 16 + notes/templates/notes/liste.html | 34 ++ notes/templates/notes/liste_maraudes.html | 14 + notes/templates/notes/liste_sujets.html | 39 +++ notes/templates/notes/menu.html | 13 + notes/templates/notes/note.html | 0 notes/templates/notes/sujet_create.html | 9 + .../templates/notes}/sujet_create_inner.html | 2 +- notes/templates/notes/sujet_merge.html | 12 + notes/templates/notes/sujet_merge_inner.html | 8 + .../templates/notes/table_cell_maraudes.html | 6 + notes/templates/notes/table_cell_sujets.html | 9 + notes/templatetags/notes.py | 9 +- notes/tests.py | 23 +- notes/urls.py | 16 + notes/views.py | 254 +++++++++++++++ requirements.txt | 2 + settings.py | 81 ++++- {suivi => statistiques}/__init__.py | 0 {suivi => statistiques}/admin.py | 0 statistiques/apps.py | 8 + statistiques/charts.py | 70 ++++ statistiques/forms.py | 42 +++ statistiques/models.py | 88 +++++ statistiques/templates/statistiques/base.html | 20 ++ .../statistiques/fiche_stats_details.html | 42 +++ .../statistiques/fiche_stats_update.html | 44 +++ .../templates/statistiques/filter_form.html | 7 + .../templates/statistiques/gchart/html.html | 5 + .../statistiques/gchart/pie_chart.html | 13 + .../templates/statistiques/index.html | 60 ++++ statistiques/templates/statistiques/menu.html | 14 + .../templates/statistiques/typologie.html | 46 +++ statistiques/tests.py | 7 + statistiques/urls.py | 11 + statistiques/views.py | 204 ++++++++++++ suivi/apps.py | 16 - suivi/forms.py | 40 --- suivi/models.py | 3 - suivi/notes.py | 20 -- suivi/templates/suivi/details.html | 26 -- suivi/templates/suivi/index.html | 14 - suivi/templates/suivi/menu_sujets.html | 16 - suivi/templates/suivi/sujet_suivi.html | 16 - suivi/tests.py | 3 - suivi/urls.py | 9 - suivi/views.py | 90 ------ sujets/__init__.py | 1 - sujets/admin.py | 14 - sujets/apps.py | 9 - sujets/forms.py | 18 -- sujets/models.py | 135 -------- sujets/templates/sujets/base.html | 6 - sujets/templates/sujets/list_table_cell.html | 6 - sujets/templates/sujets/sujet_create.html | 5 - sujets/templates/sujets/sujet_details.html | 70 ---- .../templates/sujets/sujet_details_inner.html | 63 ---- sujets/templates/sujets/sujet_liste.html | 59 ---- sujets/templates/sujets/sujet_update.html | 7 - .../templates/sujets/sujet_update_inner.html | 27 -- sujets/tests.py | 3 - sujets/urls.py | 9 - sujets/views.py | 45 --- templates/admin/app_index.html | 13 + templates/admin/auth/user/add_form.html | 10 + .../admin/auth/user/change_password.html | 57 ++++ templates/admin/base.html | 35 ++ templates/admin/base_site.html | 5 + templates/admin/change_form.html | 83 +++++ templates/admin/change_list.html | 87 +++++ templates/admin/change_list_results.html | 38 +++ templates/admin/index.html | 82 +++++ templates/admin/submit_line.html | 11 + utilisateurs/__init__.py | 0 utilisateurs/admin.py | 40 ++- utilisateurs/apps.py | 9 - utilisateurs/backends.py | 34 ++ utilisateurs/managers.py | 20 ++ utilisateurs/mixins.py | 7 + utilisateurs/models.py | 110 +++---- .../templates/utilisateurs/details.html | 6 + utilisateurs/tests.py | 51 ++- utilisateurs/views.py | 5 +- website/__init__.py | 0 website/admin.py | 3 - website/backends.py | 24 -- website/context_processors.py | 5 + website/decorators.py | 93 ------ website/mixins.py | 93 +----- website/models.py | 3 - website/navbar.py | 102 ------ website/static/css/base.css | 135 ++++++-- website/static/css/bootstrap/config.json | 2 +- website/static/scripts/bootstrap-modal.js | 3 +- website/static/scripts/jquery.flot.min.js | 8 + website/static/scripts/update_time.js | 17 +- website/templates/base.html | 75 ++++- website/templates/base_site.html | 10 - website/templates/{main.html => index.html} | 11 +- website/templates/login.html | 91 +++--- website/templates/logout.html | 1 - website/templates/navbar.html | 60 ---- website/templates/tables/table.html | 17 +- ...able_cell.html => table_cell_default.html} | 0 website/templatetags/navbar.py | 12 + website/templatetags/tables.py | 13 +- website/tests.py | 43 ++- website/urls.py | 11 +- website/views.py | 18 +- 155 files changed, 3568 insertions(+), 1988 deletions(-) delete mode 100644 maraudes/compte_rendu.py create mode 100644 maraudes/management/commands/load_csv.py delete mode 100644 maraudes/templates/compte_rendu/compterendu.html delete mode 100644 maraudes/templates/compte_rendu/compterendu_create.html delete mode 100644 maraudes/templates/compte_rendu/compterendu_update.html create mode 100644 maraudes/templates/maraudes/base.html create mode 100644 maraudes/templates/maraudes/compterendu.html rename maraudes/templates/{compte_rendu => maraudes}/compterendu_form.html (98%) delete mode 100644 maraudes/templates/maraudes/details.html create mode 100644 maraudes/templates/maraudes/finalize.html delete mode 100644 maraudes/templates/maraudes/list_table_cell.html delete mode 100644 maraudes/templates/maraudes/liste.html create mode 100644 maraudes/templates/maraudes/menu.html delete mode 100644 maraudes/templates/maraudes/missing_cr_table_cell.html create mode 100644 maraudes/templates/maraudes/planning.html create mode 100644 maraudes/templates/maraudes/table_cell_derniers_sujets.html create mode 100644 maraudes/templates/maraudes/table_cell_missing_cr.html delete mode 100644 maraudes/templates/planning/planning.html create mode 100644 notes/actions.py create mode 100644 notes/templates/notes/base.html create mode 100644 notes/templates/notes/details.html create mode 100644 notes/templates/notes/details_maraude.html create mode 100644 notes/templates/notes/details_sujet.html create mode 100644 notes/templates/notes/details_sujet_inner.html create mode 100644 notes/templates/notes/details_sujet_update.html rename suivi/templates/suivi/appel_form.html => notes/templates/notes/form_appel.html (71%) rename suivi/templates/suivi/appel_form_inner.html => notes/templates/notes/form_appel_inner.html (90%) rename suivi/templates/suivi/signalement_form.html => notes/templates/notes/form_signalement.html (71%) rename suivi/templates/suivi/signalement_form_inner.html => notes/templates/notes/form_signalement_inner.html (100%) create mode 100644 notes/templates/notes/index.html create mode 100644 notes/templates/notes/liste.html create mode 100644 notes/templates/notes/liste_maraudes.html create mode 100644 notes/templates/notes/liste_sujets.html create mode 100644 notes/templates/notes/menu.html delete mode 100644 notes/templates/notes/note.html create mode 100644 notes/templates/notes/sujet_create.html rename {sujets/templates/sujets => notes/templates/notes}/sujet_create_inner.html (74%) create mode 100644 notes/templates/notes/sujet_merge.html create mode 100644 notes/templates/notes/sujet_merge_inner.html create mode 100644 notes/templates/notes/table_cell_maraudes.html create mode 100644 notes/templates/notes/table_cell_sujets.html create mode 100644 notes/urls.py create mode 100644 notes/views.py rename {suivi => statistiques}/__init__.py (100%) rename {suivi => statistiques}/admin.py (100%) create mode 100644 statistiques/apps.py create mode 100644 statistiques/charts.py create mode 100644 statistiques/forms.py create mode 100644 statistiques/models.py create mode 100644 statistiques/templates/statistiques/base.html create mode 100644 statistiques/templates/statistiques/fiche_stats_details.html create mode 100644 statistiques/templates/statistiques/fiche_stats_update.html create mode 100644 statistiques/templates/statistiques/filter_form.html create mode 100644 statistiques/templates/statistiques/gchart/html.html create mode 100644 statistiques/templates/statistiques/gchart/pie_chart.html create mode 100644 statistiques/templates/statistiques/index.html create mode 100644 statistiques/templates/statistiques/menu.html create mode 100644 statistiques/templates/statistiques/typologie.html create mode 100644 statistiques/tests.py create mode 100644 statistiques/urls.py create mode 100644 statistiques/views.py delete mode 100644 suivi/apps.py delete mode 100644 suivi/forms.py delete mode 100644 suivi/models.py delete mode 100644 suivi/notes.py delete mode 100644 suivi/templates/suivi/details.html delete mode 100644 suivi/templates/suivi/index.html delete mode 100644 suivi/templates/suivi/menu_sujets.html delete mode 100644 suivi/templates/suivi/sujet_suivi.html delete mode 100644 suivi/tests.py delete mode 100644 suivi/urls.py delete mode 100644 suivi/views.py delete mode 100644 sujets/__init__.py delete mode 100644 sujets/admin.py delete mode 100644 sujets/apps.py delete mode 100644 sujets/forms.py delete mode 100644 sujets/models.py delete mode 100644 sujets/templates/sujets/base.html delete mode 100644 sujets/templates/sujets/list_table_cell.html delete mode 100644 sujets/templates/sujets/sujet_create.html delete mode 100644 sujets/templates/sujets/sujet_details.html delete mode 100644 sujets/templates/sujets/sujet_details_inner.html delete mode 100644 sujets/templates/sujets/sujet_liste.html delete mode 100644 sujets/templates/sujets/sujet_update.html delete mode 100644 sujets/templates/sujets/sujet_update_inner.html delete mode 100644 sujets/tests.py delete mode 100644 sujets/urls.py delete mode 100644 sujets/views.py create mode 100644 templates/admin/app_index.html create mode 100644 templates/admin/auth/user/add_form.html create mode 100644 templates/admin/auth/user/change_password.html create mode 100644 templates/admin/base.html create mode 100644 templates/admin/base_site.html create mode 100644 templates/admin/change_form.html create mode 100644 templates/admin/change_list.html create mode 100644 templates/admin/change_list_results.html create mode 100644 templates/admin/index.html create mode 100644 templates/admin/submit_line.html delete mode 100644 utilisateurs/__init__.py create mode 100644 utilisateurs/backends.py create mode 100644 utilisateurs/managers.py create mode 100644 utilisateurs/mixins.py delete mode 100644 website/__init__.py delete mode 100644 website/admin.py delete mode 100644 website/backends.py create mode 100644 website/context_processors.py delete mode 100644 website/decorators.py delete mode 100644 website/models.py delete mode 100644 website/navbar.py create mode 100644 website/static/scripts/jquery.flot.min.js rename website/templates/{main.html => index.html} (75%) delete mode 100644 website/templates/logout.html delete mode 100644 website/templates/navbar.html rename website/templates/tables/{table_cell.html => table_cell_default.html} (100%) diff --git a/maraudes/admin.py b/maraudes/admin.py index ee5d207..67c7678 100644 --- a/maraudes/admin.py +++ b/maraudes/admin.py @@ -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') diff --git a/maraudes/apps.py b/maraudes/apps.py index 1b48106..c051295 100644 --- a/maraudes/apps.py +++ b/maraudes/apps.py @@ -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) - diff --git a/maraudes/compte_rendu.py b/maraudes/compte_rendu.py deleted file mode 100644 index 5dd6aac..0000000 --- a/maraudes/compte_rendu.py +++ /dev/null @@ -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 - - diff --git a/maraudes/forms.py b/maraudes/forms.py index d2eb6d4..e1fb0b0 100644 --- a/maraudes/forms.py +++ b/maraudes/forms.py @@ -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") diff --git a/maraudes/management/commands/load_csv.py b/maraudes/management/commands/load_csv.py new file mode 100644 index 0000000..1b32026 --- /dev/null +++ b/maraudes/management/commands/load_csv.py @@ -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) diff --git a/maraudes/managers.py b/maraudes/managers.py index 1ec40ef..6e47710 100644 --- a/maraudes/managers.py +++ b/maraudes/managers.py @@ -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 """ diff --git a/maraudes/models.py b/maraudes/models.py index 10b10dd..d3a6e15 100644 --- a/maraudes/models.py +++ b/maraudes/models.py @@ -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: diff --git a/maraudes/notes.py b/maraudes/notes.py index 5931fe4..6d317e0 100644 --- a/maraudes/notes.py +++ b/maraudes/notes.py @@ -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') + diff --git a/maraudes/templates/compte_rendu/compterendu.html b/maraudes/templates/compte_rendu/compterendu.html deleted file mode 100644 index b13e306..0000000 --- a/maraudes/templates/compte_rendu/compterendu.html +++ /dev/null @@ -1,15 +0,0 @@ -{% load notes %} -
- - {% for note in notes %} - {% inline_table note header="sujet" %} - {% endfor %} -
-
-
-
-

Informations

-

Rencontres : {{ maraude.rencontre_count}}

-

Personnes rencontrées : {{ maraude.observation_count}}

-
-
diff --git a/maraudes/templates/compte_rendu/compterendu_create.html b/maraudes/templates/compte_rendu/compterendu_create.html deleted file mode 100644 index c807071..0000000 --- a/maraudes/templates/compte_rendu/compterendu_create.html +++ /dev/null @@ -1,104 +0,0 @@ -{% load bootstrap3 %}{% load staticfiles %} - - - - -
-
- -
Créer un nouvel objet : -
- - -
-
- -
- {% csrf_token %} -
-
-

Nouvelle rencontre

-
-
- {% include "compte_rendu/compterendu_form.html" %} -
- -
-
- {{ form.media.js }}{{ form.media.css }} -
-
-
-

Enregistrées

- - {% for rencontre in rencontres %} - {% for observation in rencontre.observations.all %} - - - {% endfor %}{% endfor %} -
{{ rencontre }}
{{observation.sujet}}{{observation.text}}
- -
-
-
- - - - diff --git a/maraudes/templates/compte_rendu/compterendu_update.html b/maraudes/templates/compte_rendu/compterendu_update.html deleted file mode 100644 index 5cfb17c..0000000 --- a/maraudes/templates/compte_rendu/compterendu_update.html +++ /dev/null @@ -1,22 +0,0 @@ -{% load bootstrap3 %} -
-{% csrf_token %} -{{ base_formset.management_form }} -
-
- - {% bootstrap_button "Enregistrer et quitter" button_type="submit" button_class="btn-primary btn-sm" icon="ok-circle" %} -
-
- {% for form, inline_formset in forms %} -
-
-
- {% include "compte_rendu/compterendu_form.html" %} -
- -
-
- {% endfor %} -
diff --git a/maraudes/templates/maraudes/base.html b/maraudes/templates/maraudes/base.html new file mode 100644 index 0000000..e64052c --- /dev/null +++ b/maraudes/templates/maraudes/base.html @@ -0,0 +1,14 @@ +{% extends "base_site.html" %} + +{% block title %} Maraudes > {% endblock %} + +{% block breadcrumbs %} +
  • {{ date }}
  • +{% endblock %} + +{% block sidebar %} +
    +
    + {% include "maraudes/menu.html" %} +
    +
    {% endblock %} diff --git a/maraudes/templates/maraudes/compterendu.html b/maraudes/templates/maraudes/compterendu.html new file mode 100644 index 0000000..db123ce --- /dev/null +++ b/maraudes/templates/maraudes/compterendu.html @@ -0,0 +1,169 @@ +{% extends "maraudes/base.html" %} +{% load bootstrap3 staticfiles %} + +{% block title %} {{ block.super }} Compte-rendu du {{ object.date }} {% endblock %} + +{% block breadcrumbs %} +
  • {{ object.date }}
  • +
  • Compte-rendu
  • +{% endblock %} + +{% block sidebar %} + {{ block.super }} +
    +
    +

    {% bootstrap_icon "plus" %} Création

    +
    + + +
    +
    +

    Finaliser

    + +
    +
    +{% endblock %} + +{% block page_content %} + + + +
    +
    +
    + {% csrf_token %} +
    +
    +

    Nouvelle rencontre

    +
    +
    + {% include "maraudes/compterendu_form.html" %} +
    + +
    +
    + {{ form.media.js }}{{ form.media.css }} +
    +
    +
    + + + {% for rencontre in rencontres %} + {% for observation in rencontre.observations.all %} + + {% endfor %}{% endfor %} +
    {{ rencontre }}
    + {{observation.sujet}} + + {% bootstrap_icon "stats" %} Mise à jour +
    {{observation.text}}
    +
    +
    + +
    +
    +
    + +
    +
    + + + + + +{% endblock %} diff --git a/maraudes/templates/compte_rendu/compterendu_form.html b/maraudes/templates/maraudes/compterendu_form.html similarity index 98% rename from maraudes/templates/compte_rendu/compterendu_form.html rename to maraudes/templates/maraudes/compterendu_form.html index e53bb8e..6e76dcd 100644 --- a/maraudes/templates/compte_rendu/compterendu_form.html +++ b/maraudes/templates/maraudes/compterendu_form.html @@ -6,9 +6,11 @@ - + + {% bootstrap_field form.duree layout="inline" size="small" %} +
    {{ inline_formset.management_form }} {% for form in inline_formset %} @@ -23,5 +25,4 @@
    {% endfor %} - diff --git a/maraudes/templates/maraudes/details.html b/maraudes/templates/maraudes/details.html deleted file mode 100644 index b5ff6a1..0000000 --- a/maraudes/templates/maraudes/details.html +++ /dev/null @@ -1,7 +0,0 @@ -{% if maraude.est_terminee %} - {% include "compte_rendu/compterendu.html" %} -{% else %} - {% if perms.maraudes.can_add_compterendu %}Écrire le compte-rendu - {% else %}

    Le compte-rendu n'a pas encore été écrit

    {% endif %} -{% endif %} - diff --git a/maraudes/templates/maraudes/finalize.html b/maraudes/templates/maraudes/finalize.html new file mode 100644 index 0000000..8b6cd35 --- /dev/null +++ b/maraudes/templates/maraudes/finalize.html @@ -0,0 +1,46 @@ +{% extends "maraudes/base.html" %} +{% load bootstrap3 %} +{% block title %} {{ block.super }} Compte-rendu du {{ object.date }} {% endblock %} + +{% block breadcrumbs %} +
  • {{ object.date }}
  • +
  • Transmission
  • +{% endblock %} + + +{% block page_content %} +{% if object.est_terminee %}

    Ce compte-rendu a déjà été finalisé !

    +{% else %} +
    +
    + {% csrf_token %} +
    +
    +

    Envoyer un message

    +
    +
    + {% bootstrap_form form %} +
    + +
    +
    +
    +
    + + + {% for rencontre in object.rencontres.all %} + {% for observation in rencontre.observations.all %} + + {% endfor %}{% endfor %} +
    {{ rencontre }}
    + {{observation.sujet}} + + {% bootstrap_icon "stats" %} Mise à jour +
    {{observation.text}}
    +
    +{% endif %} + +{% endblock %} diff --git a/maraudes/templates/maraudes/index.html b/maraudes/templates/maraudes/index.html index 029568c..5a66747 100644 --- a/maraudes/templates/maraudes/index.html +++ b/maraudes/templates/maraudes/index.html @@ -1,5 +1,15 @@ +{% extends "maraudes/base.html" %} + +{% block title %} {{ block.super }} Tableau de bord {% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Tableau de bord
  • +{% endblock %} + +{% block page_content %} {% load tables %} -
    +

    Votre prochaine maraude

    @@ -11,24 +21,32 @@ avec {% if user.is_superuser %}{{prochaine_maraude.binome}}{%else%}{{prochaine_maraude.referent}}{%endif%}.


    - {% if prochaine_maraude.est_terminee %} - - Voir le compte-rendu - {%else%} - - Rédiger le compte-rendu - {% endif %} + {% else %}

    Aucune maraude prévue.

    {% endif %}
    -
    -{% if user.is_superuser %} -
    +{% if derniers_sujets_rencontres %} +
    +
    +

    Ces derniers temps...

    +
    + {% table derniers_sujets_rencontres cols=3 cell_template="maraudes/table_cell_derniers_sujets.html" %} +
    +{% endif %} +{% if user.is_superuser and missing_cr %}

    Compte-rendus en retard

    - {% 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" %}
    -
    {% endif %} +
    +
    +

    Nouvelle note :

    +
    + {% include "notes/form_appel.html" with form=appel_form %} + {% include "notes/form_signalement.html" with form=signalement_form %} +
    +
    +{% endblock %} diff --git a/maraudes/templates/maraudes/lieu_create.html b/maraudes/templates/maraudes/lieu_create.html index 0664c35..bd75054 100644 --- a/maraudes/templates/maraudes/lieu_create.html +++ b/maraudes/templates/maraudes/lieu_create.html @@ -1 +1,8 @@ -{% include "maraudes/lieu_create_inner.html" %} +{% load bootstrap3 %} +
    {% csrf_token %} + {% bootstrap_form form layout="horizontal"%} +
    + {% bootstrap_button "Ajouter un lieu" button_type="submit" button_class="btn btn-primary" icon="plus" %} +
    + +
    diff --git a/maraudes/templates/maraudes/lieu_create_inner.html b/maraudes/templates/maraudes/lieu_create_inner.html index bd75054..e69de29 100644 --- a/maraudes/templates/maraudes/lieu_create_inner.html +++ b/maraudes/templates/maraudes/lieu_create_inner.html @@ -1,8 +0,0 @@ -{% load bootstrap3 %} -
    {% csrf_token %} - {% bootstrap_form form layout="horizontal"%} -
    - {% bootstrap_button "Ajouter un lieu" button_type="submit" button_class="btn btn-primary" icon="plus" %} -
    - -
    diff --git a/maraudes/templates/maraudes/list_table_cell.html b/maraudes/templates/maraudes/list_table_cell.html deleted file mode 100644 index d320b5d..0000000 --- a/maraudes/templates/maraudes/list_table_cell.html +++ /dev/null @@ -1,9 +0,0 @@ -{% if object.est_terminee %} -{% else %}{% endif %} - {{object.date}} -
    -{{ object.binome }} & {{ object.referent }} -{% if object.est_terminee %} -{{object.rencontres.count}} rencontres -{% endif %} -
    diff --git a/maraudes/templates/maraudes/liste.html b/maraudes/templates/maraudes/liste.html deleted file mode 100644 index 7ac9cb1..0000000 --- a/maraudes/templates/maraudes/liste.html +++ /dev/null @@ -1,32 +0,0 @@ -{% load bootstrap3 %} -{% load tables %} -
    -
    - -
    -

    Maraudes passées

    -
    - - {% table object_list cols=3 cell_template="maraudes/list_table_cell.html" %} - {% if is_paginated %} - - {% endif %} -
    -
    -
    -
    - - -
    -
    diff --git a/maraudes/templates/maraudes/menu.html b/maraudes/templates/maraudes/menu.html new file mode 100644 index 0000000..77cb8bc --- /dev/null +++ b/maraudes/templates/maraudes/menu.html @@ -0,0 +1,25 @@ +{% load navbar %} + diff --git a/maraudes/templates/maraudes/missing_cr_table_cell.html b/maraudes/templates/maraudes/missing_cr_table_cell.html deleted file mode 100644 index 6a277b9..0000000 --- a/maraudes/templates/maraudes/missing_cr_table_cell.html +++ /dev/null @@ -1,3 +0,0 @@ -{{object.date}} -{{object.referent}} & {{object.binome}} - diff --git a/maraudes/templates/maraudes/planning.html b/maraudes/templates/maraudes/planning.html new file mode 100644 index 0000000..2dcbb6e --- /dev/null +++ b/maraudes/templates/maraudes/planning.html @@ -0,0 +1,61 @@ +{% extends "maraudes/base.html" %} +{% load bootstrap3 %} + +{% block title %} {{ block.super }} Planning {% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Planning
  • +{% endblock %} + +{% block sidebar %} + {{ block.super }} +
    +
    + +
    +
    + {% bootstrap_icon "calendar" %} Choisir une autre période : + {% bootstrap_form select_form layout='horizontal' %} + {% bootstrap_button "Choisir" button_type="submit" button_class="btn btn-primary btn-sm" %} +
    +
    +
    +{% endblock %} + +{% block page_content %} +
    + + {% csrf_token %} + {{ formset.management_form }} + + + {% for weekday in weekdays %}{% endfor %} + + {% for week in weeks %} + + {% for day, form in week %} + + {% endfor %} + + {% endfor %} +
    {{weekday}}
    {% if day %}{% if form %} +
    +
    {% endif %} + {{ day }} + {% if form %}
    +
    + {% bootstrap_field form.id %} + {% bootstrap_field form.date %} + {% bootstrap_field form.heure_debut layout="inline" size="small" %} +
    +
    +
    + {% bootstrap_field form.binome layout="horizontal" size="small" show_label=False %} + {% bootstrap_field form.referent layout="horizontal" size="small" show_label=False %} +
    + {% endif %} + {% endif %} +
    +
    +{% endblock %} diff --git a/maraudes/templates/maraudes/table_cell_derniers_sujets.html b/maraudes/templates/maraudes/table_cell_derniers_sujets.html new file mode 100644 index 0000000..3f5c0f7 --- /dev/null +++ b/maraudes/templates/maraudes/table_cell_derniers_sujets.html @@ -0,0 +1 @@ +{{ object }} diff --git a/maraudes/templates/maraudes/table_cell_missing_cr.html b/maraudes/templates/maraudes/table_cell_missing_cr.html new file mode 100644 index 0000000..4465147 --- /dev/null +++ b/maraudes/templates/maraudes/table_cell_missing_cr.html @@ -0,0 +1 @@ + {{ object }} diff --git a/maraudes/templates/planning/planning.html b/maraudes/templates/planning/planning.html deleted file mode 100644 index 5525dca..0000000 --- a/maraudes/templates/planning/planning.html +++ /dev/null @@ -1,30 +0,0 @@ -{% load bootstrap3 %} -
    -
    - - {% bootstrap_form select_form layout='inline' %} - {% bootstrap_button "Choisir" button_type="submit" %} -
    -
    - -
    - {% csrf_token %} - {{ formset.management_form }} - {% for form in formset %}
    -
    -
    -
    {% if form.id %}{{ form.id }}{% endif %} - {% bootstrap_field form.date size="small" show_label=False %} - {% bootstrap_field form.heure_debut layout="inline" size="small" %} -
    -
    -
    -
    - {% bootstrap_field form.binome layout="horizontal" %} - {% bootstrap_field form.referent layout="horizontal" size="small" %} -
    -
    -
    -
    {% endfor %} -{% bootstrap_button "Enregistrer" button_type="submit" button_class="btn-primary" %} -
    diff --git a/maraudes/tests.py b/maraudes/tests.py index 7ec1850..4d1b3f9 100644 --- a/maraudes/tests.py +++ b/maraudes/tests.py @@ -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): diff --git a/maraudes/urls.py b/maraudes/urls.py index 7087ffa..c97ecba 100644 --- a/maraudes/urls.py +++ b/maraudes/urls.py @@ -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[0-9]+)/$', views.MaraudeDetailsView.as_view(), name="details"), - url(r'^(?P[0-9]+)/update/$', views.CompteRenduUpdateView.as_view(), name="update"), url(r'^(?P[0-9]+)/create/$', views.CompteRenduCreateView.as_view(), name="create"), + url(r'^(?P[0-9]+)/finalize/$', views.FinalizeView.as_view(), name="finalize"), ] diff --git a/maraudes/views.py b/maraudes/views.py index 86c0b65..6404696 100644 --- a/maraudes/views.py +++ b/maraudes/views.py @@ -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__" diff --git a/notes/__init__.py b/notes/__init__.py index e69de29..8ad3b03 100644 --- a/notes/__init__.py +++ b/notes/__init__.py @@ -0,0 +1 @@ +default_app_config = 'notes.apps.NotesConfig' diff --git a/notes/actions.py b/notes/actions.py new file mode 100644 index 0000000..7d99369 --- /dev/null +++ b/notes/actions.py @@ -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() diff --git a/notes/admin.py b/notes/admin.py index 8132060..609932c 100644 --- a/notes/admin.py +++ b/notes/admin.py @@ -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') diff --git a/notes/apps.py b/notes/apps.py index b6155ac..a92cd8b 100644 --- a/notes/apps.py +++ b/notes/apps.py @@ -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')) diff --git a/notes/forms.py b/notes/forms.py index 7c8bb51..494da59 100644 --- a/notes/forms.py +++ b/notes/forms.py @@ -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) diff --git a/notes/models.py b/notes/models.py index 03ca167..8fcbc55 100644 --- a/notes/models.py +++ b/notes/models.py @@ -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 diff --git a/notes/templates/notes/base.html b/notes/templates/notes/base.html new file mode 100644 index 0000000..1e71127 --- /dev/null +++ b/notes/templates/notes/base.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block title %}Notes >{% endblock %} + +{% block breadcrumbs %} +
  • Notes
  • +{% endblock %} + +{% block sidebar %} +
    +
    + {% include "notes/menu.html" %} +
    +
    {% endblock %} diff --git a/notes/templates/notes/details.html b/notes/templates/notes/details.html new file mode 100644 index 0000000..773ceac --- /dev/null +++ b/notes/templates/notes/details.html @@ -0,0 +1,33 @@ +{% extends "notes/base.html" %} +{% load bootstrap3 notes %} + +{% block page_content %} +
    +
    +
    + +{% block pre_content %}{% endblock %} + + {% for note in notes %} + {% if maraude %} + {% inline_table note header="sujet" %} + {% elif sujet %} + {% inline_table note header="date" %} + {% endif %} + {% endfor %} +
    +{% block post_content %}{% endblock %} +
    +
    +
    + +
    + {% block right_column %}{% endblock %} +
    +{% endblock %} + diff --git a/notes/templates/notes/details_maraude.html b/notes/templates/notes/details_maraude.html new file mode 100644 index 0000000..acac101 --- /dev/null +++ b/notes/templates/notes/details_maraude.html @@ -0,0 +1,47 @@ +{% extends "notes/details.html" %} + +{% block title %} + {{ block.super }} {{ maraude }} +{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Maraudes
  • +
  • {{ maraude }}
  • +{% endblock %} + +{% block sidebar %} +
    +
    +

    Navigation

    + +
    +
    +{% endblock %} + +{% block right_column %} +
    + +
    +

    Maraudeurs :  {{ maraude.binome }} & {{ maraude.referent }}

    +

    Nombre de rencontres  {{ maraude.rencontres.count }}

    +

    Nombre de personnes rencontrées  {{ maraude.observations_count }}

    +
    +
    +{% endblock %} diff --git a/notes/templates/notes/details_sujet.html b/notes/templates/notes/details_sujet.html new file mode 100644 index 0000000..6143824 --- /dev/null +++ b/notes/templates/notes/details_sujet.html @@ -0,0 +1,103 @@ +{% extends "notes/details.html" %} +{% load bootstrap3 %} + +{% block title %} + {{ block.super }} {{ sujet }} +{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Sujets
  • +
  • {{ sujet }}
  • +{% endblock %} + +{% block pre_content %} +
    +{% endblock %} + +{% block post_content %} + {% if notes.has_other_pages %}{% endif %} +
    + +
    + +
    +
    +
    {% csrf_token %} + {% bootstrap_form note_form show_label=False %} +
    + +
    +{% endblock %} + +{% block right_column %} +
    +
    +

    Informations

    +
    + {% include "notes/details_sujet_inner.html" %} +
    + +
    +

    Statistiques

    +
    + {% include "statistiques/fiche_stats_details.html" with object=sujet.statistiques %} +
    + +
    + +{% endblock %} + +{% block sidebar %} +{{ block.super }} +
    +{% if user.is_superuser %} + +{% endif %} +{% endblock %} diff --git a/notes/templates/notes/details_sujet_inner.html b/notes/templates/notes/details_sujet_inner.html new file mode 100644 index 0000000..65d811a --- /dev/null +++ b/notes/templates/notes/details_sujet_inner.html @@ -0,0 +1,22 @@ +
    + + {% with "-" as none %} + + + + + {% endwith %} +
    NomSurnomPrénom
    {{ sujet.nom|default:none }}{{ sujet.surnom|default:none}}{{ sujet.prenom|default:none}}
    ÂgePremière rencontre
    {{ sujet.age|default_if_none:none }}{{ sujet.premiere_rencontre|default_if_none:none }}
    + +
    + + diff --git a/notes/templates/notes/details_sujet_update.html b/notes/templates/notes/details_sujet_update.html new file mode 100644 index 0000000..f218fec --- /dev/null +++ b/notes/templates/notes/details_sujet_update.html @@ -0,0 +1,24 @@ +{% load bootstrap3 %} +
    {% csrf_token %} + + {% with "-" as none %} + + + + + {% endwith %} +
    NomSurnomPrénom
    {% bootstrap_field form.nom show_label=False %}{% bootstrap_field form.surnom show_label=False %}{% bootstrap_field form.prenom show_label=False %}
    ÂgeGenrePremière rencontre
    {% bootstrap_field form.age show_label=False %}{% bootstrap_field form.genre show_label=False %}{% bootstrap_field form.premiere_rencontre show_label=False %}
    + +
    + + diff --git a/suivi/templates/suivi/appel_form.html b/notes/templates/notes/form_appel.html similarity index 71% rename from suivi/templates/suivi/appel_form.html rename to notes/templates/notes/form_appel.html index 2795f3a..dd80699 100644 --- a/suivi/templates/suivi/appel_form.html +++ b/notes/templates/notes/form_appel.html @@ -1,12 +1,12 @@ -
    +
    - {% include "suivi/appel_form_inner.html" %}
    + {% include "notes/form_appel_inner.html" %}
    diff --git a/suivi/templates/suivi/appel_form_inner.html b/notes/templates/notes/form_appel_inner.html similarity index 90% rename from suivi/templates/suivi/appel_form_inner.html rename to notes/templates/notes/form_appel_inner.html index ce3b1a6..49186b0 100644 --- a/suivi/templates/suivi/appel_form_inner.html +++ b/notes/templates/notes/form_appel_inner.html @@ -1,7 +1,7 @@ {% load bootstrap3 %}
    {% csrf_token %} {% with "inline" as layout %} -
    +
    {% bootstrap_field form.created_date layout=layout %} {% bootstrap_field form.created_time layout=layout %} {% bootstrap_field form.entrant layout=layout %} @@ -14,3 +14,4 @@
    {% bootstrap_button "Enregistrer l'appel" button_type="submit" %}
    {{ form.media.js }}{{ form.media.css }} + diff --git a/suivi/templates/suivi/signalement_form.html b/notes/templates/notes/form_signalement.html similarity index 71% rename from suivi/templates/suivi/signalement_form.html rename to notes/templates/notes/form_signalement.html index 0b22928..a182775 100644 --- a/suivi/templates/suivi/signalement_form.html +++ b/notes/templates/notes/form_signalement.html @@ -1,12 +1,12 @@ -
    +
    - {% include "suivi/signalement_form_inner.html" %} + {% include "notes/form_signalement_inner.html" %}
    diff --git a/suivi/templates/suivi/signalement_form_inner.html b/notes/templates/notes/form_signalement_inner.html similarity index 100% rename from suivi/templates/suivi/signalement_form_inner.html rename to notes/templates/notes/form_signalement_inner.html diff --git a/notes/templates/notes/index.html b/notes/templates/notes/index.html new file mode 100644 index 0000000..6791117 --- /dev/null +++ b/notes/templates/notes/index.html @@ -0,0 +1,16 @@ +{% extends "notes/base.html" %} + +{% block title %}{{block.super}} Tableau de bord{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Tableau de bord
  • +{% endblock %} + +{% block page_content %} +
    + +

    Liste des sujets qui ont été ajoutés depuis la dernière connexion

    +

    Liste des compte-rendus qui ont été ajoutés depuis la dernière connexion

    +
    +{% endblock %} diff --git a/notes/templates/notes/liste.html b/notes/templates/notes/liste.html new file mode 100644 index 0000000..b7b1877 --- /dev/null +++ b/notes/templates/notes/liste.html @@ -0,0 +1,34 @@ +{% extends "notes/base.html" %} +{% load tables %} + +{% block sidebar %} +{{ block.super }} +{% block sidebar_insert %}{% endblock %} +{% if filters %} +
    +

    Filtres

    + +
    +{% endif %} +{% endblock %} + +{% block page_content %} + {% block search %}{% endblock %} + + {% table object_list cols=3 cell_template=table_cell_template header=table_header %} + {% if is_paginated %} +
    +
      {% with request.GET.filter as filter %} + {% for num in page_obj.paginator.page_range %} +
    • {{num}}
    • + {%endfor%}{% endwith %} +
    +
    + {% endif %} +{% endblock %} diff --git a/notes/templates/notes/liste_maraudes.html b/notes/templates/notes/liste_maraudes.html new file mode 100644 index 0000000..861e546 --- /dev/null +++ b/notes/templates/notes/liste_maraudes.html @@ -0,0 +1,14 @@ +{% extends "notes/liste.html" %} + +{% block title %} + {{ block.super }} Liste des maraudes +{% endblock %} + +{% block breadcrumbs %} + {{block.super}} +
  • Maraudes
  • +{% endblock %} + + + + diff --git a/notes/templates/notes/liste_sujets.html b/notes/templates/notes/liste_sujets.html new file mode 100644 index 0000000..9e65b34 --- /dev/null +++ b/notes/templates/notes/liste_sujets.html @@ -0,0 +1,39 @@ +{% extends "notes/liste.html" %} + +{% block title %}{{block.super}} Liste des sujets {% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Sujets
  • + {% if query_text %}
  • '{{query_text}}'
  • {% endif %} +{% endblock %} + +{% block sidebar_insert %} +
    +
    +

    Rechercher

    +
    {% csrf_token %} +
    + + + + +
    +
    +
    +

    Outils

    + + Ajouter un sujet
    +{% endblock %} + +{% block search %} + {% if query_text %}
    +

    '{{query_text}}' + + {% if not object_list %}Aucun résultat + {% else %} {{ object_list.count }} résultats + {% endif %} +

    +
    {% endif %} +{% endblock %} + diff --git a/notes/templates/notes/menu.html b/notes/templates/notes/menu.html new file mode 100644 index 0000000..45c8abe --- /dev/null +++ b/notes/templates/notes/menu.html @@ -0,0 +1,13 @@ +{% load navbar %} + diff --git a/notes/templates/notes/note.html b/notes/templates/notes/note.html deleted file mode 100644 index e69de29..0000000 diff --git a/notes/templates/notes/sujet_create.html b/notes/templates/notes/sujet_create.html new file mode 100644 index 0000000..711659b --- /dev/null +++ b/notes/templates/notes/sujet_create.html @@ -0,0 +1,9 @@ +{% extends "notes/base.html" %} + +{% block page_content %} +
    +
    + {% include "notes/sujet_create_inner.html" %} +
    +
    +{% endblock %} diff --git a/sujets/templates/sujets/sujet_create_inner.html b/notes/templates/notes/sujet_create_inner.html similarity index 74% rename from sujets/templates/sujets/sujet_create_inner.html rename to notes/templates/notes/sujet_create_inner.html index b6994a0..2a384c1 100644 --- a/sujets/templates/sujets/sujet_create_inner.html +++ b/notes/templates/notes/sujet_create_inner.html @@ -1,5 +1,5 @@ {% load bootstrap3 %} -
    {% csrf_token %} +{% csrf_token %} {% bootstrap_form form layout="horizontal"%}
    {% bootstrap_button "Ajouter un sujet" button_type="submit" button_class="btn btn-primary" icon="plus" %} diff --git a/notes/templates/notes/sujet_merge.html b/notes/templates/notes/sujet_merge.html new file mode 100644 index 0000000..7db6dc9 --- /dev/null +++ b/notes/templates/notes/sujet_merge.html @@ -0,0 +1,12 @@ +{% extends "notes/base.html" %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Sujets
  • +
  • {{ object }}
  • +
  • Fusionner vers...
  • +{% endblock %} + +{% block page_content %} +{% include 'notes/sujet_merge_inner.html' %} +{% endblock %} diff --git a/notes/templates/notes/sujet_merge_inner.html b/notes/templates/notes/sujet_merge_inner.html new file mode 100644 index 0000000..794916c --- /dev/null +++ b/notes/templates/notes/sujet_merge_inner.html @@ -0,0 +1,8 @@ +{% load bootstrap3 %} +

    Vous allez fusionner la fiche de {{object}} et ses {{object.notes.count}} notes vers :

    + +{% csrf_token %} +{{ form.media }} +{% bootstrap_field form.sujet %} +
    {% bootstrap_button 'Fusionner' button_type='submit' icon='paste' %}
    + diff --git a/notes/templates/notes/table_cell_maraudes.html b/notes/templates/notes/table_cell_maraudes.html new file mode 100644 index 0000000..3dc9dac --- /dev/null +++ b/notes/templates/notes/table_cell_maraudes.html @@ -0,0 +1,6 @@ +{% if object.est_terminee %} +{% else %}{% endif %}{{ object }} +
    + {{ object.binome }} & {{ object.referent }} + {% if object.est_terminee %}{{object.rencontres.count}} rencontres{% endif %} +
    diff --git a/notes/templates/notes/table_cell_sujets.html b/notes/templates/notes/table_cell_sujets.html new file mode 100644 index 0000000..8bcbaca --- /dev/null +++ b/notes/templates/notes/table_cell_sujets.html @@ -0,0 +1,9 @@ +{{object}} +
    + {{ object.notes.count }} notes + {% with object.statistiques.info_completed as completed %} + {{ completed }} % + {% endwith %} +
    + + diff --git a/notes/templatetags/notes.py b/notes/templatetags/notes.py index 5c09b29..c8c55d2 100644 --- a/notes/templatetags/notes.py +++ b/notes/templatetags/notes.py @@ -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) diff --git a/notes/tests.py b/notes/tests.py index 9120a03..55ea0c2 100644 --- a/notes/tests.py +++ b/notes/tests.py @@ -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 """ diff --git a/notes/urls.py b/notes/urls.py new file mode 100644 index 0000000..f508b04 --- /dev/null +++ b/notes/urls.py @@ -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[0-9]+)/$', views.SuiviSujetView.as_view(), name="details-sujet"), + url(r'sujets/(?P[0-9]+)/merge/$', views.MergeView.as_view(), name="sujets-merge"), + url(r'maraudes/$', views.MaraudeListView.as_view(), name="liste-maraude"), + url(r'maraudes/(?P[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[0-9]+)/$', views.SujetAjaxDetailsView.as_view(), name="sujet"), + url(r'sujet/(?P[0-9]+)/update/$', views.SujetAjaxUpdateView.as_view(), name="update-sujet"), +] diff --git a/notes/views.py b/notes/views.py new file mode 100644 index 0000000..a37ba15 --- /dev/null +++ b/notes/views.py @@ -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) diff --git a/requirements.txt b/requirements.txt index e2d15e2..55c987f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ django-bootstrap3 django-select2 django-watson +django-graphos +django-nose diff --git a/settings.py b/settings.py index 0b4eef7..0b62f6c 100644 --- a/settings.py +++ b/settings.py @@ -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' - ] - - diff --git a/suivi/__init__.py b/statistiques/__init__.py similarity index 100% rename from suivi/__init__.py rename to statistiques/__init__.py diff --git a/suivi/admin.py b/statistiques/admin.py similarity index 100% rename from suivi/admin.py rename to statistiques/admin.py diff --git a/statistiques/apps.py b/statistiques/apps.py new file mode 100644 index 0000000..3f1d7b8 --- /dev/null +++ b/statistiques/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + +class StatistiquesConfig(AppConfig): + name = 'statistiques' + + + + diff --git a/statistiques/charts.py b/statistiques/charts.py new file mode 100644 index 0000000..786dc34 --- /dev/null +++ b/statistiques/charts.py @@ -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 diff --git a/statistiques/forms.py b/statistiques/forms.py new file mode 100644 index 0000000..705d0bd --- /dev/null +++ b/statistiques/forms.py @@ -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') + ], + ) diff --git a/statistiques/models.py b/statistiques/models.py new file mode 100644 index 0000000..1866658 --- /dev/null +++ b/statistiques/models.py @@ -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) diff --git a/statistiques/templates/statistiques/base.html b/statistiques/templates/statistiques/base.html new file mode 100644 index 0000000..fa83e2d --- /dev/null +++ b/statistiques/templates/statistiques/base.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% load static %} + +{% block extrahead %} + + + +{% endblock %} + +{% block title %}Statistiques >{% endblock %} + +{% block breadcrumbs %} +
  • Statistiques
  • +{% endblock %} + +{% block sidebar %} + {% include "statistiques/menu.html" %} +{% endblock %} diff --git a/statistiques/templates/statistiques/fiche_stats_details.html b/statistiques/templates/statistiques/fiche_stats_details.html new file mode 100644 index 0000000..b124d6f --- /dev/null +++ b/statistiques/templates/statistiques/fiche_stats_details.html @@ -0,0 +1,42 @@ +{% load boolean_icons bootstrap3 %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Problématiques
    Psychiatrique{{ object.prob_psychiatrie|as_icon }}Addiction{{ object.prob_addiction|as_icon }}
    Administratif{{ object.prob_administratif|as_icon }}Somatique{{ object.prob_somatique|as_icon }}
    Habitation
    Type{{ object.habitation }}Connu du SIAO{{ object.connu_siao|as_icon }}
    Ressources
    {{ object.ressources }}
    Parcours de vie
    {{ object.parcours_de_vie }}Lien familial{{ object.lien_familial|as_icon }}
    + diff --git a/statistiques/templates/statistiques/fiche_stats_update.html b/statistiques/templates/statistiques/fiche_stats_update.html new file mode 100644 index 0000000..9fc2eda --- /dev/null +++ b/statistiques/templates/statistiques/fiche_stats_update.html @@ -0,0 +1,44 @@ +{% load bootstrap3 %} +
    {% csrf_token%} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Problématiques
    Psychiatrique{% bootstrap_field form.prob_psychiatrie show_label=False size="small" %}Addiction{% bootstrap_field form.prob_addiction show_label=False size="small" %}
    Administratif{% bootstrap_field form.prob_administratif show_label=False size="small" %}Somatique{% bootstrap_field form.prob_somatique show_label=False size="small" %}
    Habitation
    Type{% bootstrap_field form.habitation show_label=False size="small" %}Connu du SIAO{% bootstrap_field form.connu_siao show_label=False size="small" %}
    Ressources
    {% bootstrap_field form.ressources show_label=False size="small" %}
    Parcours de vie
    {% bootstrap_field form.parcours_de_vie show_label=False size="small" %}Lien familial{% bootstrap_field form.lien_familial show_label=False size="small" %}
    + +
    diff --git a/statistiques/templates/statistiques/filter_form.html b/statistiques/templates/statistiques/filter_form.html new file mode 100644 index 0000000..64a5b85 --- /dev/null +++ b/statistiques/templates/statistiques/filter_form.html @@ -0,0 +1,7 @@ +{% load bootstrap3 %} + +

    Période

    +
    + {% bootstrap_form form layout="inline" %} + {% bootstrap_button "Ok" button_type="submit" %} +
    diff --git a/statistiques/templates/statistiques/gchart/html.html b/statistiques/templates/statistiques/gchart/html.html new file mode 100644 index 0000000..d94ed05 --- /dev/null +++ b/statistiques/templates/statistiques/gchart/html.html @@ -0,0 +1,5 @@ + + diff --git a/statistiques/templates/statistiques/gchart/pie_chart.html b/statistiques/templates/statistiques/gchart/pie_chart.html new file mode 100644 index 0000000..6a519aa --- /dev/null +++ b/statistiques/templates/statistiques/gchart/pie_chart.html @@ -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 %} diff --git a/statistiques/templates/statistiques/index.html b/statistiques/templates/statistiques/index.html new file mode 100644 index 0000000..0924178 --- /dev/null +++ b/statistiques/templates/statistiques/index.html @@ -0,0 +1,60 @@ +{% extends "statistiques/base.html" %} +{% load static %} + +{% block title %}{{ block.super }} Maraudes{% endblock %} + + +{% block sidebar %} + {{ block.super }} +
    +
    + {% include "statistiques/filter_form.html" %} +
    +
    +{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Maraudes
  • +{% endblock %} + +{% block page_content %} + +
    + +

    Voici les données permettant une analyse statistiques des maraudes.

    +

    Vous pouvez sélectionner une période particulière ou l'ensemble des données

    +

    Les données sont réparties en trois catégories, accessibles par le menu sur la gauche

    +
    +
    + +
      +
    • + {{ nbr_maraudes }} + Nombre de maraudes +
    • +
    • + {{ nbr_maraudes_jour }} + dont, Maraudes de journée +
    • +
    • + {{ nbr_rencontres }} + Nombre total de rencontres +
    • +
    • + {{ moy_rencontres }} + soit, en moyenne par maraude +
    • +
    • + {{ nbr_sujets_rencontres }} + Nombre de sujets rencontrés +
    • +
    +
    +{% if rencontres_par_mois %} +
    + + {{ rencontres_par_mois.as_html }} +
    +{% endif %} +{% endblock %} diff --git a/statistiques/templates/statistiques/menu.html b/statistiques/templates/statistiques/menu.html new file mode 100644 index 0000000..f276fe0 --- /dev/null +++ b/statistiques/templates/statistiques/menu.html @@ -0,0 +1,14 @@ +{% load navbar %} + +
    diff --git a/statistiques/templates/statistiques/typologie.html b/statistiques/templates/statistiques/typologie.html new file mode 100644 index 0000000..67ec86d --- /dev/null +++ b/statistiques/templates/statistiques/typologie.html @@ -0,0 +1,46 @@ +{% extends "statistiques/base.html" %} + +{% block title %}{{block.super}} Typologie{% endblock %} + +{% block breadcrumbs %}{{block.super}}
  • Typologie
  • {% endblock %} + +{% block sidebar %} + {{ block.super }} +
    +
    +
    + {% include "statistiques/filter_form.html" %} +
    +

    Échantillon : {{ queryset.count }} sujets

    +
    +
    +{% endblock %} + +{% block page_content %} + + + + {% for title, graph in graphs %} + {{ graph.as_html }} + {% endfor %} + +{% endblock %} diff --git a/statistiques/tests.py b/statistiques/tests.py new file mode 100644 index 0000000..997542e --- /dev/null +++ b/statistiques/tests.py @@ -0,0 +1,7 @@ +from django.test import TestCase + +# Create your tests here. + +# MANDATORY FEATURES + +# FicheStatistique primary key IS it's foreign sujet pk diff --git a/statistiques/urls.py b/statistiques/urls.py new file mode 100644 index 0000000..e792961 --- /dev/null +++ b/statistiques/urls.py @@ -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[0-9]+)/$', views.StatistiquesDetailsView.as_view(), name="details"), + url(r'^update/(?P[0-9]+)/$', views.StatistiquesUpdateView.as_view(), name="update"), +] + diff --git a/statistiques/views.py b/statistiques/views.py new file mode 100644 index 0000000..07c672d --- /dev/null +++ b/statistiques/views.py @@ -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" diff --git a/suivi/apps.py b/suivi/apps.py deleted file mode 100644 index 335b08d..0000000 --- a/suivi/apps.py +++ /dev/null @@ -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')) - diff --git a/suivi/forms.py b/suivi/forms.py deleted file mode 100644 index 79a7b85..0000000 --- a/suivi/forms.py +++ /dev/null @@ -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 diff --git a/suivi/models.py b/suivi/models.py deleted file mode 100644 index 71a8362..0000000 --- a/suivi/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/suivi/notes.py b/suivi/notes.py deleted file mode 100644 index 3f12eb2..0000000 --- a/suivi/notes.py +++ /dev/null @@ -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') - - - diff --git a/suivi/templates/suivi/details.html b/suivi/templates/suivi/details.html deleted file mode 100644 index c3576f2..0000000 --- a/suivi/templates/suivi/details.html +++ /dev/null @@ -1,26 +0,0 @@ -{% load bootstrap3 %} -
    -
    - {% include "suivi/sujet_suivi.html" %} -
    - -
    -
    -
    {% csrf_token %} - {% bootstrap_form note_form show_label=False %} -
    - -
    -
    -
    -
    -
    {% include "sujets/sujet_details.html" %}
    diff --git a/suivi/templates/suivi/index.html b/suivi/templates/suivi/index.html deleted file mode 100644 index 23b12c9..0000000 --- a/suivi/templates/suivi/index.html +++ /dev/null @@ -1,14 +0,0 @@ -
    -

    Ces derniers temps

    -

    Nous avons rencontré {{ derniers_sujets }}.

    -

    Vigilance

    -
    - -
    -

    Créer une note :

    -
    - {% include "suivi/appel_form.html" with form=appel_form %} - {% include "suivi/signalement_form.html" with form=signalement_form %} -
    -
    - diff --git a/suivi/templates/suivi/menu_sujets.html b/suivi/templates/suivi/menu_sujets.html deleted file mode 100644 index 51a5b30..0000000 --- a/suivi/templates/suivi/menu_sujets.html +++ /dev/null @@ -1,16 +0,0 @@ -{% load bootstrap3 %} -
  • - Liste des sujets - {% bootstrap_icon "list" %} -
  • - {% if user.is_superuser %} -
  • Administration
  • + {% for app in app_list %}
  • {{ app.name }}
  • {% endfor %} +{% endblock %} +{% endif %} + +{% block sidebar %}{% endblock %} diff --git a/templates/admin/auth/user/add_form.html b/templates/admin/auth/user/add_form.html new file mode 100644 index 0000000..5c240d5 --- /dev/null +++ b/templates/admin/auth/user/add_form.html @@ -0,0 +1,10 @@ +{% extends "admin/change_form.html" %} +{% load i18n %} + +{% block form_top %} + {% if not is_popup %} +

    {% trans "First, enter a username and password. Then, you'll be able to edit more user options." %}

    + {% else %} +

    {% trans "Enter a username and password." %}

    + {% endif %} +{% endblock %} diff --git a/templates/admin/auth/user/change_password.html b/templates/admin/auth/user/change_password.html new file mode 100644 index 0000000..25f0dde --- /dev/null +++ b/templates/admin/auth/user/change_password.html @@ -0,0 +1,57 @@ +{% extends "admin/base_site.html" %} +{% load i18n static %} +{% load admin_urls %} + +{% block extrahead %}{{ block.super }} + +{% endblock %} +{% block extrastyle %}{{ block.super }}{% endblock %} +{% block bodyclass %}{{ block.super }} {{ opts.app_label }}-{{ opts.model_name }} change-form{% endblock %} +{% if not is_popup %} +{% block breadcrumbs %} +
  • {% trans 'Home' %}
  • +
  • {{ opts.app_config.verbose_name }}
  • +
  • {{ opts.verbose_name_plural|capfirst }}
  • +
  • {{ original|truncatewords:"18" }}
  • +
  • {% trans 'Change password' %}
  • +{% endblock %} +{% endif %} +{% block content %}
    +
    {% csrf_token %}{% block form_top %}{% endblock %} +
    +{% if is_popup %}{% endif %} +{% if form.errors %} +

    + {% if form.errors.items|length == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %} +

    +{% endif %} + +

    {% blocktrans with username=original %}Enter a new password for the user {{ username }}.{% endblocktrans %}

    + +
    + +
    + {{ form.password1.errors }} + {{ form.password1.label_tag }} {{ form.password1 }} + {% if form.password1.help_text %} +

    {{ form.password1.help_text|safe }}

    + {% endif %} +
    + +
    + {{ form.password2.errors }} + {{ form.password2.label_tag }} {{ form.password2 }} + {% if form.password2.help_text %} +

    {{ form.password2.help_text|safe }}

    + {% endif %} +
    + +
    + +
    + +
    + +
    +
    +{% endblock %} diff --git a/templates/admin/base.html b/templates/admin/base.html new file mode 100644 index 0000000..eeab338 --- /dev/null +++ b/templates/admin/base.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% load i18n static %} +{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %} + +{% block extrastyle %} + +{% if LANGUAGE_BIDI %}{% endif %} +{% endblock %} + +{% load i18n %} + +{% block extra_body_attrs %}class="{% if is_popup %}popup {% endif %}{% block bodyclass %}{% endblock %}" data-admin-utc-offset="{% now "Z" %}" +{% endblock %} + +{% block page_content %} + +
    + + +
    + {% block pretitle %}{% endblock %} + {% block content_title %}{% if title %}

    {{ title }}

    {% endif %}{% endblock %} + {% block content %} + {% block object-tools %}{% endblock %} + {{ content }} + {% endblock %} +
    +
    + + + {% block footer %}{% endblock %} +
    + + +{% endblock %} diff --git a/templates/admin/base_site.html b/templates/admin/base_site.html new file mode 100644 index 0000000..4bbef55 --- /dev/null +++ b/templates/admin/base_site.html @@ -0,0 +1,5 @@ +{% extends "admin/base.html" %} + +{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} + +{% block nav-global %}{% endblock %} diff --git a/templates/admin/change_form.html b/templates/admin/change_form.html new file mode 100644 index 0000000..a324c5b --- /dev/null +++ b/templates/admin/change_form.html @@ -0,0 +1,83 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static admin_modify %} + +{% block extrahead %}{{ block.super }} + +{{ media }} +{% endblock %} + +{% block extrastyle %}{{ block.super }}{% endblock %} + +{% block coltype %}colM{% endblock %} + +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-form{% endblock %} + +{% if not is_popup %} +{% block breadcrumbs %} +
  • {% trans 'Home' %}
  • +
  • {{ opts.app_config.verbose_name }}
  • +
  • {% if has_change_permission %}{{ opts.verbose_name_plural|capfirst }}{% else %}{{ opts.verbose_name_plural|capfirst }}{% endif %}
  • +
  • {% if add %}{% blocktrans with name=opts.verbose_name %}Add {{ name }}{% endblocktrans %}{% else %}{{ original|truncatewords:"18" }}{% endif %}
  • +{% endblock %} +{% endif %} + +{% block content %}
    +{% block object-tools %} +{% if change %}{% if not is_popup %} + +{% endif %}{% endif %} +{% endblock %} +
    {% csrf_token %}{% block form_top %}{% endblock %} +
    +{% if is_popup %}{% endif %} +{% if to_field %}{% endif %} +{% if save_on_top %}{% block submit_buttons_top %}{% submit_row %}{% endblock %}{% endif %} +{% if errors %} +

    + {% if errors|length == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %} +

    + {{ adminform.form.non_field_errors }} +{% endif %} + +{% block field_sets %} +{% for fieldset in adminform %} + {% include "admin/includes/fieldset.html" %} +{% endfor %} +{% endblock %} + +{% block after_field_sets %}{% endblock %} + +{% block inline_field_sets %} +{% for inline_admin_formset in inline_admin_formsets %} + {% include inline_admin_formset.opts.template %} +{% endfor %} +{% endblock %} + +{% block after_related_objects %}{% endblock %} + +{% block submit_buttons_bottom %}{% submit_row %}{% endblock %} + +{% block admin_change_form_document_ready %} + +{% endblock %} + +{# JavaScript for prepopulated fields #} +{% prepopulated_fields_js %} + +
    +
    +{% endblock %} diff --git a/templates/admin/change_list.html b/templates/admin/change_list.html new file mode 100644 index 0000000..e9d0924 --- /dev/null +++ b/templates/admin/change_list.html @@ -0,0 +1,87 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static admin_list %} + +{% block extrastyle %} + {{ block.super }} + + {% if cl.formset %} + + {% endif %} + {% if cl.formset or action_form %} + + {% endif %} + {{ media.css }} + {% if not actions_on_top and not actions_on_bottom %} + + {% endif %} +{% endblock %} + +{% block extrahead %} +{{ block.super }} +{{ media.js }} +{% endblock %} + +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-list{% endblock %} + +{% if not is_popup %} +{% block breadcrumbs %} +
  • {% trans 'Administration' %}
  • +
  • {{ cl.opts.app_config.verbose_name }}
  • +
  • {{ cl.opts.verbose_name_plural|capfirst }}
  • +{% endblock %} +{% endif %} + +{% block coltype %}flex{% endblock %} + +{% block content %} +
    + {% block object-tools %} + + {% endblock %} + {% if cl.formset.errors %} +

    + {% if cl.formset.total_error_count == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %} +

    + {{ cl.formset.non_form_errors }} + {% endif %} +
    + {% block search %}{% search_form cl %}{% endblock %} + {% block date_hierarchy %}{% date_hierarchy cl %}{% endblock %} + + {% block filters %} + {% if cl.has_filters %} +
    +

    {% trans 'Filter' %}

    + {% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %} +
    + {% endif %} + {% endblock %} + +
    {% csrf_token %} + {% if cl.formset %} +
    {{ cl.formset.management_form }}
    + {% endif %} + + {% block result_list %} + {% if action_form and actions_on_top and cl.show_admin_actions %}{% admin_actions %}{% endif %} + {% result_list cl %} + {% if action_form and actions_on_bottom and cl.show_admin_actions %}{% admin_actions %}{% endif %} + {% endblock %} + {% block pagination %}{% pagination cl %}{% endblock %} +
    +
    +
    +{% endblock %} diff --git a/templates/admin/change_list_results.html b/templates/admin/change_list_results.html new file mode 100644 index 0000000..b3d7dd0 --- /dev/null +++ b/templates/admin/change_list_results.html @@ -0,0 +1,38 @@ +{% load i18n static %} +{% if result_hidden_fields %} +
    {# DIV for HTML validation #} +{% for item in result_hidden_fields %}{{ item }}{% endfor %} +
    +{% endif %} +{% if results %} +
    + + + +{% for header in result_headers %} +{% endfor %} + + + +{% for result in results %} +{% if result.form.non_field_errors %} + +{% endif %} +{% for item in result %}{{ item }}{% endfor %} +{% endfor %} + +
    + {% if header.sortable %} + {% if header.sort_priority > 0 %} +
    + + {% if num_sorted_fields > 1 %}{{ header.sort_priority }}{% endif %} + +
    + {% endif %} + {% endif %} +
    {% if header.sortable %}{{ header.text|capfirst }}{% else %}{{ header.text|capfirst }}{% endif %}
    +
    +
    {{ result.form.non_field_errors }}
    +
    +{% endif %} diff --git a/templates/admin/index.html b/templates/admin/index.html new file mode 100644 index 0000000..68075af --- /dev/null +++ b/templates/admin/index.html @@ -0,0 +1,82 @@ +{% extends "admin/base_site.html" %} +{% load i18n static %} + +{% block extrastyle %}{{ block.super }}{% endblock %} + +{% block coltype %}colMS{% endblock %} + +{% block bodyclass %}{{ block.super }} dashboard{% endblock %} + +{% block breadcrumbs %}
  • Administration
  • {% endblock %} + +{% block content %} +
    + +{% if app_list %} + {% for app in app_list %} +
    + + + {% for model in app.models %} + + {% if model.admin_url %} + + {% else %} + + {% endif %} + + {% if model.add_url %} + + {% else %} + + {% endif %} + + {% if model.admin_url %} + + {% else %} + + {% endif %} + + {% endfor %} +
    + {{ app.name }} +
    {{ model.name }}{{ model.name }}{% trans 'Add' %} {% trans 'Change' %} 
    +
    + {% endfor %} +{% else %} +

    {% trans "You don't have permission to edit anything." %}

    +{% endif %} +
    +{% endblock %} + +{% block sidebar %} + +{% endblock %} diff --git a/templates/admin/submit_line.html b/templates/admin/submit_line.html new file mode 100644 index 0000000..c1b907f --- /dev/null +++ b/templates/admin/submit_line.html @@ -0,0 +1,11 @@ +{% load i18n admin_urls bootstrap3 %} +
    +{% if show_save %}{% endif %} +{% if show_delete_link %} + {% url opts|admin_urlname:'delete' original.pk|admin_urlquote as delete_url %} + +{% endif %} +{% if show_save_as_new %}{% endif %} +{% if show_save_and_add_another %}{% endif %} +{% if show_save_and_continue %}{% endif %} +
    diff --git a/utilisateurs/__init__.py b/utilisateurs/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/utilisateurs/admin.py b/utilisateurs/admin.py index 23fd0bc..8d9be87 100644 --- a/utilisateurs/admin.py +++ b/utilisateurs/admin.py @@ -1,4 +1,4 @@ -from django.contrib import admin +from django.contrib import admin, messages from .models import * # Register your models here. @@ -9,13 +9,47 @@ admin.register(Organisme) @admin.register(Maraudeur) class MaraudeurAdmin(admin.ModelAdmin): fieldsets = [ - ('Informations', {'fields': [('first_name', 'last_name')]}), + ('Identité', {'fields': [('first_name', 'last_name'),]}), + ('Contact', {'fields': [('organisme','email',)]}), + ('Statut', {'fields': [('is_active',)]}), ] - list_display = ('username', 'is_active') + list_display = ('username', 'is_active', 'est_referent') + actions = ['set_referent', 'toggle_staff'] + ordering = ['-is_active', 'username'] + def get_changeform_initial_data(self, request): + return {'organisme': Maraudeur.get_organisme(), + 'is_active': True, + } + def set_referent(self, request, queryset): + if len(queryset) > 1: + self.message_user( + request, + "Vous ne pouvez définir qu'un seul référent !", + level=messages.WARNING) + return + maraudeur = queryset.first() + Maraudeur.objects.set_referent(maraudeur.first_name, maraudeur.last_name) + self.message_user(request, "%s a été défini comme référent." % maraudeur, + level=messages.SUCCESS) + set_referent.short_description = "Définir comme référent" + + def toggle_staff(self, request, queryset): + try: + for m in queryset: + m.is_active = not m.is_active + m.save() + self.message_user(request, + "%i maraudeurs ont été modifié(s)" % len(queryset), + level=messages.SUCCESS) + except: + self.message_user(request, "Erreur lors de l'inversion", level=messages.WARNING) + toggle_staff.short_description = "Inverser le statut actif" @admin.register(Organisme) class OrganismeAdmin(admin.ModelAdmin): pass + + diff --git a/utilisateurs/apps.py b/utilisateurs/apps.py index e2dbaf8..76327c8 100644 --- a/utilisateurs/apps.py +++ b/utilisateurs/apps.py @@ -1,16 +1,7 @@ from django.apps import AppConfig -from website.decorators import Webpage -from .models import Professionnel class UtilisateursConfig(AppConfig): name = 'utilisateurs' -utilisateurs = Webpage('utilisateurs', - icon="user", - defaults={ - 'users': [Professionnel], - 'ajax': False, - 'title': ('Utilisateurs','app'), - }) diff --git a/utilisateurs/backends.py b/utilisateurs/backends.py new file mode 100644 index 0000000..7627e50 --- /dev/null +++ b/utilisateurs/backends.py @@ -0,0 +1,34 @@ +import logging +from django.contrib.auth.backends import ModelBackend +from .models import Maraudeur + +logger = logging.getLogger(__name__) + +AUTHORIZED_MODELS = (Maraudeur, ) + +class CustomUserAuthentication(ModelBackend): + """ Custom ModelBackend that can only return an authorized custom models """ + + def authenticate(self, *args, **kwargs): + user = super().authenticate(*args, **kwargs) + if user: + user = self.get_user(user.pk) + return user + + def get_user(self, user_id): + user = super().get_user(user_id) + if not user: + return None + + for model in AUTHORIZED_MODELS: + try: + return model.objects.get(user_ptr=user) + except: + continue + + logger.warning("WARNING: Could not find any AUTHORIZED_MODEL for %s !" % user) + return user + + def has_perm(self, *args, **kwargs): + print('call has_perm', args, kwargs) + return super().has_perm(*args, **kwargs) diff --git a/utilisateurs/managers.py b/utilisateurs/managers.py new file mode 100644 index 0000000..25212d8 --- /dev/null +++ b/utilisateurs/managers.py @@ -0,0 +1,20 @@ +from django.contrib.auth.models import UserManager + +class MaraudeurManager(UserManager): + """ Manager for Maraudeurs objects. + """ + + def get_referent(self): + try: + return self.get(is_superuser=True) + except self.model.DoesNotExist: + return None + + def set_referent(self, first_name, last_name): + maraudeur, created = self.get_or_create(first_name=first_name, last_name=last_name) + for previous in self.get_queryset().filter(is_superuser=True): + previous.is_superuser = False + previous.save() + maraudeur.is_superuser = True + maraudeur.save() + return maraudeur diff --git a/utilisateurs/mixins.py b/utilisateurs/mixins.py new file mode 100644 index 0000000..f7ed2b1 --- /dev/null +++ b/utilisateurs/mixins.py @@ -0,0 +1,7 @@ +from django.contrib.auth.mixins import UserPassesTestMixin +from .models import Maraudeur + +class MaraudeurMixin(UserPassesTestMixin): + + def test_func(self): + return isinstance(self.request.user, Maraudeur) diff --git a/utilisateurs/models.py b/utilisateurs/models.py index 323b9bd..80edb36 100644 --- a/utilisateurs/models.py +++ b/utilisateurs/models.py @@ -1,25 +1,37 @@ import datetime -from django.db import models +from django.core.exceptions import ImproperlyConfigured +from django.conf import settings -from django.contrib.auth.models import User, UserManager, AnonymousUser +from django.db import models +from django.contrib.auth.models import User + +from .managers import MaraudeurManager # Create your models here. +if not settings.MARAUDEURS: + raise ImproperlyConfigured("No configuration for Maraudeur model") +else: + try: + assert(isinstance(settings.MARAUDEURS.get('organisme'), dict)) + except: + raise ImproperlyConfigured("'organisme' key of MARAUDEURS settings is not a dict !") -## Visiteur -class Visiteur(AnonymousUser): +def get_email_suffix(organisme): + if not organisme.email: + return "unconfigured.org" + else: + return organisme.email.split("@")[1] - def __str__(self): - return "Visiteur" class Organisme(models.Model): """ Organisme : Association, Entreprise, Service public, ...""" - nom = models.CharField(max_length=64) + nom = models.CharField(max_length=64, primary_key=True) email = models.EmailField("e-mail") - adresse = models.CharField(max_length=128) + adresse = models.CharField(max_length=128, blank=True, null=True) class Meta: verbose_name = "Organisme" @@ -31,70 +43,50 @@ class Organisme(models.Model): class Professionnel(User): """ Professionnel d'un organisme """ - organisme = models.ForeignKey( - Organisme, + organisme = models.ForeignKey(Organisme, + models.CASCADE, related_name="professionnels", - blank=True, null=True # For now ) + def make_username(self): + """ Build the username for this Professionel instance. Must be overriden.""" + raise NotImplementedError - -class MaraudeurManager(UserManager): - """ Manager for Maraudeurs objects. - - Updates `create`, `get_or_create` methods signatures : 'first_name', 'last_name'. - Add `set_referent` method (same signature). - """ - - def create(self, first_name, last_name): - username = "%s.%s" % (first_name[0].lower(), last_name.lower()) - data = { - 'first_name': first_name, - 'last_name': last_name, - 'email': "%s@alsa68.org" % username, - 'is_staff': True, - 'is_active': True, - } - - return super().create_user(username, **data) - - def get_or_create(self, first_name, last_name): - try: - maraudeur = self.get(first_name=first_name, last_name=last_name) - created = False - except self.model.DoesNotExist: - created = True - maraudeur = self.create(first_name, last_name) - - return (maraudeur, created) - - def get_referent(self): - try: - return self.get(is_superuser=True) - except self.model.DoesNotExist: - return None - - def set_referent(self, first_name, last_name): - maraudeur, created = self.get_or_create(first_name, last_name) - for previous in self.get_queryset().filter(is_superuser=True): - previous.is_superuser = False - previous.save() - maraudeur.is_superuser = True - maraudeur.save() - return maraudeur + def save(self, *args, **kwargs): + self.username = self.make_username() + if not self.pk: + self.email = "%s@%s" % (self.username, get_email_suffix(self.organisme)) + return super().save(*args, **kwargs) class Maraudeur(Professionnel): - """ Professionnels qui participent aux maraudes """ + """ Professionnel qui participe aux maraudes """ - # Donne accès aux vues "maraudes" et "suivi" + @staticmethod + def get_organisme(): + return Organisme.objects.get_or_create(**settings.MARAUDEURS['organisme'])[0] + + def est_referent(self): + return self.is_superuser + est_referent.boolean = True + est_referent.short_description = 'Référent Maraude' objects = MaraudeurManager() class Meta: verbose_name = "Maraudeur" - def __str__(self): - return "%s %s" % (self.first_name, self.last_name[0]) + def make_username(self): + return "%s.%s" % (self.first_name[0].lower(), self.last_name.lower()) + + def save(self, *args, **kwargs): + if not self.pk: + self.is_staff = True + self.organisme = Maraudeur.get_organisme() + self.set_password(settings.MARAUDEURS['password']) + return super().save(*args, **kwargs) + + def __str__(self): + return "%s %s." % (self.first_name, self.last_name[0]) diff --git a/utilisateurs/templates/utilisateurs/details.html b/utilisateurs/templates/utilisateurs/details.html index 8578ead..c0391e1 100644 --- a/utilisateurs/templates/utilisateurs/details.html +++ b/utilisateurs/templates/utilisateurs/details.html @@ -1,2 +1,8 @@ +{% extends "base.html" %} +{% block page_content %} + {{ user.first_name }}, {{ user.last_name }} + +{% endblock %} + diff --git a/utilisateurs/tests.py b/utilisateurs/tests.py index 7374e10..215c448 100644 --- a/utilisateurs/tests.py +++ b/utilisateurs/tests.py @@ -1,33 +1,64 @@ from django.test import TestCase -from .models import Maraudeur, Professionnel +from utilisateurs.models import Maraudeur, Professionnel # Create your tests here. +def generate_names(): + i = 0 + while True: + yield {'first_name': 'name%i' % i, 'last_name': 'family%i' % i} + i += 1 + +class ProfessionelTestCase(TestCase): + pass -#TODO: Un seul objet Maraudeur peut avoir la propriété vraie 'is_referent' class MaraudeurTestCase(TestCase): + maraudeurs = generate_names() + + def create(self): + return Maraudeur.objects.create(**next(self.maraudeurs)) + + def test_email_set_on_creation(self): + m = self.create() + self.assertIsNotNone(m.email) + + def test_username_set_on_creation(self): + m = self.create() + self.assertEqual(m.username, Maraudeur.make_username(m)) + + def test_maraudeurs_is_staff(self): + m = self.create() + self.assertEqual(m.is_staff, True) + + def test_username_set_on_update(self): + m = self.create() + m.last_name = "test01" + m.save() + self.assertEqual(m.username, "%s.test01" % (m.first_name[0])) + +class MaraudeurManagerTestCase(TestCase): + names = [ - ('Arthur', 'Gerbaud'), - ('Thibault', 'Huet'), - ('Jacqueline', 'Julien'), + {'first_name': 'Astérix', 'last_name': 'Devinci'}, + {'first_name': 'Obélix', 'last_name': 'Idéfix'}, ] def setUp(self): for name in self.names: - Maraudeur.objects.create(*name) + Maraudeur.objects.create(**name) def test_get_or_create_from_first_and_last_name(self): # Existing Maraudeur - get_maraudeur = Maraudeur.objects.get(first_name="Thibault", last_name="Huet") - maraudeur, created = Maraudeur.objects.get_or_create('Thibault','Huet') + get_maraudeur = Maraudeur.objects.get(first_name="Obélix", last_name="Idéfix") + maraudeur, created = Maraudeur.objects.get_or_create(first_name='Obélix', last_name='Idéfix') self.assertEqual(created, False) self.assertEqual(maraudeur, get_maraudeur) # Non-existing Maraudeur with self.assertRaises(Maraudeur.DoesNotExist): Maraudeur.objects.get(first_name="Thierry", last_name="Lhermitte") - maraudeur, created = Maraudeur.objects.get_or_create('Thierry', 'Lhermitte') + maraudeur, created = Maraudeur.objects.get_or_create(first_name='Thierry', last_name='Lhermitte') self.assertEqual(created, True) self.assertEqual(maraudeur, Maraudeur.objects.get(username="t.lhermitte")) @@ -37,7 +68,7 @@ class MaraudeurTestCase(TestCase): self.assertEqual(referent1.is_superuser, True) self.assertEqual(referent1, Maraudeur.objects.get_referent()) # Set a new referent, existing Maraudeur - referent2 = Maraudeur.objects.set_referent("Arthur", "Gerbaud") + referent2 = Maraudeur.objects.set_referent("Astérix", "Devinci") self.assertEqual(referent2.is_superuser, True) self.assertEqual(referent2, Maraudeur.objects.get_referent()) self.test_referent_is_unique() diff --git a/utilisateurs/views.py b/utilisateurs/views.py index 3e381f4..2886a84 100644 --- a/utilisateurs/views.py +++ b/utilisateurs/views.py @@ -1,10 +1,9 @@ from django.views import generic -from .apps import utilisateurs from .models import Professionnel +from .mixins import MaraudeurMixin -@utilisateurs -class UtilisateurView(generic.DetailView): +class UtilisateurView(MaraudeurMixin, generic.DetailView): template_name = "utilisateurs/details.html" model = Professionnel diff --git a/website/__init__.py b/website/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/website/admin.py b/website/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/website/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/website/backends.py b/website/backends.py deleted file mode 100644 index da822d4..0000000 --- a/website/backends.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.contrib.auth.backends import ModelBackend - -from utilisateurs.models import Maraudeur - - -def user_models(): - return (Maraudeur,) - -class MyBackend(ModelBackend): - - def get_user(self, user_id): - """ Essaye de récupérer une classe enfant de User existante, telle que - définie dans 'utilisateurs.models'. Fallback to default user. - """ - for user_model in user_models(): - try: - return user_model.objects.get(pk=user_id) - except user_model.DoesNotExist: - print('Tried %s.' % user_model.__class__) - return super().get_user(user_id) - - def has_perm(self, *args, **kwargs): - print('call has_perm', args, kwargs) - return super().has_perm(*args, **kwargs) diff --git a/website/context_processors.py b/website/context_processors.py new file mode 100644 index 0000000..c5c2282 --- /dev/null +++ b/website/context_processors.py @@ -0,0 +1,5 @@ +from django.utils import timezone as tz + +def website_processor(request): + return {'date': tz.now().date} + diff --git a/website/decorators.py b/website/decorators.py deleted file mode 100644 index 2a8db32..0000000 --- a/website/decorators.py +++ /dev/null @@ -1,93 +0,0 @@ -from .mixins import (WebsiteTemplateMixin, WebsiteAjaxTemplateMixin, - SpecialUserRequiredMixin) -from .navbar import ApplicationMenu - - - -def _insert_bases(cls, bases): - """ Insert new bases in given view class """ - old_bases = cls.__bases__ - new_bases = tuple(bases) + old_bases - cls.__bases__ = new_bases - - - -class Webpage: - """ Webpage configurator. It is used as a decorator. - - The constructor takes one positionnal argument: - - app_name : name of the application where this view shall be categorized. - and keyword arguments: - - defaults : mapping of default options. - - menu : does it register a menu ? default is True - - icon : bootstrap name of menu header icon, ignored if 'menu' is False. - - Options are : - - title: tuple of (header, header_small), header_small is optionnal. - - restricted: set of group to which access is restricted. - - ajax: can this view be called as ajax ? - - """ - - options = [ - ('title', ('Unset', 'small header')), - ('restricted', []), - ('ajax', False) - ] - - def __init__(self, app_name, icon=None, defaults={}, menu=True): - self.app_name = app_name - - if menu: # Build ApplicationMenu subclass - app_menu = type( - app_name.title() + "Menu", - (ApplicationMenu,), - {'name': app_name, - 'header': (app_name.title(), '%s:index' % app_name, icon), - '_links': [], - '_dropdowns': [], - } - ) - self.app_menu = app_menu - else: - self.app_menu = None - - self._defaults = {} - self._updated = {} # Store updated options - # Set all default options - for opt_name, opt_default in self.options: - self._set_option(opt_name, defaults.get(opt_name, opt_default)) - - def __getattr__(self, attr): - """ Return the overriden value if any, default overwise """ - return self._updated.get(attr, self._defaults[attr]) - - def _set_option(self, attr, value): - """ Set the default value if there is none already, updated overwise """ - if not attr in self._defaults: - self._defaults[attr] = value - else: - if attr in self._updated: - raise RuntimeError(attr, 'has already been updated !') - self._updated[attr] = value - - def __call__(self, view_cls): - """ Setup the view and return it """ - bases_to_add = [] - if self.ajax: bases_to_add.append(WebsiteAjaxTemplateMixin) - else: bases_to_add.append(WebsiteTemplateMixin) - if self.restricted: bases_to_add.append(SpecialUserRequiredMixin) - _insert_bases(view_cls, bases_to_add) - # Setup configuration. ISSUE: defaults values will be overriden ! - view_cls.app_name = self.app_name - view_cls.header = self.title - view_cls.app_users = self.restricted - self._updated = {} # Reset updated attributes to avoid misbehavior - return view_cls - - def using(self, **kwargs): - """ Overrides defaults options with the values given """ - for opt_name, _ in self.options: - if opt_name in kwargs: - self._set_option(opt_name, kwargs[opt_name]) - return self diff --git a/website/mixins.py b/website/mixins.py index de51906..3fabe3a 100644 --- a/website/mixins.py +++ b/website/mixins.py @@ -1,102 +1,13 @@ -from django.core.exceptions import ImproperlyConfigured -from django.contrib.auth.decorators import user_passes_test -from django.template import Template, Context -from django.views.generic.base import TemplateResponseMixin -## Mixins ## - - -class SpecialUserRequiredMixin(object): - """ Requires that the User is an instance of some class """ - app_users = [] - - @classmethod - def as_view(cls, **initkwargs): - view = super().as_view(**initkwargs) - return cls.special_user_required(cls.app_users)(view) - - @staticmethod - def special_user_required(authorized_users): - valid_cls = tuple(authorized_users) - if not valid_cls: # No restriction usually means misconfiguration ! - raise ImproperlyConfigured( -'A view was configured as "restricted" with no restricting parameters !') - - def check_special_user(user): - if isinstance(user, valid_cls): - return True - else: - return False - return user_passes_test(check_special_user) - - -def user_processor(request, context): - context['user_group'] = request.user.__class__.__qualname__ - return context - -def header_processor(header, context): - context['page_header'] = Template(header[0]).render(context) - context['page_header_small'] = Template(header[1]).render(context) if len(header) == 2 else '' - context['page_title'] = " - ".join((context['page_header'], context['page_header_small'])) - return context - - - -class WebsiteTemplateMixin(TemplateResponseMixin): - """ Mixin for easy integration of 'website' templates - - If 'content_template' is not defined, value will fallback to template_name - in child view. - """ - base_template = "base_site.html" - content_template = None - app_name = None - - class Configuration: - stylesheets = ['css/base.css'] - - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.user = None - - def get_template_names(self): - """ Ensure same template for all children views. """ - return [self.base_template] - - def get_content_template(self): - # Ensure easy integration with generic views - if hasattr(self, 'template_name'): - self.content_template = self.template_name - else: - raise ImproperlyConfigured(self, "has no template defined !") - return self.content_template - - def get_context_data(self, **kwargs): - context = Context(super().get_context_data(**kwargs)) - #Website processor - context['stylesheets'] = self.Configuration.stylesheets - context['active_app'] = self.app_name # Set by Webpage decorator - # User processor - context = header_processor(self.header, context) - context = user_processor(self.request, context) - #Webpage - context['content_template'] = self.get_content_template() - return context - - - -class WebsiteAjaxTemplateMixin(WebsiteTemplateMixin): +class AjaxTemplateMixin: """ Mixin that returns content_template instead of base_template when request is Ajax. """ is_ajax = False def dispatch(self, request, *args, **kwargs): - if not hasattr(self, 'content_template') or not self.content_template: - self.content_template = self.get_content_template() if not hasattr(self, 'ajax_template'): - self.ajax_template = '%s_inner.html' % self.content_template.split(".")[0] + self.ajax_template = '%s_inner.html' % self.template_name.split(".")[0] if request.is_ajax(): self.is_ajax = True return super().dispatch(request, *args, **kwargs) diff --git a/website/models.py b/website/models.py deleted file mode 100644 index 71a8362..0000000 --- a/website/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/website/navbar.py b/website/navbar.py deleted file mode 100644 index 6fadd2d..0000000 --- a/website/navbar.py +++ /dev/null @@ -1,102 +0,0 @@ -""" Draft for navbar application menu -""" - -from django.urls import reverse - -registered = [] - - -class Link: - """ Navbar link - - Constructor takes one required argument : - - text : text to display for the link - - and two optional arguments : - - target : str or tuple (str, dict) - - icon : bootstrap icon name, no validation - - """ - - def __init__(self, text, target="#", icon=None): - self.text = text - self._target = target - self.icon = icon - - @property - def href(self): - """ Lazy creation of html 'href' value, using 'target' instance attribute """ - if not hasattr(self, '_href'): - if self._target == "#": #Create void link - self._href = "#" - else: - try: - target, kwargs = self._target - except ValueError: - target = self._target - kwargs = {} - assert type(target) == str - assert type(kwargs) == dict - self._href = reverse(target, **kwargs) - return self._href - - - -class LinkManager: - """ Per-class manager of links """ - - def __init__(self): - self.items = [] - - def __get__(self, instance, owner): - if instance: #Filtering done at each call, not optimized at all ! - if not instance.user.is_superuser: - return [link for link in self.items if link.admin_link == False] - else: - return self.items.copy() - return self - - def add(self, link): - self.items.append(link) - - def __repr__(self): - return '' - - - -class MenuRegistry(type): - """ Metaclass that registers subclass into module level variable 'registered' """ - def __new__(metacls, name, bases, attrs): - cls = type.__new__(metacls, name, bases, attrs) - if name != "ApplicationMenu": - print('registering menu', cls) - registered.append(cls) - # Create Link instance for header attributes - try: - header, target, icon = cls.header - except ValueError: - header = cls.header - target = "#" - icon = None - cls.header = Link(header, target, icon) - cls.links = LinkManager() - return cls - - - -class ApplicationMenu(metaclass=MenuRegistry): - name = None - header = None - - def __init__(self, view, user): - self.view = view - self.user = user - self.is_active = self.name == self.view.app_name - - @classmethod - def add_link(cls, link, admin=False): - if not isinstance(link, Link): - link = Link(*link) - link.admin_link = admin - cls.links.add(link) - diff --git a/website/static/css/base.css b/website/static/css/base.css index 3d7992a..26efba3 100644 --- a/website/static/css/base.css +++ b/website/static/css/base.css @@ -1,32 +1,127 @@ - -.navbar-fixed-side .navbar-nav>li>a { - border-bottom: none; - font-variant: small-caps; - color: #fff; +body { + padding: 0px 0 10px 0; } -#menu { - border: none; - border-right: 4px solid #980300; - background-color: #121212; +#page-header { + font-size:1.1em; + font-weight: bold; } -@media (max-width:768px){ - #menu { border: none; } +.navbar-text.breadcrumb { + padding: 0px; + margin-bottom: 0px; } -.app-menu { - background-color: #121212; - border: none; +.navbar-text.breadcrumb > li { + font-size: 1.2em; +} + +.navbar-text.breadcrumb > li > a { + color: #e2f2f2; +} + +.navbar-text.breadcrumb > li > a:hover { + color: #d9230f; +} + +/* Admin overrides */ + +#content-related { + margin-right: 0px !important; } -.active{ - border-right: 2px solid #980300 !important; +/* Bootstrap Navbar custom */ + +.navbar-default { + background-color: #2e2f2f; + border-color: #a91b0c; +} +.navbar-default .navbar-brand { + color: #f6f6f6; +} +.navbar-default .navbar-brand:hover, +.navbar-default .navbar-brand:focus { + color: #ffffff; +} +.navbar-default .navbar-text { + color: #f6f6f6; +} +.navbar-default .navbar-nav > li > a { + color: #f6f6f6; + font-weight: bold; + font-size: 1.1em; + min-height: 45px; +} +.navbar-default .navbar-nav > li > a:hover, +.navbar-default .navbar-nav > li > a:focus { + color: #ffffff; +} +.navbar-default .navbar-nav > li > .dropdown-menu { + background-color: #2e2f2f; +} +.navbar-default .navbar-nav > li > .dropdown-menu > li > a { + color: #f6f6f6; +} +.navbar-default .navbar-nav > li > .dropdown-menu > li > a:hover, +.navbar-default .navbar-nav > li > .dropdown-menu > li > a:focus { + color: #ffffff; + background-color: #d2220f; +} +.navbar-default .navbar-nav > li > .dropdown-menu > li > .divider { + background-color: #d2220f; +} +.navbar-default .navbar-nav .open .dropdown-menu > .active > a, +.navbar-default .navbar-nav .open .dropdown-menu > .active > a:hover, +.navbar-default .navbar-nav .open .dropdown-menu > .active > a:focus { + color: #ffffff; + background-color: #d2220f; +} +.navbar-default .navbar-nav > .active > a, +.navbar-default .navbar-nav > .active > a:hover, +.navbar-default .navbar-nav > .active > a:focus { + color: #ffffff; + background-color: #d2220f; +} +.navbar-default .navbar-nav > .open > a, +.navbar-default .navbar-nav > .open > a:hover, +.navbar-default .navbar-nav > .open > a:focus { + color: #ffffff; + background-color: #d2220f; +} +.navbar-default .navbar-toggle { + border-color: #d2220f; +} +.navbar-default .navbar-toggle:hover, +.navbar-default .navbar-toggle:focus { + background-color: #d2220f; +} +.navbar-default .navbar-toggle .icon-bar { + background-color: #f6f6f6; +} +.navbar-default .navbar-collapse, +.navbar-default .navbar-form { + border-color: #f6f6f6; +} +.navbar-default .navbar-link { + color: #f6f6f6; +} +.navbar-default .navbar-link:hover { + color: #ffffff; } -.dropdown-menu { - border-bottom: none !important; +@media (max-width: 767px) { + .navbar-default .navbar-nav .open .dropdown-menu > li > a { + color: #f6f6f6; + } + .navbar-default .navbar-nav .open .dropdown-menu > li > a:hover, + .navbar-default .navbar-nav .open .dropdown-menu > li > a:focus { + color: #ffffff; + } + .navbar-default .navbar-nav .open .dropdown-menu > .active > a, + .navbar-default .navbar-nav .open .dropdown-menu > .active > a:hover, + .navbar-default .navbar-nav .open .dropdown-menu > .active > a:focus { + color: #ffffff; + background-color: #d2220f; + } } - - diff --git a/website/static/css/bootstrap/config.json b/website/static/css/bootstrap/config.json index 900e79e..7f2ac8e 100644 --- a/website/static/css/bootstrap/config.json +++ b/website/static/css/bootstrap/config.json @@ -432,4 +432,4 @@ "transition.js" ], "customizerUrl": "http://getbootstrap.com/customize/?id=7f853f3d936c9ba68499a06009229bc9" -} \ No newline at end of file +} diff --git a/website/static/scripts/bootstrap-modal.js b/website/static/scripts/bootstrap-modal.js index 70cf67e..38c635f 100644 --- a/website/static/scripts/bootstrap-modal.js +++ b/website/static/scripts/bootstrap-modal.js @@ -22,7 +22,8 @@ } }, error: function (xhr, ajaxOptions, thrownError) { - // handle response errors here + console.log("Error with ajax request : "); + console.log(thrownError); } }); }); diff --git a/website/static/scripts/jquery.flot.min.js b/website/static/scripts/jquery.flot.min.js new file mode 100644 index 0000000..968d3eb --- /dev/null +++ b/website/static/scripts/jquery.flot.min.js @@ -0,0 +1,8 @@ +/* Javascript plotting library for jQuery, version 0.8.3. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +*/ +(function($){$.color={};$.color.make=function(r,g,b,a){var o={};o.r=r||0;o.g=g||0;o.b=b||0;o.a=a!=null?a:1;o.add=function(c,d){for(var i=0;i=1){return"rgb("+[o.r,o.g,o.b].join(",")+")"}else{return"rgba("+[o.r,o.g,o.b,o.a].join(",")+")"}};o.normalize=function(){function clamp(min,value,max){return valuemax?max:value}o.r=clamp(0,parseInt(o.r),255);o.g=clamp(0,parseInt(o.g),255);o.b=clamp(0,parseInt(o.b),255);o.a=clamp(0,o.a,1);return o};o.clone=function(){return $.color.make(o.r,o.b,o.g,o.a)};return o.normalize()};$.color.extract=function(elem,css){var c;do{c=elem.css(css).toLowerCase();if(c!=""&&c!="transparent")break;elem=elem.parent()}while(elem.length&&!$.nodeName(elem.get(0),"body"));if(c=="rgba(0, 0, 0, 0)")c="transparent";return $.color.parse(c)};$.color.parse=function(str){var res,m=$.color.make;if(res=/rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(str))return m(parseInt(res[1],10),parseInt(res[2],10),parseInt(res[3],10));if(res=/rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str))return m(parseInt(res[1],10),parseInt(res[2],10),parseInt(res[3],10),parseFloat(res[4]));if(res=/rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(str))return m(parseFloat(res[1])*2.55,parseFloat(res[2])*2.55,parseFloat(res[3])*2.55);if(res=/rgba\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str))return m(parseFloat(res[1])*2.55,parseFloat(res[2])*2.55,parseFloat(res[3])*2.55,parseFloat(res[4]));if(res=/#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(str))return m(parseInt(res[1],16),parseInt(res[2],16),parseInt(res[3],16));if(res=/#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(str))return m(parseInt(res[1]+res[1],16),parseInt(res[2]+res[2],16),parseInt(res[3]+res[3],16));var name=$.trim(str).toLowerCase();if(name=="transparent")return m(255,255,255,0);else{res=lookupColors[name]||[0,0,0];return m(res[0],res[1],res[2])}};var lookupColors={aqua:[0,255,255],azure:[240,255,255],beige:[245,245,220],black:[0,0,0],blue:[0,0,255],brown:[165,42,42],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgrey:[169,169,169],darkgreen:[0,100,0],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkviolet:[148,0,211],fuchsia:[255,0,255],gold:[255,215,0],green:[0,128,0],indigo:[75,0,130],khaki:[240,230,140],lightblue:[173,216,230],lightcyan:[224,255,255],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightyellow:[255,255,224],lime:[0,255,0],magenta:[255,0,255],maroon:[128,0,0],navy:[0,0,128],olive:[128,128,0],orange:[255,165,0],pink:[255,192,203],purple:[128,0,128],violet:[128,0,128],red:[255,0,0],silver:[192,192,192],white:[255,255,255],yellow:[255,255,0]}})(jQuery);(function($){var hasOwnProperty=Object.prototype.hasOwnProperty;if(!$.fn.detach){$.fn.detach=function(){return this.each(function(){if(this.parentNode){this.parentNode.removeChild(this)}})}}function Canvas(cls,container){var element=container.children("."+cls)[0];if(element==null){element=document.createElement("canvas");element.className=cls;$(element).css({direction:"ltr",position:"absolute",left:0,top:0}).appendTo(container);if(!element.getContext){if(window.G_vmlCanvasManager){element=window.G_vmlCanvasManager.initElement(element)}else{throw new Error("Canvas is not available. If you're using IE with a fall-back such as Excanvas, then there's either a mistake in your conditional include, or the page has no DOCTYPE and is rendering in Quirks Mode.")}}}this.element=element;var context=this.context=element.getContext("2d");var devicePixelRatio=window.devicePixelRatio||1,backingStoreRatio=context.webkitBackingStorePixelRatio||context.mozBackingStorePixelRatio||context.msBackingStorePixelRatio||context.oBackingStorePixelRatio||context.backingStorePixelRatio||1;this.pixelRatio=devicePixelRatio/backingStoreRatio;this.resize(container.width(),container.height());this.textContainer=null;this.text={};this._textCache={}}Canvas.prototype.resize=function(width,height){if(width<=0||height<=0){throw new Error("Invalid dimensions for plot, width = "+width+", height = "+height)}var element=this.element,context=this.context,pixelRatio=this.pixelRatio;if(this.width!=width){element.width=width*pixelRatio;element.style.width=width+"px";this.width=width}if(this.height!=height){element.height=height*pixelRatio;element.style.height=height+"px";this.height=height}context.restore();context.save();context.scale(pixelRatio,pixelRatio)};Canvas.prototype.clear=function(){this.context.clearRect(0,0,this.width,this.height)};Canvas.prototype.render=function(){var cache=this._textCache;for(var layerKey in cache){if(hasOwnProperty.call(cache,layerKey)){var layer=this.getTextLayer(layerKey),layerCache=cache[layerKey];layer.hide();for(var styleKey in layerCache){if(hasOwnProperty.call(layerCache,styleKey)){var styleCache=layerCache[styleKey];for(var key in styleCache){if(hasOwnProperty.call(styleCache,key)){var positions=styleCache[key].positions;for(var i=0,position;position=positions[i];i++){if(position.active){if(!position.rendered){layer.append(position.element);position.rendered=true}}else{positions.splice(i--,1);if(position.rendered){position.element.detach()}}}if(positions.length==0){delete styleCache[key]}}}}}layer.show()}}};Canvas.prototype.getTextLayer=function(classes){var layer=this.text[classes];if(layer==null){if(this.textContainer==null){this.textContainer=$("
    ").css({position:"absolute",top:0,left:0,bottom:0,right:0,"font-size":"smaller",color:"#545454"}).insertAfter(this.element)}layer=this.text[classes]=$("
    ").addClass(classes).css({position:"absolute",top:0,left:0,bottom:0,right:0}).appendTo(this.textContainer)}return layer};Canvas.prototype.getTextInfo=function(layer,text,font,angle,width){var textStyle,layerCache,styleCache,info;text=""+text;if(typeof font==="object"){textStyle=font.style+" "+font.variant+" "+font.weight+" "+font.size+"px/"+font.lineHeight+"px "+font.family}else{textStyle=font}layerCache=this._textCache[layer];if(layerCache==null){layerCache=this._textCache[layer]={}}styleCache=layerCache[textStyle];if(styleCache==null){styleCache=layerCache[textStyle]={}}info=styleCache[text];if(info==null){var element=$("
    ").html(text).css({position:"absolute","max-width":width,top:-9999}).appendTo(this.getTextLayer(layer));if(typeof font==="object"){element.css({font:textStyle,color:font.color})}else if(typeof font==="string"){element.addClass(font)}info=styleCache[text]={width:element.outerWidth(true),height:element.outerHeight(true),element:element,positions:[]};element.detach()}return info};Canvas.prototype.addText=function(layer,x,y,text,font,angle,width,halign,valign){var info=this.getTextInfo(layer,text,font,angle,width),positions=info.positions;if(halign=="center"){x-=info.width/2}else if(halign=="right"){x-=info.width}if(valign=="middle"){y-=info.height/2}else if(valign=="bottom"){y-=info.height}for(var i=0,position;position=positions[i];i++){if(position.x==x&&position.y==y){position.active=true;return}}position={active:true,rendered:false,element:positions.length?info.element.clone():info.element,x:x,y:y};positions.push(position);position.element.css({top:Math.round(y),left:Math.round(x),"text-align":halign})};Canvas.prototype.removeText=function(layer,x,y,text,font,angle){if(text==null){var layerCache=this._textCache[layer];if(layerCache!=null){for(var styleKey in layerCache){if(hasOwnProperty.call(layerCache,styleKey)){var styleCache=layerCache[styleKey];for(var key in styleCache){if(hasOwnProperty.call(styleCache,key)){var positions=styleCache[key].positions;for(var i=0,position;position=positions[i];i++){position.active=false}}}}}}}else{var positions=this.getTextInfo(layer,text,font,angle).positions;for(var i=0,position;position=positions[i];i++){if(position.x==x&&position.y==y){position.active=false}}}};function Plot(placeholder,data_,options_,plugins){var series=[],options={colors:["#edc240","#afd8f8","#cb4b4b","#4da74d","#9440ed"],legend:{show:true,noColumns:1,labelFormatter:null,labelBoxBorderColor:"#ccc",container:null,position:"ne",margin:5,backgroundColor:null,backgroundOpacity:.85,sorted:null},xaxis:{show:null,position:"bottom",mode:null,font:null,color:null,tickColor:null,transform:null,inverseTransform:null,min:null,max:null,autoscaleMargin:null,ticks:null,tickFormatter:null,labelWidth:null,labelHeight:null,reserveSpace:null,tickLength:null,alignTicksWithAxis:null,tickDecimals:null,tickSize:null,minTickSize:null},yaxis:{autoscaleMargin:.02,position:"left"},xaxes:[],yaxes:[],series:{points:{show:false,radius:3,lineWidth:2,fill:true,fillColor:"#ffffff",symbol:"circle"},lines:{lineWidth:2,fill:false,fillColor:null,steps:false},bars:{show:false,lineWidth:2,barWidth:1,fill:true,fillColor:null,align:"left",horizontal:false,zero:true},shadowSize:3,highlightColor:null},grid:{show:true,aboveData:false,color:"#545454",backgroundColor:null,borderColor:null,tickColor:null,margin:0,labelMargin:5,axisMargin:8,borderWidth:2,minBorderMargin:null,markings:null,markingsColor:"#f4f4f4",markingsLineWidth:2,clickable:false,hoverable:false,autoHighlight:true,mouseActiveRadius:10},interaction:{redrawOverlayInterval:1e3/60},hooks:{}},surface=null,overlay=null,eventHolder=null,ctx=null,octx=null,xaxes=[],yaxes=[],plotOffset={left:0,right:0,top:0,bottom:0},plotWidth=0,plotHeight=0,hooks={processOptions:[],processRawData:[],processDatapoints:[],processOffset:[],drawBackground:[],drawSeries:[],draw:[],bindEvents:[],drawOverlay:[],shutdown:[]},plot=this;plot.setData=setData;plot.setupGrid=setupGrid;plot.draw=draw;plot.getPlaceholder=function(){return placeholder};plot.getCanvas=function(){return surface.element};plot.getPlotOffset=function(){return plotOffset};plot.width=function(){return plotWidth};plot.height=function(){return plotHeight};plot.offset=function(){var o=eventHolder.offset();o.left+=plotOffset.left;o.top+=plotOffset.top;return o};plot.getData=function(){return series};plot.getAxes=function(){var res={},i;$.each(xaxes.concat(yaxes),function(_,axis){if(axis)res[axis.direction+(axis.n!=1?axis.n:"")+"axis"]=axis});return res};plot.getXAxes=function(){return xaxes};plot.getYAxes=function(){return yaxes};plot.c2p=canvasToAxisCoords;plot.p2c=axisToCanvasCoords;plot.getOptions=function(){return options};plot.highlight=highlight;plot.unhighlight=unhighlight;plot.triggerRedrawOverlay=triggerRedrawOverlay;plot.pointOffset=function(point){return{left:parseInt(xaxes[axisNumber(point,"x")-1].p2c(+point.x)+plotOffset.left,10),top:parseInt(yaxes[axisNumber(point,"y")-1].p2c(+point.y)+plotOffset.top,10)}};plot.shutdown=shutdown;plot.destroy=function(){shutdown();placeholder.removeData("plot").empty();series=[];options=null;surface=null;overlay=null;eventHolder=null;ctx=null;octx=null;xaxes=[];yaxes=[];hooks=null;highlights=[];plot=null};plot.resize=function(){var width=placeholder.width(),height=placeholder.height();surface.resize(width,height);overlay.resize(width,height)};plot.hooks=hooks;initPlugins(plot);parseOptions(options_);setupCanvases();setData(data_);setupGrid();draw();bindEvents();function executeHooks(hook,args){args=[plot].concat(args);for(var i=0;imaxIndex){maxIndex=sc}}}if(neededColors<=maxIndex){neededColors=maxIndex+1}var c,colors=[],colorPool=options.colors,colorPoolSize=colorPool.length,variation=0;for(i=0;i=0){if(variation<.5){variation=-variation-.2}else variation=0}else variation=-variation}colors[i]=c.scale("rgb",1+variation)}var colori=0,s;for(i=0;iaxis.datamax&&max!=fakeInfinity)axis.datamax=max}$.each(allAxes(),function(_,axis){axis.datamin=topSentry;axis.datamax=bottomSentry;axis.used=false});for(i=0;i0&&points[k-ps]!=null&&points[k-ps]!=points[k]&&points[k-ps+1]!=points[k+1]){for(m=0;mxmax)xmax=val}if(f.y){if(valymax)ymax=val}}}if(s.bars.show){var delta;switch(s.bars.align){case"left":delta=0;break;case"right":delta=-s.bars.barWidth;break;default:delta=-s.bars.barWidth/2}if(s.bars.horizontal){ymin+=delta;ymax+=delta+s.bars.barWidth}else{xmin+=delta;xmax+=delta+s.bars.barWidth}}updateAxis(s.xaxis,xmin,xmax);updateAxis(s.yaxis,ymin,ymax)}$.each(allAxes(),function(_,axis){if(axis.datamin==topSentry)axis.datamin=null;if(axis.datamax==bottomSentry)axis.datamax=null})}function setupCanvases(){placeholder.css("padding",0).children().filter(function(){return!$(this).hasClass("flot-overlay")&&!$(this).hasClass("flot-base")}).remove();if(placeholder.css("position")=="static")placeholder.css("position","relative");surface=new Canvas("flot-base",placeholder);overlay=new Canvas("flot-overlay",placeholder);ctx=surface.context;octx=overlay.context;eventHolder=$(overlay.element).unbind();var existing=placeholder.data("plot");if(existing){existing.shutdown();overlay.clear()}placeholder.data("plot",plot)}function bindEvents(){if(options.grid.hoverable){eventHolder.mousemove(onMouseMove);eventHolder.bind("mouseleave",onMouseLeave)}if(options.grid.clickable)eventHolder.click(onClick);executeHooks(hooks.bindEvents,[eventHolder])}function shutdown(){if(redrawTimeout)clearTimeout(redrawTimeout);eventHolder.unbind("mousemove",onMouseMove);eventHolder.unbind("mouseleave",onMouseLeave);eventHolder.unbind("click",onClick);executeHooks(hooks.shutdown,[eventHolder])}function setTransformationHelpers(axis){function identity(x){return x}var s,m,t=axis.options.transform||identity,it=axis.options.inverseTransform;if(axis.direction=="x"){s=axis.scale=plotWidth/Math.abs(t(axis.max)-t(axis.min));m=Math.min(t(axis.max),t(axis.min))}else{s=axis.scale=plotHeight/Math.abs(t(axis.max)-t(axis.min));s=-s;m=Math.max(t(axis.max),t(axis.min))}if(t==identity)axis.p2c=function(p){return(p-m)*s};else axis.p2c=function(p){return(t(p)-m)*s};if(!it)axis.c2p=function(c){return m+c/s};else axis.c2p=function(c){return it(m+c/s)}}function measureTickLabels(axis){var opts=axis.options,ticks=axis.ticks||[],labelWidth=opts.labelWidth||0,labelHeight=opts.labelHeight||0,maxWidth=labelWidth||(axis.direction=="x"?Math.floor(surface.width/(ticks.length||1)):null),legacyStyles=axis.direction+"Axis "+axis.direction+axis.n+"Axis",layer="flot-"+axis.direction+"-axis flot-"+axis.direction+axis.n+"-axis "+legacyStyles,font=opts.font||"flot-tick-label tickLabel";for(var i=0;i=0;--i)allocateAxisBoxFirstPhase(allocatedAxes[i]);adjustLayoutForThingsStickingOut();$.each(allocatedAxes,function(_,axis){allocateAxisBoxSecondPhase(axis)})}plotWidth=surface.width-plotOffset.left-plotOffset.right;plotHeight=surface.height-plotOffset.bottom-plotOffset.top;$.each(axes,function(_,axis){setTransformationHelpers(axis)});if(showGrid){drawAxisLabels()}insertLegend()}function setRange(axis){var opts=axis.options,min=+(opts.min!=null?opts.min:axis.datamin),max=+(opts.max!=null?opts.max:axis.datamax),delta=max-min;if(delta==0){var widen=max==0?1:.01;if(opts.min==null)min-=widen;if(opts.max==null||opts.min!=null)max+=widen}else{var margin=opts.autoscaleMargin;if(margin!=null){if(opts.min==null){min-=delta*margin;if(min<0&&axis.datamin!=null&&axis.datamin>=0)min=0}if(opts.max==null){max+=delta*margin;if(max>0&&axis.datamax!=null&&axis.datamax<=0)max=0}}}axis.min=min;axis.max=max}function setupTickGeneration(axis){var opts=axis.options;var noTicks;if(typeof opts.ticks=="number"&&opts.ticks>0)noTicks=opts.ticks;else noTicks=.3*Math.sqrt(axis.direction=="x"?surface.width:surface.height);var delta=(axis.max-axis.min)/noTicks,dec=-Math.floor(Math.log(delta)/Math.LN10),maxDec=opts.tickDecimals;if(maxDec!=null&&dec>maxDec){dec=maxDec}var magn=Math.pow(10,-dec),norm=delta/magn,size;if(norm<1.5){size=1}else if(norm<3){size=2;if(norm>2.25&&(maxDec==null||dec+1<=maxDec)){size=2.5;++dec}}else if(norm<7.5){size=5}else{size=10}size*=magn;if(opts.minTickSize!=null&&size0){if(opts.min==null)axis.min=Math.min(axis.min,niceTicks[0]);if(opts.max==null&&niceTicks.length>1)axis.max=Math.max(axis.max,niceTicks[niceTicks.length-1])}axis.tickGenerator=function(axis){var ticks=[],v,i;for(i=0;i1&&/\..*0$/.test((ts[1]-ts[0]).toFixed(extraDec))))axis.tickDecimals=extraDec}}}}function setTicks(axis){var oticks=axis.options.ticks,ticks=[];if(oticks==null||typeof oticks=="number"&&oticks>0)ticks=axis.tickGenerator(axis);else if(oticks){if($.isFunction(oticks))ticks=oticks(axis);else ticks=oticks}var i,v;axis.ticks=[];for(i=0;i1)label=t[1]}else v=+t;if(label==null)label=axis.tickFormatter(v,axis);if(!isNaN(v))axis.ticks.push({v:v,label:label})}}function snapRangeToTicks(axis,ticks){if(axis.options.autoscaleMargin&&ticks.length>0){if(axis.options.min==null)axis.min=Math.min(axis.min,ticks[0].v);if(axis.options.max==null&&ticks.length>1)axis.max=Math.max(axis.max,ticks[ticks.length-1].v)}}function draw(){surface.clear();executeHooks(hooks.drawBackground,[ctx]);var grid=options.grid;if(grid.show&&grid.backgroundColor)drawBackground();if(grid.show&&!grid.aboveData){drawGrid()}for(var i=0;ito){var tmp=from;from=to;to=tmp}return{from:from,to:to,axis:axis}}function drawBackground(){ctx.save();ctx.translate(plotOffset.left,plotOffset.top);ctx.fillStyle=getColorOrGradient(options.grid.backgroundColor,plotHeight,0,"rgba(255, 255, 255, 0)");ctx.fillRect(0,0,plotWidth,plotHeight);ctx.restore()}function drawGrid(){var i,axes,bw,bc;ctx.save();ctx.translate(plotOffset.left,plotOffset.top);var markings=options.grid.markings;if(markings){if($.isFunction(markings)){axes=plot.getAxes();axes.xmin=axes.xaxis.min;axes.xmax=axes.xaxis.max;axes.ymin=axes.yaxis.min;axes.ymax=axes.yaxis.max;markings=markings(axes)}for(i=0;ixrange.axis.max||yrange.toyrange.axis.max)continue;xrange.from=Math.max(xrange.from,xrange.axis.min);xrange.to=Math.min(xrange.to,xrange.axis.max);yrange.from=Math.max(yrange.from,yrange.axis.min);yrange.to=Math.min(yrange.to,yrange.axis.max);var xequal=xrange.from===xrange.to,yequal=yrange.from===yrange.to;if(xequal&&yequal){continue}xrange.from=Math.floor(xrange.axis.p2c(xrange.from));xrange.to=Math.floor(xrange.axis.p2c(xrange.to));yrange.from=Math.floor(yrange.axis.p2c(yrange.from));yrange.to=Math.floor(yrange.axis.p2c(yrange.to));if(xequal||yequal){var lineWidth=m.lineWidth||options.grid.markingsLineWidth,subPixel=lineWidth%2?.5:0;ctx.beginPath();ctx.strokeStyle=m.color||options.grid.markingsColor;ctx.lineWidth=lineWidth;if(xequal){ctx.moveTo(xrange.to+subPixel,yrange.from);ctx.lineTo(xrange.to+subPixel,yrange.to)}else{ctx.moveTo(xrange.from,yrange.to+subPixel);ctx.lineTo(xrange.to,yrange.to+subPixel)}ctx.stroke()}else{ctx.fillStyle=m.color||options.grid.markingsColor;ctx.fillRect(xrange.from,yrange.to,xrange.to-xrange.from,yrange.from-yrange.to)}}}axes=allAxes();bw=options.grid.borderWidth;for(var j=0;jaxis.max||t=="full"&&(typeof bw=="object"&&bw[axis.position]>0||bw>0)&&(v==axis.min||v==axis.max))continue;if(axis.direction=="x"){x=axis.p2c(v);yoff=t=="full"?-plotHeight:t;if(axis.position=="top")yoff=-yoff}else{y=axis.p2c(v);xoff=t=="full"?-plotWidth:t;if(axis.position=="left")xoff=-xoff}if(ctx.lineWidth==1){if(axis.direction=="x")x=Math.floor(x)+.5;else y=Math.floor(y)+.5}ctx.moveTo(x,y);ctx.lineTo(x+xoff,y+yoff)}ctx.stroke()}if(bw){bc=options.grid.borderColor;if(typeof bw=="object"||typeof bc=="object"){if(typeof bw!=="object"){bw={top:bw,right:bw,bottom:bw,left:bw}}if(typeof bc!=="object"){bc={top:bc,right:bc,bottom:bc,left:bc}}if(bw.top>0){ctx.strokeStyle=bc.top;ctx.lineWidth=bw.top;ctx.beginPath();ctx.moveTo(0-bw.left,0-bw.top/2);ctx.lineTo(plotWidth,0-bw.top/2);ctx.stroke()}if(bw.right>0){ctx.strokeStyle=bc.right;ctx.lineWidth=bw.right;ctx.beginPath();ctx.moveTo(plotWidth+bw.right/2,0-bw.top);ctx.lineTo(plotWidth+bw.right/2,plotHeight);ctx.stroke()}if(bw.bottom>0){ctx.strokeStyle=bc.bottom;ctx.lineWidth=bw.bottom;ctx.beginPath();ctx.moveTo(plotWidth+bw.right,plotHeight+bw.bottom/2);ctx.lineTo(0,plotHeight+bw.bottom/2);ctx.stroke()}if(bw.left>0){ctx.strokeStyle=bc.left;ctx.lineWidth=bw.left;ctx.beginPath();ctx.moveTo(0-bw.left/2,plotHeight+bw.bottom);ctx.lineTo(0-bw.left/2,0);ctx.stroke()}}else{ctx.lineWidth=bw;ctx.strokeStyle=options.grid.borderColor;ctx.strokeRect(-bw/2,-bw/2,plotWidth+bw,plotHeight+bw)}}ctx.restore()}function drawAxisLabels(){$.each(allAxes(),function(_,axis){var box=axis.box,legacyStyles=axis.direction+"Axis "+axis.direction+axis.n+"Axis",layer="flot-"+axis.direction+"-axis flot-"+axis.direction+axis.n+"-axis "+legacyStyles,font=axis.options.font||"flot-tick-label tickLabel",tick,x,y,halign,valign;surface.removeText(layer);if(!axis.show||axis.ticks.length==0)return;for(var i=0;iaxis.max)continue;if(axis.direction=="x"){halign="center";x=plotOffset.left+axis.p2c(tick.v);if(axis.position=="bottom"){y=box.top+box.padding}else{y=box.top+box.height-box.padding;valign="bottom"}}else{valign="middle";y=plotOffset.top+axis.p2c(tick.v);if(axis.position=="left"){x=box.left+box.width-box.padding;halign="right"}else{x=box.left+box.padding}}surface.addText(layer,x,y,tick.label,font,null,null,halign,valign)}})}function drawSeries(series){if(series.lines.show)drawSeriesLines(series);if(series.bars.show)drawSeriesBars(series);if(series.points.show)drawSeriesPoints(series)}function drawSeriesLines(series){function plotLine(datapoints,xoffset,yoffset,axisx,axisy){var points=datapoints.points,ps=datapoints.pointsize,prevx=null,prevy=null;ctx.beginPath();for(var i=ps;i=y2&&y1>axisy.max){if(y2>axisy.max)continue;x1=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y1=axisy.max}else if(y2>=y1&&y2>axisy.max){if(y1>axisy.max)continue;x2=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y2=axisy.max}if(x1<=x2&&x1=x2&&x1>axisx.max){if(x2>axisx.max)continue;y1=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x1=axisx.max}else if(x2>=x1&&x2>axisx.max){if(x1>axisx.max)continue;y2=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x2=axisx.max}if(x1!=prevx||y1!=prevy)ctx.moveTo(axisx.p2c(x1)+xoffset,axisy.p2c(y1)+yoffset);prevx=x2;prevy=y2;ctx.lineTo(axisx.p2c(x2)+xoffset,axisy.p2c(y2)+yoffset)}ctx.stroke()}function plotLineArea(datapoints,axisx,axisy){var points=datapoints.points,ps=datapoints.pointsize,bottom=Math.min(Math.max(0,axisy.min),axisy.max),i=0,top,areaOpen=false,ypos=1,segmentStart=0,segmentEnd=0;while(true){if(ps>0&&i>points.length+ps)break;i+=ps;var x1=points[i-ps],y1=points[i-ps+ypos],x2=points[i],y2=points[i+ypos];if(areaOpen){if(ps>0&&x1!=null&&x2==null){segmentEnd=i;ps=-ps;ypos=2;continue}if(ps<0&&i==segmentStart+ps){ctx.fill();areaOpen=false;ps=-ps;ypos=1;i=segmentStart=segmentEnd+ps;continue}}if(x1==null||x2==null)continue;if(x1<=x2&&x1=x2&&x1>axisx.max){if(x2>axisx.max)continue;y1=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x1=axisx.max}else if(x2>=x1&&x2>axisx.max){if(x1>axisx.max)continue;y2=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x2=axisx.max}if(!areaOpen){ctx.beginPath();ctx.moveTo(axisx.p2c(x1),axisy.p2c(bottom));areaOpen=true}if(y1>=axisy.max&&y2>=axisy.max){ctx.lineTo(axisx.p2c(x1),axisy.p2c(axisy.max));ctx.lineTo(axisx.p2c(x2),axisy.p2c(axisy.max));continue}else if(y1<=axisy.min&&y2<=axisy.min){ctx.lineTo(axisx.p2c(x1),axisy.p2c(axisy.min));ctx.lineTo(axisx.p2c(x2),axisy.p2c(axisy.min));continue}var x1old=x1,x2old=x2;if(y1<=y2&&y1=axisy.min){x1=(axisy.min-y1)/(y2-y1)*(x2-x1)+x1;y1=axisy.min}else if(y2<=y1&&y2=axisy.min){x2=(axisy.min-y1)/(y2-y1)*(x2-x1)+x1;y2=axisy.min}if(y1>=y2&&y1>axisy.max&&y2<=axisy.max){x1=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y1=axisy.max}else if(y2>=y1&&y2>axisy.max&&y1<=axisy.max){x2=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y2=axisy.max}if(x1!=x1old){ctx.lineTo(axisx.p2c(x1old),axisy.p2c(y1))}ctx.lineTo(axisx.p2c(x1),axisy.p2c(y1));ctx.lineTo(axisx.p2c(x2),axisy.p2c(y2));if(x2!=x2old){ctx.lineTo(axisx.p2c(x2),axisy.p2c(y2));ctx.lineTo(axisx.p2c(x2old),axisy.p2c(y2))}}}ctx.save();ctx.translate(plotOffset.left,plotOffset.top);ctx.lineJoin="round";var lw=series.lines.lineWidth,sw=series.shadowSize;if(lw>0&&sw>0){ctx.lineWidth=sw;ctx.strokeStyle="rgba(0,0,0,0.1)";var angle=Math.PI/18;plotLine(series.datapoints,Math.sin(angle)*(lw/2+sw/2),Math.cos(angle)*(lw/2+sw/2),series.xaxis,series.yaxis);ctx.lineWidth=sw/2;plotLine(series.datapoints,Math.sin(angle)*(lw/2+sw/4),Math.cos(angle)*(lw/2+sw/4),series.xaxis,series.yaxis)}ctx.lineWidth=lw;ctx.strokeStyle=series.color;var fillStyle=getFillStyle(series.lines,series.color,0,plotHeight);if(fillStyle){ctx.fillStyle=fillStyle;plotLineArea(series.datapoints,series.xaxis,series.yaxis)}if(lw>0)plotLine(series.datapoints,0,0,series.xaxis,series.yaxis);ctx.restore()}function drawSeriesPoints(series){function plotPoints(datapoints,radius,fillStyle,offset,shadow,axisx,axisy,symbol){var points=datapoints.points,ps=datapoints.pointsize;for(var i=0;iaxisx.max||yaxisy.max)continue;ctx.beginPath();x=axisx.p2c(x);y=axisy.p2c(y)+offset;if(symbol=="circle")ctx.arc(x,y,radius,0,shadow?Math.PI:Math.PI*2,false);else symbol(ctx,x,y,radius,shadow);ctx.closePath();if(fillStyle){ctx.fillStyle=fillStyle;ctx.fill()}ctx.stroke()}}ctx.save();ctx.translate(plotOffset.left,plotOffset.top);var lw=series.points.lineWidth,sw=series.shadowSize,radius=series.points.radius,symbol=series.points.symbol;if(lw==0)lw=1e-4;if(lw>0&&sw>0){var w=sw/2;ctx.lineWidth=w;ctx.strokeStyle="rgba(0,0,0,0.1)";plotPoints(series.datapoints,radius,null,w+w/2,true,series.xaxis,series.yaxis,symbol);ctx.strokeStyle="rgba(0,0,0,0.2)";plotPoints(series.datapoints,radius,null,w/2,true,series.xaxis,series.yaxis,symbol)}ctx.lineWidth=lw;ctx.strokeStyle=series.color;plotPoints(series.datapoints,radius,getFillStyle(series.points,series.color),0,false,series.xaxis,series.yaxis,symbol);ctx.restore()}function drawBar(x,y,b,barLeft,barRight,fillStyleCallback,axisx,axisy,c,horizontal,lineWidth){var left,right,bottom,top,drawLeft,drawRight,drawTop,drawBottom,tmp;if(horizontal){drawBottom=drawRight=drawTop=true;drawLeft=false;left=b;right=x;top=y+barLeft;bottom=y+barRight;if(rightaxisx.max||topaxisy.max)return;if(leftaxisx.max){right=axisx.max;drawRight=false}if(bottomaxisy.max){top=axisy.max;drawTop=false}left=axisx.p2c(left);bottom=axisy.p2c(bottom);right=axisx.p2c(right);top=axisy.p2c(top);if(fillStyleCallback){c.fillStyle=fillStyleCallback(bottom,top);c.fillRect(left,top,right-left,bottom-top)}if(lineWidth>0&&(drawLeft||drawRight||drawTop||drawBottom)){c.beginPath();c.moveTo(left,bottom);if(drawLeft)c.lineTo(left,top);else c.moveTo(left,top);if(drawTop)c.lineTo(right,top);else c.moveTo(right,top);if(drawRight)c.lineTo(right,bottom);else c.moveTo(right,bottom);if(drawBottom)c.lineTo(left,bottom);else c.moveTo(left,bottom);c.stroke()}}function drawSeriesBars(series){function plotBars(datapoints,barLeft,barRight,fillStyleCallback,axisx,axisy){var points=datapoints.points,ps=datapoints.pointsize;for(var i=0;i");fragments.push("");rowStarted=true}fragments.push('
    '+''+entry.label+"")}if(rowStarted)fragments.push("");if(fragments.length==0)return;var table=''+fragments.join("")+"
    ";if(options.legend.container!=null)$(options.legend.container).html(table);else{var pos="",p=options.legend.position,m=options.legend.margin;if(m[0]==null)m=[m,m];if(p.charAt(0)=="n")pos+="top:"+(m[1]+plotOffset.top)+"px;";else if(p.charAt(0)=="s")pos+="bottom:"+(m[1]+plotOffset.bottom)+"px;";if(p.charAt(1)=="e")pos+="right:"+(m[0]+plotOffset.right)+"px;";else if(p.charAt(1)=="w")pos+="left:"+(m[0]+plotOffset.left)+"px;";var legend=$('
    '+table.replace('style="','style="position:absolute;'+pos+";")+"
    ").appendTo(placeholder);if(options.legend.backgroundOpacity!=0){var c=options.legend.backgroundColor;if(c==null){c=options.grid.backgroundColor;if(c&&typeof c=="string")c=$.color.parse(c);else c=$.color.extract(legend,"background-color");c.a=1;c=c.toString()}var div=legend.children();$('
    ').prependTo(legend).css("opacity",options.legend.backgroundOpacity)}}}var highlights=[],redrawTimeout=null;function findNearbyItem(mouseX,mouseY,seriesFilter){var maxDistance=options.grid.mouseActiveRadius,smallestDistance=maxDistance*maxDistance+1,item=null,foundPoint=false,i,j,ps;for(i=series.length-1;i>=0;--i){if(!seriesFilter(series[i]))continue;var s=series[i],axisx=s.xaxis,axisy=s.yaxis,points=s.datapoints.points,mx=axisx.c2p(mouseX),my=axisy.c2p(mouseY),maxx=maxDistance/axisx.scale,maxy=maxDistance/axisy.scale;ps=s.datapoints.pointsize;if(axisx.options.inverseTransform)maxx=Number.MAX_VALUE;if(axisy.options.inverseTransform)maxy=Number.MAX_VALUE;if(s.lines.show||s.points.show){for(j=0;jmaxx||x-mx<-maxx||y-my>maxy||y-my<-maxy)continue;var dx=Math.abs(axisx.p2c(x)-mouseX),dy=Math.abs(axisy.p2c(y)-mouseY),dist=dx*dx+dy*dy;if(dist=Math.min(b,x)&&my>=y+barLeft&&my<=y+barRight:mx>=x+barLeft&&mx<=x+barRight&&my>=Math.min(b,y)&&my<=Math.max(b,y))item=[i,j/ps]}}}if(item){i=item[0];j=item[1];ps=series[i].datapoints.pointsize;return{datapoint:series[i].datapoints.points.slice(j*ps,(j+1)*ps),dataIndex:j,series:series[i],seriesIndex:i}}return null}function onMouseMove(e){if(options.grid.hoverable)triggerClickHoverEvent("plothover",e,function(s){return s["hoverable"]!=false})}function onMouseLeave(e){if(options.grid.hoverable)triggerClickHoverEvent("plothover",e,function(s){return false})}function onClick(e){triggerClickHoverEvent("plotclick",e,function(s){return s["clickable"]!=false})}function triggerClickHoverEvent(eventname,event,seriesFilter){var offset=eventHolder.offset(),canvasX=event.pageX-offset.left-plotOffset.left,canvasY=event.pageY-offset.top-plotOffset.top,pos=canvasToAxisCoords({left:canvasX,top:canvasY});pos.pageX=event.pageX;pos.pageY=event.pageY;var item=findNearbyItem(canvasX,canvasY,seriesFilter);if(item){item.pageX=parseInt(item.series.xaxis.p2c(item.datapoint[0])+offset.left+plotOffset.left,10);item.pageY=parseInt(item.series.yaxis.p2c(item.datapoint[1])+offset.top+plotOffset.top,10)}if(options.grid.autoHighlight){for(var i=0;iaxisx.max||yaxisy.max)return;var pointRadius=series.points.radius+series.points.lineWidth/2;octx.lineWidth=pointRadius;octx.strokeStyle=highlightColor;var radius=1.5*pointRadius;x=axisx.p2c(x);y=axisy.p2c(y);octx.beginPath();if(series.points.symbol=="circle")octx.arc(x,y,radius,0,2*Math.PI,false);else series.points.symbol(octx,x,y,radius,false);octx.closePath();octx.stroke()}function drawBarHighlight(series,point){var highlightColor=typeof series.highlightColor==="string"?series.highlightColor:$.color.parse(series.color).scale("a",.5).toString(),fillStyle=highlightColor,barLeft;switch(series.bars.align){case"left":barLeft=0;break;case"right":barLeft=-series.bars.barWidth;break;default:barLeft=-series.bars.barWidth/2}octx.lineWidth=series.bars.lineWidth;octx.strokeStyle=highlightColor;drawBar(point[0],point[1],point[2]||0,barLeft,barLeft+series.bars.barWidth,function(){return fillStyle},series.xaxis,series.yaxis,octx,series.bars.horizontal,series.bars.lineWidth)}function getColorOrGradient(spec,bottom,top,defaultColor){if(typeof spec=="string")return spec;else{var gradient=ctx.createLinearGradient(0,top,0,bottom);for(var i=0,l=spec.colors.length;i= test_min_value || (!is_morning && test_value < 120000)) { - input.attr('value', value.join(":")); - console.log('updated!') - }; + input.attr('value', value.join(":")); + }; $('#minus-5').click(function() { $.fn.editHeureValue(-5) - console.log('minus 5') + //console.log('minus 5') }); $('#plus-5').click(function() { $.fn.editHeureValue(5) - console.log('plus 5') + //console.log('plus 5') }); }); diff --git a/website/templates/base.html b/website/templates/base.html index ccc3d5f..9eef7f8 100644 --- a/website/templates/base.html +++ b/website/templates/base.html @@ -4,21 +4,74 @@ {% block title %}La maraude{% endblock %} {% bootstrap_css %}{% bootstrap_javascript %} - - - {% if stylesheets %}{% for stylesheet in stylesheets %} - {% endfor %}{% endif %} + + {% block extrastyle %}{% endblock %} + {% if stylesheets %}{% for stylesheet in stylesheets %}{% endfor %}{% endif %} + {% block extrahead %}{% endblock %} + {% block blockbots %}{% endblock %} - +
    +
    -
    - {% navbar %} -
    -
    -

    {% block page_header %}{% endblock %}

    + +
    + +
    +
    {% bootstrap_messages %} - {% block content %}{% endblock %} + {% block page_content %}{% endblock %} +
    +
    + {% block sidebar %}{% endblock %}
    diff --git a/website/templates/base_site.html b/website/templates/base_site.html index 9ec4093..93a93fa 100644 --- a/website/templates/base_site.html +++ b/website/templates/base_site.html @@ -1,15 +1,5 @@ {% extends "base.html" %} -{% block title %}{{ page_title }}{% endblock %} - -{% block page_header %} -{{ page_header }} -{{ page_header_small }} -{% endblock %} - -{% block content %} - {% include content_template %} -{% endblock %} diff --git a/website/templates/main.html b/website/templates/index.html similarity index 75% rename from website/templates/main.html rename to website/templates/index.html index 4f378ea..2683ca3 100644 --- a/website/templates/main.html +++ b/website/templates/index.html @@ -1,4 +1,11 @@ -
    +{% extends "base.html" %} +{% load bootstrap3 %} + +{% block sidebar %} + {% include "login.html" %} +{% endblock %} + +{% block page_content %}

    Objectifs

    Description de la maraude à destination des visiteurs, partenaires, etc...

    @@ -8,5 +15,5 @@ et les vendredis en fin d'après-midi.

    Ils sont reconnaissables à leur vestes oranges, n'hésitez pas à les interpeller.

    -
    +{% endblock %} diff --git a/website/templates/login.html b/website/templates/login.html index 035acc5..9406173 100644 --- a/website/templates/login.html +++ b/website/templates/login.html @@ -1,41 +1,54 @@ -{% extends "base.html" %} - -{% load bootstrap3 %} - -{# Tweak columns layout for login box %} -{% block panels %}
    {% endblock %} - -{% block content %} -
    -

    Connexion

    -
    - {% if user.is_authenticated %} -

    Bienvenue {{ user.first_name|default:user.username }} !

    - {% if next %} -

    Votre compte ne donne pas accès à cette page. Veuillez vous - connecter avec un autre compte.

    - Déconnexion - {% else %} -
    - Entrer - {% if user.is_superuser %} - Administration - {% endif %} - Déconnexion -
    - {% endif %} - {% else %} - {% if next %} -

    Veuillez vous connecter pour accéder à cette page.

    - {% endif %} -
    - {% csrf_token %} - {% bootstrap_form form %} - {% bootstrap_button "Connexion" button_type="submit" button_class="btn-lg btn-primary" %} - -
    - {% endif %} +
    +
    +

    Connexion

    +
    +
    +{% if user.is_authenticated %} +

    {{ user.first_name|default:user.username }}, vous êtes connecté !

    + {% if next %} +
    +

    Votre compte ne donne pas accès à cette page. Veuillez vous connecter avec un autre compte.

    +
    + Déconnexion + {% else %} +
    + Entrer + {% if user.is_superuser %} + Administration + {% endif %} + Déconnexion +
    + {% endif %} +{% else %} +
    + {% csrf_token %} + {% if next %} +
    +

    Vous devez vous connecter pour accéder à cette page.

    +
    + + {% endif %} +
    +
    + +
    + +
    - +
    +
    + +
    + +
    -{% endblock %} +
    +
    +
    + +
    +
    +{% endif %} +
    + +
    diff --git a/website/templates/logout.html b/website/templates/logout.html deleted file mode 100644 index b651552..0000000 --- a/website/templates/logout.html +++ /dev/null @@ -1 +0,0 @@ -

    {{ title }}

    diff --git a/website/templates/navbar.html b/website/templates/navbar.html deleted file mode 100644 index 861e2cb..0000000 --- a/website/templates/navbar.html +++ /dev/null @@ -1,60 +0,0 @@ -{% load bootstrap3 %}{% load staticfiles %} - diff --git a/website/templates/tables/table.html b/website/templates/tables/table.html index dfe7b2c..7da22e8 100644 --- a/website/templates/tables/table.html +++ b/website/templates/tables/table.html @@ -1,13 +1,10 @@ - - {% for row in rows %} - - {% for object in row %} - +
    - {% if object %} - {% include cell_template with object=object %} - {%endif%} -
    + {% if header %}{% endif %} + {% for row in rows %} + {% for object in row %} {% endfor %} - {% endfor %} + {% endfor %}
    {{ header }}
    + {% if object %}{% include cell_template with object=object %}{%endif%} +
    diff --git a/website/templates/tables/table_cell.html b/website/templates/tables/table_cell_default.html similarity index 100% rename from website/templates/tables/table_cell.html rename to website/templates/tables/table_cell_default.html diff --git a/website/templatetags/navbar.py b/website/templatetags/navbar.py index c70b21f..2590d99 100644 --- a/website/templatetags/navbar.py +++ b/website/templatetags/navbar.py @@ -2,6 +2,8 @@ from django import template from django.urls import reverse +from django.utils.safestring import mark_safe + register = template.Library() @@ -48,4 +50,14 @@ def navbar_menu(app_menu): } +@register.simple_tag(takes_context=True) +def active(context, namespace=None, viewname=None): + try: + (cur_namespace, cur_viewname) = context.request.resolver_match.view_name.split(":") + except: + (cur_namespace, cur_viewname) = (None, context.request.resolver_match.view_name) + if namespace == cur_namespace: + if not viewname or viewname == cur_viewname: + return mark_safe("class=\"active\"") + return "" diff --git a/website/templatetags/tables.py b/website/templatetags/tables.py index b0fe817..c8d74c8 100644 --- a/website/templatetags/tables.py +++ b/website/templatetags/tables.py @@ -11,21 +11,14 @@ def get_columns(iterable, cols): yield iterable[i*cols_len:(i+1)*cols_len] @register.inclusion_tag("tables/table.html") -def table(object_list, cols=2, cell_template="tables/table_cell.html"): +def table(object_list, cols=2, cell_template="tables/table_cell_default.html", header=None): """ Render object list in table of given columns number """ return { 'cell_template': cell_template, + 'cols_number': cols, + 'header': header, 'rows': tuple(zip_longest( *get_columns(object_list, cols), fillvalue=None )) } -@register.inclusion_tag("tables/header_table.html") -def header_table(object_list, cols=2): - """ Display object list in table of given columns number """ - return { - 'cols': cols, - 'rows': tuple(zip_longest( *get_columns(object_list, cols), - fillvalue=None - )) - } diff --git a/website/tests.py b/website/tests.py index 7ce503c..61fa7e9 100644 --- a/website/tests.py +++ b/website/tests.py @@ -1,3 +1,44 @@ -from django.test import TestCase +from django.test import TestCase, Client +from utilisateurs.models import Maraudeur # Create your tests here. + +class RestrictedAccessAnonymousUserTestCase(TestCase): + + modules = ["maraudes", "notes", "utilisateurs"] + + def setUp(self): + self.client = Client() + + def test_access_restricted_modules(self): + for mod in self.modules: + url = "/%s/" % mod + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/?next=%s" % url) + + +class RestrictedAccessConnectedMaraudeurTestCase(TestCase): + modules = ["maraudes", "notes/sujets"] + def setUp(self): + m = Maraudeur.objects.create(first_name="Astérix", last_name="LeGaulois") + self.client = Client() + self.client.force_login(m) + + def test_access_restricted_modules(self): + for mod in self.modules: + url = "/%s/" % mod + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + +class NonRestrictedAccessTestCase(TestCase): + + urls = ["/statistiques/", "/"] + + def setUp(self): + self.client = Client() + + def test_access(self): + for url in self.urls: + response = self.client.get(url) + self.assertEqual(response.status_code, 200) diff --git a/website/urls.py b/website/urls.py index 767a87d..a085350 100644 --- a/website/urls.py +++ b/website/urls.py @@ -4,21 +4,20 @@ from django.contrib.auth import views as auth_views from .views import Index, login_view from maraudes import urls as maraudes_urls -from suivi import urls as suivi_urls -from sujets import urls as sujets_urls +from notes import urls as notes_urls from utilisateurs import urls as utilisateurs_urls +from statistiques import urls as stats_urls urlpatterns = [ # Authentification url(r'^$', Index.as_view(), name="index"), - url(r'^login/$', login_view), + url(r'^login/$', login_view, name="login"), url(r'^logout/$', auth_views.logout, { - 'template_name': 'logout.html', 'next_page': 'index', }, name="logout"), # Applications url(r'^maraudes/', include(maraudes_urls, namespace="maraudes")), - url(r'^suivi/', include(suivi_urls, namespace="suivi")), - url(r'^sujets/', include(sujets_urls, namespace="sujets")), + url(r'^notes/', include(notes_urls, namespace="notes")), url(r'^utilisateurs/', include(utilisateurs_urls, namespace="utilisateurs")), + url(r'^statistiques/', include(stats_urls, namespace="statistiques")), ] diff --git a/website/views.py b/website/views.py index 414d83a..d2def11 100644 --- a/website/views.py +++ b/website/views.py @@ -1,20 +1,17 @@ -from django.shortcuts import redirect from django.urls import reverse from django import views -from .mixins import WebsiteTemplateMixin from django.contrib.auth import login, authenticate +from django.contrib import messages from django.http import HttpResponseRedirect, HttpResponsePermanentRedirect -class Index(WebsiteTemplateMixin, views.generic.TemplateView): +class Index(views.generic.TemplateView): - template_name = "main.html" + template_name = "index.html" app_menu = None header = ('La Maraude ALSA', 'accueil') - class PageInfo: - title = "La maraude ALSA" - header = "La Maraude ALSA" - header_small = "accueil" + + http_method_names = ['get',] def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -25,7 +22,9 @@ class Index(WebsiteTemplateMixin, views.generic.TemplateView): def _get_entry_point(user): from utilisateurs.models import Maraudeur + from utilisateurs.backends import CustomUserAuthentication + print("Entry point for ", user, user.__class__) if isinstance(user, Maraudeur): return reverse('maraudes:index') else: @@ -43,6 +42,9 @@ def login_view(request): next = request.POST.get('next', None) if not next: next = _get_entry_point(user) + messages.success(request, "%s, vous êtes connecté !" % user) return HttpResponseRedirect(next) else: + messages.error(request, "Le nom d'utilisateur et/ou le mot de passe sont incorrects !") return HttpResponseRedirect('/') +