from collections import OrderedDict, namedtuple from datetime import date, timedelta from operator import attrgetter from pathlib import Path from django.contrib.auth.models import AbstractUser, Group, UserManager from django.contrib.postgres.aggregates import StringAgg from django.core.validators import MaxValueValidator from django.db import models from django.db.models import Count, F, OuterRef, Q, Subquery, Sum from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear from django.db.utils import IntegrityError from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property from django_countries.fields import CountryField from common import choices from common.fields import ChoiceArrayField from .utils import format_adresse, format_contact, format_d_m_Y, random_string_generator class CercleScolaire(models.Model): """ Les 7 cercles scolaires du canton""" nom = models.CharField(max_length=50, unique=True) telephone = models.CharField('tél.', max_length=35, blank=True) class Meta: verbose_name = 'Cercle scolaire' verbose_name_plural = 'Cercles scolaires' ordering = ('nom',) def __str__(self): return self.nom class Service(models.Model): """ Services sociaux en lien avec la Fondation Transit """ sigle = models.CharField('Sigle', max_length=20, unique=True) nom_complet = models.CharField('Nom complet', max_length=80, blank=True) class Meta: ordering = ('sigle',) def __str__(self): return self.sigle def save(self, *args, **kwargs): self.sigle = self.sigle.upper() super().save(*args, **kwargs) class RoleManager(models.Manager): def get_by_natural_key(self, nom): return self.get(nom=nom) class Role(models.Model): NON_EDITABLE = ['Père', 'Mère', 'Enfant suivi', 'Enfant non-suivi'] ROLES_PARENTS = ['Père', 'Mère'] nom = models.CharField("Nom", max_length=50, unique=True) est_famille = models.BooleanField("Famille", default=False) est_intervenant = models.BooleanField("Intervenant", default=False) est_editeur = models.BooleanField("Éditeur", default=False, help_text=( "Un rôle éditeur donne le droit de modification des dossiers familles si " "la personne est intervenante." )) objects = RoleManager() class Meta: ordering = ('nom',) def __str__(self): return self.nom @property def editable(self): return self.nom not in self.NON_EDITABLE def natural_key(self): return (self.nom,) class Contact(models.Model): prenom = models.CharField('Prénom', max_length=30) nom = models.CharField('Nom', max_length=30) rue = models.CharField('Rue', max_length=30, blank=True) npa = models.CharField('NPA', max_length=4, blank=True) localite = models.CharField('Localité', max_length=30, blank=True) tel_prive = models.CharField('Tél. privé', max_length=30, blank=True) tel_prof = models.CharField('Tél. prof.', max_length=30, blank=True) email = models.EmailField(max_length=100, blank=True) service = models.ForeignKey(Service, null=True, blank=True, on_delete=models.PROTECT) roles = models.ManyToManyField(Role, related_name='contacts', blank=True, verbose_name='Rôles') profession = models.CharField('Activité/prof.', max_length=100, blank=True) remarque = models.TextField("Remarque", blank=True) est_actif = models.BooleanField('actif', default=True) class Meta: verbose_name = 'Contact' ordering = ('nom', 'prenom') unique_together = ('nom', 'prenom', 'service') def __str__(self): sigle = self.service.sigle if self.service else '' return '{} ({})'.format(self.nom_prenom, sigle) if self.service else self.nom_prenom @property def nom_prenom(self): return '{} {}'.format(self.nom, self.prenom) @property def adresse(self): return format_adresse(self.rue, self.npa, self.localite) @cached_property def all_roles(self): return self.roles.all() def has_role(self, roles=()): """Return True if user has at least one of the `roles` name.""" return bool(set(r.nom for r in self.all_roles).intersection(set(roles))) def roles_str(self, sep=', '): return sep.join([role.nom for role in self.all_roles]) @property def contact(self): return format_contact(self.tel_prof, self.email) @classmethod def membres_ope(cls): return cls.objects.filter(service__sigle__startswith='OPE', est_actif=True).select_related('service') class EquipeChoices(models.TextChoices): MONTAGNES = 'montagnes', 'Montagnes et V-d-T' LITTORAL = 'littoral', 'Littoral et V-d-R' class Utilisateur(Contact, AbstractUser): sigle = models.CharField(max_length=5, blank=True) equipe = models.CharField("Équipe", max_length=10, choices=EquipeChoices.choices, blank=True) date_desactivation = models.DateField('Date désactivation', null=True, blank=True) taux_activite = models.PositiveSmallIntegerField( "Taux d’activité (en %)", blank=True, default=0, validators=[MaxValueValidator(100)] ) decharge = models.PositiveSmallIntegerField("Heures de décharge", blank=True, null=True) objects = UserManager() def __str__(self): return self.nom_prenom @property def groupes(self): return [item.name for item in self.groups.all()] @property def is_psy_or_educ(self): return self.has_role({'Psy', 'Educ'}) @property def is_responsable(self): return self.has_role({'Responsable/coordinateur'}) @property def is_admin_ipe(self): return 'admin ipe' in self.groupes @property def initiales(self): return self.sigle if self.sigle else f'{self.prenom[0]}{self.nom[0]}' @property def charge_max(self): """ Renvoie la charge maximale d'heures hebodmadaires de charge dossier en fonction du taux d'activité. """ charge_map = { 100: 32, 90: 28, 80: 24, 75: 22, 70: 20, 60: 16, 0: 0, } charge = charge_map.get(self.taux_activite, int(32 * self.taux_activite / 100)) if self.decharge: charge -= self.decharge return charge def prestations_gen(self): """Renvoie les prestations générales de l’utilisateur.""" return self.prestations.filter(famille__isnull=True) def temps_total_prestations(self, unite): return self.prestations.model.temps_total(self.prestations, tous_utilisateurs=False) def total_mensuel(self, unite, month, year): prestations = self.prestations.filter( date_prestation__year=year, date_prestation__month=month ) return self.prestations.model.temps_total(prestations, tous_utilisateurs=False) def totaux_mensuels(self, unite, year): return [self.total_mensuel(unite, m, year) for m in range(1, 13)] def total_annuel(self, unite, year): prestations = self.prestations.filter(date_prestation__year=year) return self.prestations.model.temps_total(prestations, tous_utilisateurs=False) @classmethod def _intervenants(cls, groupes=(), annee=None): if annee is None: # Utilisateurs actuellement actifs utils = Utilisateur.objects.filter(date_desactivation__isnull=True) else: utils = Utilisateur.objects.filter( Q(date_desactivation__isnull=True) | Q(date_desactivation__year__gte=annee) ) return utils.filter( groups__name__in=groupes, is_superuser=False ).exclude( groups__name__in=['direction'] ).distinct() @classmethod def intervenants(cls, annee=None): return cls._intervenants( groupes=['aemo'], annee=annee ) class GroupInfo(models.Model): group = models.OneToOneField(Group, on_delete=models.CASCADE) description = models.TextField(blank=True) def __str__(self): return f"Description of group {self.group.name}" class Region(models.Model): nom = models.CharField(max_length=30, unique=True) rue = models.CharField("Rue", max_length=100, blank=True) def __str__(self): return self.nom def __lt__(self, other): return self.nom < (other.nom if other else '') class FamilleQuerySet(models.QuerySet): def with_duos(self): """ Renvoie une liste des groupes Psy/Educ différents, sous forme de chaînes de sigles séparées par "/". Peut aussi renvoyer 1 ou 3 intervenants, le cas échéant. """ return self.annotate( duo=StringAgg( F('suivi__intervenant__intervenant__sigle'), delimiter='/', filter=( Q(suivi__intervenant__date_fin__isnull=True) & Q(suivi__intervenant__role__nom__in=['Educ', 'Psy']) ), ordering='suivi__intervenant__intervenant__sigle', ) ) def with_niveau_interv(self): """ Annote les familles avec le niveau d’intervention le plus récent. """ return self.annotate( niveau_interv=Subquery( Niveau.objects.filter( famille=OuterRef('pk') ).order_by('-date_debut').values('niveau_interv')[:1] ) ) class FamilleManager(models.Manager): def get_queryset(self): return FamilleQuerySet(self.model, using=self._db).filter( suivi__isnull=False ).prefetch_related( models.Prefetch('membres', queryset=Personne.objects.select_related('role')) ) def create_famille(self, equipe='', **kwargs): kwargs.pop('_state', None) famille = self.create(**kwargs) Suivi.objects.create( famille=famille, equipe=equipe ) famille.suivi.date_demande = date.today() famille.suivi.save() return famille class Famille(models.Model): created_at = models.DateTimeField(auto_now_add=True, editable=False) archived_at = models.DateTimeField('Archivée le', blank=True, null=True) nom = models.CharField('Nom de famille', max_length=40) rue = models.CharField('Rue', max_length=60, blank=True) npa = models.CharField('NPA', max_length=4, blank=True) localite = models.CharField('Localité', max_length=30, blank=True) region = models.ForeignKey(to=Region, null=True, blank=True, default=None, on_delete=models.SET_NULL) telephone = models.CharField('Tél.', max_length=60, blank=True) autorite_parentale = models.CharField("Autorité parentale", max_length=20, choices=choices.AUTORITE_PARENTALE_CHOICES, blank=True) monoparentale = models.BooleanField('Famille monoparent.', default=None, blank=True, null=True) statut_marital = models.CharField("Statut marital", max_length=20, choices=choices.STATUT_MARITAL_CHOICES, blank=True) connue = models.BooleanField("famille déjà suivie", default=False) accueil = models.BooleanField("famille d'accueil", default=False) # Pour CIPE besoins_part = models.BooleanField("famille à besoins particuliers", default=False) sap = models.BooleanField(default=False, verbose_name="famille s@p") garde = models.CharField( 'Type de garde', max_length=20, choices=choices.TYPE_GARDE_CHOICES, blank=True ) provenance = models.CharField( 'Provenance', max_length=30, choices=choices.PROVENANCE_DESTINATION_CHOICES, blank=True) destination = models.CharField( 'Destination', max_length=30, choices=choices.PROVENANCE_DESTINATION_CHOICES, blank=True) statut_financier = models.CharField( 'Statut financier', max_length=30, choices=choices.STATUT_FINANCIER_CHOICES, blank=True) remarques = models.TextField(blank=True) objects = FamilleManager() class Meta: ordering = ('nom', 'npa') permissions = ( ("can_manage_waiting_list", "Gérer la liste d'attente"), ("can_archive", "Archiver les dossiers AEMO"), ('export_stats', 'Exporter les statistiques'), ) def __str__(self): return '{} - {}'.format(self.nom, self.adresse) @property def adresse(self): return format_adresse(self.rue, self.npa, self.localite) @property def suivi(self): return getattr(self, self.suivi_name) if self.suivi_name else None @property def suivi_url(self): return reverse('famille-suivi', args=[self.pk]) @property def edit_url(self): return reverse('famille-edit', args=[self.pk]) @property def add_person_url(self): return reverse('personne-add', args=[self.pk]) def redirect_after_personne_creation(self, personne): return self.edit_url @property def print_coords_url(self): return reverse('print-coord-famille', args=[self.pk]) @classmethod def actives(cls, date=None): qs = Famille.objects.filter(suivi__isnull=False).order_by('nom', 'npa') if date is not None: return qs.filter(suivi__date_demande__lte=date).filter( models.Q(suivi__date_fin_suivi__isnull=True) | models.Q(suivi__date_fin_suivi__gte=date) ) else: return qs.filter(suivi__date_fin_suivi__isnull=True) def interventions_actives(self, dt=None): dt = dt or date.today() return self.suivi.intervenant_set.filter( Q(date_debut__lte=dt) & ( Q(date_fin__isnull=True) | Q(date_fin__gte=dt) ) ).select_related('intervenant', 'role') def niveau_actuel(self): try: return [ niv.niveau_interv for niv in sorted( self.niveaux.all(), key=attrgetter('date_debut') ) ][-1] except IndexError: return None def membres_suivis(self): if 'membres' in getattr(self, '_prefetched_objects_cache', {}): return sorted([ pers for pers in self._prefetched_objects_cache['membres'] if pers.role.nom == "Enfant suivi" ], key=lambda p: p.date_naissance or date(1950, 1, 1)) else: return self.membres.filter(role__nom="Enfant suivi").order_by('date_naissance') def enfants_non_suivis(self): if 'membres' in getattr(self, '_prefetched_objects_cache', {}): return sorted([ pers for pers in self._prefetched_objects_cache['membres'] if pers.role.nom == "Enfant non-suivi" ], key=lambda p: p.date_naissance or date(1950, 1, 1)) else: return self.membres.filter(role__nom='Enfant non-suivi').order_by('date_naissance') def parents(self): if 'membres' in getattr(self, '_prefetched_objects_cache', {}): parents = [ pers for pers in self._prefetched_objects_cache['membres'] if pers.role.nom in Role.ROLES_PARENTS ] else: parents = [ pers for pers in self.membres.filter(role__nom__in=Role.ROLES_PARENTS) ] return sorted(parents, key=lambda p: p.role.nom) # Mère avant Père def autres_parents(self): excluded_roles = Role.ROLES_PARENTS + ['Enfant suivi', 'Enfant non-suivi'] if 'membres' in getattr(self, '_prefetched_objects_cache', {}): return [ pers for pers in self._prefetched_objects_cache['membres'] if pers.role.nom not in excluded_roles ] else: return self.membres.exclude(role__nom__in=excluded_roles) def access_ok(self, user): if user.has_perm('aemo.change_famille') or user.is_responsable: return True intervs = self.interventions_actives() return not intervs or user in [interv.intervenant for interv in intervs] def can_view(self, user): return user.has_perm('aemo.view_famille') def can_edit(self, user): if user.has_perm('aemo.change_famille') or user.is_responsable: return True if not self.suivi.date_fin_suivi or self.suivi.date_fin_suivi >= date.today(): intervs = self.interventions_actives() if ( self.can_view(user) and (not intervs or user in [ interv.intervenant for interv in intervs if interv.role.est_editeur ]) ): return True return False def can_be_deleted(self, user): if not user.has_perm('aemo.change_famille'): return False if self.suivi.motif_fin_suivi == 'erreur': return True return False def can_be_archived(self, user): if self.archived_at: return False closed_since = date.today() - self.suivi.date_fin_suivi return ( user.has_perm('aemo.can_archive') and self.suivi.date_fin_suivi is not None and closed_since.days > 180 and self.suivi.date_fin_suivi.year < date.today().year and (date.today().month > 1 or closed_since.days > 400) ) def temps_total_prestations(self, interv=None): """ Temps total des prestations liées à la famille, quel que soit le nombre de membres suivis. Filtré facultativement par intervenant. """ prest = self.prestations if interv is None else self.prestations.filter(intervenant=interv) return prest.annotate(num_util=Count('intervenants')).aggregate( total=Sum(F('duree') * F('num_util'), output_field=models.DurationField()) )['total'] or timedelta() def temps_total_prestations_reparti(self): """ Temps total des prestations liées à la famille, divisé par le nombre d'enfants suivis. """ duree = self.temps_total_prestations() duree //= (len(self.membres_suivis()) or 1) return duree def total_mensuel(self, date_debut_mois): """ Temps total de prestations sur un mois donné """ return self.prestations.filter( date_prestation__month=date_debut_mois.month, date_prestation__year=date_debut_mois.year ).aggregate(total=Sum('duree'))['total'] or timedelta() def total_mensuel_par_prestation(self, prest_codes, date_debut_mois): """ Temps total d'évaluation sur un mois donné par prestation. """ return self.prestations.annotate( num_util=Count('intervenants') ).filter( lib_prestation__code__in=prest_codes, date_prestation__month=date_debut_mois.month, date_prestation__year=date_debut_mois.year ).aggregate( total=Sum(F('duree') * F('num_util'), output_field=models.DurationField()) )['total'] or timedelta() def total_mensuel_evaluation(self, date_debut_mois): return self.total_mensuel_par_prestation(['aemo01'], date_debut_mois) def total_mensuel_suivi(self, date_debut_mois): return self.total_mensuel_par_prestation(['aemo02', 'aemo04', 'aemo05', 'aemo06', 'aemo07'], date_debut_mois) def prestations_historiques(self): return Prestation.prestations_historiques(self.prestations.all()) def prestations_du_mois(self): """ Retourne le détail des prestations pour cette famille à partir du mois courant """ date_ref = date(date.today().year, date.today().month, 1) return self.prestations.filter(date_prestation__gte=date_ref) @classmethod def suivis_en_cours(cls, debut, fin): base = cls.objects.annotate( date_deb=Coalesce( 'suivi__date_demande', 'suivi__date_debut_evaluation', 'suivi__date_debut_suivi' ), ).prefetch_related('membres') return base.filter( date_deb__lte=fin ).filter( Q(**{'suivi__date_fin_suivi__isnull': True}) | Q(**{'suivi__date_fin_suivi__gte': debut}) ).exclude(**{'suivi__motif_fin_suivi': 'erreur'}) @classmethod def suivis_nouveaux(cls, annee, mois): base = cls.objects.annotate( date_dem=F('suivi__date_demande') ).prefetch_related('membres') return base.filter(**{ 'date_dem__year': annee, 'date_dem__month': mois, }).exclude(**{'suivi__motif_fin_suivi': 'erreur'}) @classmethod def suivis_termines(cls, annee, mois): base = cls.objects.all().prefetch_related('membres') return base.filter(**{ 'suivi__date_fin_suivi__year': annee, 'suivi__date_fin_suivi__month': mois, }).exclude(**{'suivi__motif_fin_suivi': 'erreur'}) def can_be_reactivated(self, user): return user.has_perm("aemo.change_famille") and self.suivi.date_fin_suivi is not None def anonymiser(self): # Famille self.nom = random_string_generator() self.rue = '' self.telephone = '' self.remarques = '' self.archived_at = timezone.now() self.save() # Personne for personne in self.membres.all().select_related('role'): if personne.role.nom == 'Enfant suivi': personne.nom = random_string_generator() personne.prenom = random_string_generator() personne.validite = None personne.contractant = False fields = ['rue', 'npa', 'localite', 'telephone', 'email', 'remarque', 'remarque_privee', 'profession', 'filiation', 'allergies', 'employeur', 'permis', 'animaux'] [setattr(personne, field, '') for field in fields] personne.save() personne.reseaux.clear() if hasattr(personne, 'formation'): personne.formation.cercle_scolaire = None fields = ['college', 'classe', 'enseignant', 'creche', 'creche_resp', 'entreprise', 'maitre_apprentissage', 'remarque'] [setattr(personne.formation, field, '') for field in fields] personne.formation.save() else: personne.delete() # Suivi fields = ['difficultes', 'aides', 'competences', 'autres_contacts', 'disponibilites', 'remarque', 'remarque_privee', 'motif_detail', 'referent_note', 'collaboration', 'ressources', 'crise', 'pers_famille_presentes', 'ref_presents', 'autres_pers_presentes'] [setattr(self.suivi, field, '') for field in fields] self.suivi.save() # Related for doc in self.documents.all(): doc.delete() for bilan in self.bilans.all(): bilan.delete() for rapport in self.rapports.all(): rapport.delete() self.prestations.all().update(texte='') for prestation in self.prestations.exclude(fichier=''): prestation.fichier.delete() prestation.save() class PersonneManager(models.Manager): def create_personne(self, **kwargs): kwargs.pop('_state', None) pers = self.create(**kwargs) return self.add_formation(pers) def add_formation(self, pers): if pers.role.nom == 'Enfant suivi' and not hasattr(pers, 'formation'): moins_de_4ans = bool(pers.age and pers.age < 4) Formation.objects.create( personne=pers, statut='pre_scol' if moins_de_4ans else '' ) if moins_de_4ans: try: pers.famille.suivi.demande_prioritaire = True pers.famille.suivi.save() except AttributeError: pass return pers class Personne(models.Model): """ Classe de base des personnes """ created_at = models.DateTimeField(auto_now_add=True, editable=False, null=True) nom = models.CharField('Nom', max_length=30) prenom = models.CharField('Prénom', max_length=30, blank=True) date_naissance = models.DateField('Date de naissance', null=True, blank=True) genre = models.CharField('Genre', max_length=1, choices=(('M', 'M'), ('F', 'F')), default='M') rue = models.CharField('Rue', max_length=60, blank=True) npa = models.CharField('NPA', max_length=4, blank=True) localite = models.CharField('Localité', max_length=30, blank=True) telephone = models.CharField('Tél.', max_length=60, blank=True) email = models.EmailField('Courriel', blank=True) pays_origine = CountryField('Nationalité', blank=True) remarque = models.TextField(blank=True) remarque_privee = models.TextField('Remarque privée', blank=True) famille = models.ForeignKey(Famille, on_delete=models.CASCADE, related_name='membres') role = models.ForeignKey('Role', on_delete=models.PROTECT) profession = models.CharField('Profession', max_length=50, blank=True) filiation = models.CharField('Filiation', max_length=80, blank=True) decedee = models.BooleanField('Cette personne est décédée', default=False) reseaux = models.ManyToManyField(Contact, blank=True) allergies = models.TextField('Allergies', blank=True) # Champs spécifiques SIFP employeur = models.CharField('Adresse empl.', max_length=50, blank=True) permis = models.CharField('Permis/séjour', max_length=30, blank=True) validite = models.DateField('Date validité', blank=True, null=True) animaux = models.BooleanField('Animaux', default=None, null=True) objects = PersonneManager() class Meta: verbose_name = 'Personne' ordering = ('nom', 'prenom') def __str__(self): return '{} {}'.format(self.nom, self.prenom) @property def nom_prenom(self): return str(self) @property def adresse(self): return format_adresse(self.rue, self.npa, self.localite) @property def age(self): return self.age_a(date.today()) def age_str(self, date_=None, format_='auto'): """Renvoie '2 ans, 4 mois', '5 ans', '8 mois'. Param: 'auto': application du format - 'jour' si l'âge < 30 jours - 'sem_jour' si l'âge < 77 jours (11 semaines) - 'mois_sem' si l'âge < 690 jours (23 mois) - 'an_mois': autres cas 'jour': âge en jours 'sem_jour': âge en semaines et jours 'mois_sem': âge en mois et semaines """ if not self.date_naissance: # Pourrait disparaître si date_naissance devient non null return '' if format_ not in ['auto', 'jour', 'sem_jour', 'mois_sem', 'an_mois']: raise ValueError('Paramètre erroné') age_jours = self.age_jours(date_) if (age_jours < 30 and format_ == 'auto') or format_ == 'jour': age_str = f"{age_jours} jour{'s' if age_jours > 1 else ''}" elif (age_jours < 77 and format_ == 'auto') or format_ == 'sem_jour': sem, jours = age_jours // 7, age_jours % 7 age_str = f"{sem} sem." age_str += f" {jours} jour{'s' if jours > 1 else ''}" if jours > 0 else '' elif (age_jours < 690 and format_ == 'auto') or format_ == 'mois_sem': mois, sem = age_jours // 30, int((age_jours % 30)/7) age_str = f"{mois} mois" age_str += f" {sem} sem." if sem > 0 else '' else: ans, mois = int(age_jours / 365.25), int((age_jours % 365.25) / 30) age_str = f"{ans} an{'s' if ans > 1 else ''}" age_str += " %d mois" % mois if mois > 0 else '' return age_str def age_a(self, date_): if not self.date_naissance: # Pourrait disparaître si date_naissance devient non null return None age = (date_ - self.date_naissance).days / 365.25 return int(age * 10) / 10 # 1 décimale arrondi vers le bas def age_jours(self, date_=None): if date_ is None: date_ = date.today() return (date_ - self.date_naissance).days def age_mois(self, date_=None): """Âge en mois à la date_""" if date_ is None: date_ = date.today() if not self.date_naissance or date_ < self.date_naissance: return None return self.age_jours(date_) / (365 / 12) @property def localite_display(self): return '{} {}'.format(self.npa, self.localite) @property def edit_url(self): return reverse('personne-edit', args=[self.famille_id, self.pk]) def can_edit(self, user): return self.famille.can_edit(user) def can_be_deleted(self, user): return self.famille.can_be_deleted(user) class Formation(models.Model): FORMATION_CHOICES = ( ('pre_scol', 'Pré-scolaire'), ('cycle1', 'Cycle 1'), ('cycle2', 'Cycle 2'), ('cycle3', 'Cycle 3'), ('apprenti', 'Apprentissage'), ('etudiant', 'Etudiant'), ('en_emploi', 'En emploi'), ('sans_emploi', 'Sans emploi'), ('sans_occupation', 'Sans occupation'), ) personne = models.OneToOneField(Personne, on_delete=models.CASCADE) statut = models.CharField('Scolarité', max_length=20, choices=FORMATION_CHOICES, blank=True) cercle_scolaire = models.ForeignKey( CercleScolaire, blank=True, null=True, on_delete=models.SET_NULL, related_name='+', verbose_name='Cercle scolaire' ) college = models.CharField('Collège', max_length=50, blank=True) classe = models.CharField('Classe', max_length=50, blank=True) enseignant = models.CharField('Enseignant', max_length=50, blank=True) creche = models.CharField('Crèche', max_length=50, blank=True) creche_resp = models.CharField('Resp.crèche', max_length=50, blank=True) entreprise = models.CharField('Entreprise', max_length=50, blank=True) maitre_apprentissage = models.CharField("Maître d'appr.", max_length=50, blank=True) remarque = models.TextField(blank=True) class Meta: verbose_name = 'Scolarité' def __str__(self): return 'Scolarité de {}'.format(self.personne) def can_edit(self, user): return self.personne.famille.can_edit(user) def delete(self, **kwargs): if self.personne: raise IntegrityError else: super().delete(**kwargs) @property def sse(self): return "Champ à définir" def pdf_data(self): if self.personne.formation.statut == 'pre_scol': data = [ ['Crèche: {}'.format(self.creche), 'responsable: {}'.format(self.creche_resp)], ] elif self.personne.formation.statut in ['cycle1', 'cycle2', 'cycle3', 'etudiant']: data = [ ['Cercle: {}'.format(self.cercle_scolaire), 'collège: {}'.format(self.college)], ['Classe: {}'.format(self.classe), 'Enseignant-e: {}'.format(self.enseignant)] ] elif self.personne.formation.statut == 'apprenti': data = [ ['Employeur: {}'.format(self.entreprise), 'resp. apprenti-e: {}'.format(self.maitre_apprentissage)], ] else: data = [self.personne.formation.statut] return data def info_scol(self): if self.statut == 'pre_scol': creche = f"Crèche: {self.creche}" if self.creche else '' resp = f" - resp.: {self.creche_resp}" if self.creche_resp else '' data = ''.join([creche, resp]) elif self.statut in ['cycle1', 'cycle2', 'cycle3', 'etudiant']: college = f"Collège: {self.college}" if self.college else '' classe = f" - classe: {self.classe}" if self.classe else '' ens = f" - ens.: {self.enseignant}" if self.enseignant else '' data = ''.join([college, classe, ens]) elif self.statut == 'apprenti': emp = f"Employeur: {self.entreprise}" if self.entreprise else '' resp = f" - resp.: {self.maitre_apprentissage}" if self.maitre_apprentissage else '' data = ''.join([emp, resp]) elif self.statut: data = self.get_statut_display() if self.statut else '' else: data = "Formation: aucune info. saisie" return data class Document(models.Model): famille = models.ForeignKey(Famille, related_name='documents', on_delete=models.CASCADE) fichier = models.FileField("Nouveau fichier", upload_to='doc') titre = models.CharField(max_length=100) class Meta: unique_together = ('famille', 'titre') def __str__(self): return self.titre def delete(self): Path(self.fichier.path).unlink(missing_ok=True) return super().delete() def can_edit(self, user): return self.famille.can_edit(user) def can_be_deleted(self, user): return self.famille.can_be_deleted(user) class Bilan(models.Model): famille = models.ForeignKey(Famille, related_name='bilans', on_delete=models.CASCADE) date = models.DateField("Date du bilan") auteur = models.ForeignKey( Utilisateur, related_name='bilans', on_delete=models.PROTECT, null=True, ) objectifs = models.TextField("Objectifs") rythme = models.TextField("Rythme et fréquence") sig_famille = models.BooleanField("Apposer signature de la famille", default=True) sig_interv = models.ManyToManyField(Utilisateur, blank=True, verbose_name="Signature des intervenants") fichier = models.FileField("Fichier/image", blank=True, upload_to='bilans') delete_urlname = 'famille-bilan-delete' def __str__(self): return "Bilan du {} pour la famille {}".format(self.date, self.famille) def get_absolute_url(self): return reverse('famille-bilan-view', args=[self.famille_id, self.pk]) def get_print_url(self): return reverse('print-bilan', args=[self.pk]) def edit_url(self): return reverse('famille-bilan-edit', args=[self.famille_id, self.pk]) def can_edit(self, user): return self.famille.can_edit(user) or user in self.famille.suivi.intervenants.all() def title(self): return f"Bilan du {format_d_m_Y(self.date)}" class Rapport(models.Model): """ Rapport est l'appellation historique, Résumé étant l'appellation moderne (dès février 2023). Les champs «techniques» n'ont pas été renommés. """ famille = models.ForeignKey(Famille, related_name='rapports', on_delete=models.CASCADE) date = models.DateField("Date du résumé") auteur = models.ForeignKey(Utilisateur, on_delete=models.PROTECT) pres_interv = models.ManyToManyField( Utilisateur, blank=True, verbose_name="Intervenants cités dans le résumé", related_name='+' ) sig_interv = models.ManyToManyField( Utilisateur, blank=True, verbose_name="Signature des intervenants", related_name='+' ) situation = models.TextField("Situation / contexte familial", blank=True) observations = models.TextField("Observations, évolution et hypothèses", blank=True) projet = models.TextField("Perspectives d’avenir", blank=True) def __str__(self): return "Résumé du {} pour la famille {}".format(format_d_m_Y(self.date), self.famille) def get_absolute_url(self): return reverse('famille-rapport-view', args=[self.famille.pk, self.pk]) def edit_url(self): return reverse('famille-rapport-edit', args=[self.famille.pk, self.pk]) def get_print_url(self): return reverse('famille-rapport-print', args=[self.famille.pk, self.pk]) def can_edit(self, user): return self.famille.can_edit(user) or user in self.famille.suivi.intervenants.all() def can_delete(self, user): return self.famille.can_edit(user) def title(self): return f"Résumé du {format_d_m_Y(self.date)}" def intervenants(self): return [i.intervenant for i in self.famille.interventions_actives(self.date)] class Niveau(models.Model): INTERV_CHOICES = [(0, '0'), (1, '1'), (2, '2'), (3, '3')] famille = models.ForeignKey( Famille, related_name='niveaux', on_delete=models.CASCADE, verbose_name="Famille" ) niveau_interv = models.PositiveSmallIntegerField("Niveau d’intervention", choices=INTERV_CHOICES) date_debut = models.DateField('Date début', blank=True, null=True) date_fin = models.DateField('Date fin', blank=True, null=True) def __str__(self): return ( f"{self.famille.nom} - {self.niveau_interv} - du {format_d_m_Y(self.date_debut)} au " f"{format_d_m_Y(self.date_fin)}" ) class LibellePrestation(models.Model): code = models.CharField('Code', max_length=6, unique=True) nom = models.CharField('Nom', max_length=30) actes = models.TextField('Actes à prester', blank=True) class Meta: ordering = ('code',) def __str__(self): return self.nom class Prestation(models.Model): famille = models.ForeignKey(Famille, related_name='prestations', null=True, blank=True, on_delete=models.SET_NULL, verbose_name="Famille") auteur = models.ForeignKey(Utilisateur, on_delete=models.PROTECT, verbose_name='auteur') intervenants = models.ManyToManyField(Utilisateur, related_name='prestations') date_prestation = models.DateField("date de l’intervention") duree = models.DurationField("durée") lib_prestation = models.ForeignKey( LibellePrestation, on_delete=models.SET_NULL, null=True, default=None, related_name='prestations_%(app_label)s' ) # Nombre de familles actives au moment de la prestation, utilisé pour ventiler les heures # dans les statistiques. familles_actives = models.PositiveSmallIntegerField(blank=True, default=0) texte = models.TextField('Contenu', blank=True) manque = models.BooleanField('Rendez-vous manqué', default=False) fichier = models.FileField('Fichier/image', upload_to='prestations', blank=True) # Nbre de jours maximum après fin d'un mois où il est encore possible de saisir # des données du mois précédent. DELAI_SAISIE_SUPPL = 14 class Meta: ordering = ('-date_prestation',) permissions = ( ('edit_prest_prev_month', 'Modifier prestations du mois précédent'), ) def __str__(self): if self.famille: return f'Prestation pour la famille {self.famille} le {self.date_prestation} : {self.duree}' return f'Prestation générale le {self.date_prestation} : {self.duree}' def can_edit(self, user): return ( (user == self.auteur or user in self.intervenants.all() or user.has_perm('aemo.edit_prest_prev_month') ) and self.check_date_allowed(user, self.date_prestation) ) def edit_url(self): return reverse('prestation-edit', args=[self.famille.pk if self.famille else 0, self.pk]) def save(self, *args, **kwargs): if self.famille is None: self.familles_actives = Famille.actives(self.date_prestation).count() super().save(*args, **kwargs) @classmethod def check_date_allowed(cls, user, dt): """Contrôle si la date `dt` est située dans le mois courant + DELAI_SAISIE_SUPPL.""" today = date.today() delai = cls.DELAI_SAISIE_SUPPL if user.has_perm('aemo.edit_prest_prev_month'): delai += 31 if today.day <= delai: return dt >= (today - timedelta(days=delai + 1)).replace(day=1) else: return dt.year == today.year and dt.month == today.month @classmethod def prestations_historiques(cls, prestations): """ Renvoie un queryset avec toutes les prestations regroupées par annee/mois et le total correspondant. """ return prestations.annotate( annee=ExtractYear('date_prestation'), mois=ExtractMonth('date_prestation') ).values('annee', 'mois').order_by('-annee', '-mois').annotate(total=Sum('duree')) @classmethod def temps_total(cls, prestations, tous_utilisateurs=False): """ Renvoie le temps total des `prestations` (QuerySet) sous forme de chaîne '12:30'. Si tous_utilisateurs = True, multiplie le temps de chaque prestation par son nombre d'intervenants. """ if prestations.count() > 0: if tous_utilisateurs: duree = prestations.annotate(num_util=Count('intervenants')).aggregate( total=Sum(F('duree') * F('num_util'), output_field=models.DurationField()) )['total'] or timedelta() else: duree = prestations.aggregate(total=Sum('duree'))['total'] or timedelta() else: duree = timedelta() return duree @classmethod def temps_total_general(cls, annee, familles=True): """ Renvoie le temps total des prestations (familiales ou générales selon familles) pour l'année civile `annee`. """ prest_tot = cls.objects.filter( famille__isnull=not familles, date_prestation__year=annee ).aggregate(total=Sum('duree'))['total'] or timedelta() return prest_tot @classmethod def temps_total_general_fam_gen(cls, annee): """ Renvoie le temps total des prestations familiales ET générales pour l'année civile `annee`. """ prest_tot = cls.objects.filter( date_prestation__year=annee ).aggregate(total=Sum('duree'))['total'] or timedelta() return prest_tot @classmethod def temps_totaux_mensuels(cls, annee): """ Renvoie une liste du total mensuel de toutes les prestations familiales (sans prestations générales) pour l'année en cours (de janv. à déc.). """ data = [] for month in range(1, 13): tot = cls.objects.filter( famille__isnull=False, date_prestation__year=annee, date_prestation__month=month, ).aggregate(total=Sum('duree'))['total'] or timedelta() data.append(tot) return data class JournalAcces(models.Model): """Journalisation des accès aux familles.""" famille = models.ForeignKey(Famille, on_delete=models.CASCADE, related_name='+') utilisateur = models.ForeignKey(Utilisateur, on_delete=models.CASCADE, related_name='+') ordinaire = models.BooleanField(default=True) quand = models.DateTimeField(auto_now_add=True) def __str__(self): return f"Accès de «{self.utilisateur}» à la famille «{self.famille}», le {self.quand}" class Etape: """ 1. Numéro d'ordre pour tri dans la vue 2. Code 3. Affichage dans tableau 4. Nom visible de l'étape 5. Code de l'étape suivante 6. Délai jusqu'à l'échéance (en jours) 7. Code de l'étape précédente 8. Date obligatoire précédente 9. Saisie obligatoire ou non (True / False) """ def __init__(self, num, code, abrev, nom, suivante, delai, precedente, preced_oblig, oblig): self.num = num self.code = code self.abrev = abrev self.nom = nom self.suivante = suivante self.delai = delai self.precedente = precedente self.preced_oblig = preced_oblig self.oblig = oblig def __str__(self): return self.nom def __repr__(self): return ''.format(self.num, self.code) def date(self, suivi): return getattr(suivi, 'date_{}'.format(self.code)) def delai_depuis(self, suivi, date_base): """Délai de cette étape à partir de la date date_base (en général date précédente étape).""" if not date_base: return None return date_base + timedelta(days=self.delai) def date_nom(self): return 'date_{}'.format(self.code) def etape_suivante(self, suivi): return Suivi.WORKFLOW[self.suivante] if self.suivante else None def etape_precedente(self): return Suivi.WORKFLOW[self.precedente] if self.precedente else None class EtapeDebutSuivi(Etape): def date(self, suivi): return suivi.debut_suivi_selon_niveau class EtapeBilan(Etape): delai_standard = 3 * 30 # 3 mois delai_niveau3 = 30 # 1 mois def date(self, suivi): """Date du dernier bilan""" if self.etape_suivante(suivi).code == 'bilan_suivant': return None return suivi.date_dernier_bilan() def etape_suivante(self, suivi): # Soit bilan suivant, soit rapport if suivi.date_dernier_rapport(): return Suivi.WORKFLOW['resume'] date_bilan = suivi.date_dernier_bilan() prochain_rapport = suivi.date_prochain_rapport() if date_bilan and prochain_rapport and (prochain_rapport - date_bilan) < timedelta(days=4 * 30): return Suivi.WORKFLOW['resume'] else: return Suivi.WORKFLOW['bilan_suivant'] def delai_depuis(self, suivi, date_base): """Délai de cette étape à partir de la date date_base (en général date précédente étape).""" if not date_base: return None base = suivi.date_dernier_bilan() or date_base delai = self.delai_niveau3 if suivi.famille.niveau_actuel() == 3 else self.delai_standard return base + timedelta(days=delai) class EtapeResume(Etape): def date(self, suivi): """Date du dernier rapport""" return suivi.date_dernier_rapport() class EtapeFin(Etape): delai_standard = 365 + 180 # 18 mois delai_niveau3 = 270 # 9 mois def delai_depuis(self, suivi, *args): if not suivi.date_debut_suivi: return None debut_suivi = suivi.debut_suivi_selon_niveau delai = self.delai_niveau3 if suivi.famille.niveau_actuel() == 3 else self.delai_standard return debut_suivi + timedelta(days=delai) Equipe = namedtuple("Equipe", ['code', 'nom', 'perm']) EQUIPES = [ Equipe(code='montagnes', nom='Montagnes et V-d-T', perm=None), Equipe(code='littoral', nom='Littoral et V-d-R', perm=None), # Anciennes équipes, conservées pour anciens dossiers Equipe(code='neuch_ville', nom='Neuchâtel-ville (archives)', perm=None), Equipe(code='litt_est', nom='Littoral Est (archives)', perm=None), Equipe(code='litt_ouest', nom='Littoral Ouest (archives)', perm=None), ] class Suivi(models.Model): WORKFLOW = OrderedDict([ ('demande', Etape( 1, 'demande', 'dem', 'Demande déposée', 'debut_evaluation', 0, 'demande', 'demande', True )), ('debut_evaluation', Etape( 2, 'debut_evaluation', 'deb_eva', "Début de l’évaluation", 'fin_evaluation', 40, 'demande', 'demande', True )), ('fin_evaluation', Etape( 3, 'fin_evaluation', "fin_eva", "Fin de l’évaluation", 'debut_suivi', 40, 'debut_evaluation', 'debut_evaluation', True )), ('debut_suivi', EtapeDebutSuivi( 4, 'debut_suivi', "dsuiv", 'Début du suivi', 'bilan_suivant', 40, 'fin_evaluation', 'fin_evaluation', False )), ('bilan_suivant', EtapeBilan( 5, 'bilan_suivant', "bil", 'Bilan suivant', 'resume', 90, 'debut_suivi', 'fin_evaluation', False )), ('resume', EtapeResume( 6, 'resume', 'rés', 'Résumé', 'fin_suivi', 90, 'bilan_suivant', 'fin_evaluation', False )), ('fin_suivi', EtapeFin( 7, 'fin_suivi', "fsuiv", 'Fin du suivi', 'archivage', 365 + 180, 'resume', 'fin_evaluation', True )), ('archivage', Etape( 8, 'arch_dossier', 'arch', 'Archivage du dossier', None, 0, 'fin_suivi', 'fin_suivi', False )), ]) EQUIPES_CHOICES = [(equ.code, equ.nom) for equ in EQUIPES] famille = models.OneToOneField(Famille, on_delete=models.CASCADE) equipe = models.CharField('Équipe', max_length=15, choices=EQUIPES_CHOICES) heure_coord = models.BooleanField("Heure de coordination", default=False) difficultes = models.TextField("Difficultés", blank=True) aides = models.TextField("Aides souhaitées", blank=True) competences = models.TextField('Ressources/Compétences', blank=True) dates_demande = models.CharField('Dates', max_length=128, blank=True) autres_contacts = models.TextField("Autres services contactés", blank=True) disponibilites = models.TextField('Disponibilités', blank=True) remarque = models.TextField(blank=True) remarque_privee = models.TextField('Remarque privée', blank=True) service_orienteur = models.CharField( "Orienté vers l’AEMO par", max_length=15, choices=choices.SERVICE_ORIENTEUR_CHOICES, blank=True ) service_annonceur = models.CharField('Service annonceur', max_length=60, blank=True) motif_demande = ChoiceArrayField( models.CharField(max_length=60, choices=choices.MOTIF_DEMANDE_CHOICES), verbose_name="Motif de la demande", blank=True, null=True) motif_detail = models.TextField('Motif', blank=True) # Référents intervenants = models.ManyToManyField( Utilisateur, through='Intervenant', related_name='interventions', blank=True ) ope_referent = models.ForeignKey(Contact, blank=True, null=True, related_name='+', on_delete=models.SET_NULL, verbose_name='as. OPE') ope_referent_2 = models.ForeignKey( Contact, blank=True, null=True, related_name='+', on_delete=models.SET_NULL, verbose_name='as. OPE 2' ) mandat_ope = ChoiceArrayField( models.CharField(max_length=65, choices=choices.MANDATS_OPE_CHOICES, blank=True), verbose_name="Mandat OPE", blank=True, null=True, ) sse_referent = models.ForeignKey(Contact, blank=True, null=True, related_name='+', on_delete=models.SET_NULL, verbose_name='SSE') referent_note = models.TextField('Autres contacts', blank=True) collaboration = models.TextField('Collaboration', blank=True) ressource = models.TextField('Ressource', blank=True) crise = models.TextField('Gestion de crise', blank=True) date_demande = models.DateField("Demande déposée le", blank=True, null=True, default=None) date_debut_evaluation = models.DateField("Début de l’évaluation le", blank=True, null=True, default=None) date_fin_evaluation = models.DateField("Fin de l’évaluation le", blank=True, null=True, default=None) date_debut_suivi = models.DateField("Début du suivi le", blank=True, null=True, default=None) date_fin_suivi = models.DateField("Fin du suivi le", blank=True, null=True, default=None) demande_prioritaire = models.BooleanField("Demande prioritaire", default=False) demarche = ChoiceArrayField(models.CharField(max_length=60, choices=choices.DEMARCHE_CHOICES, blank=True), verbose_name="Démarche", blank=True, null=True) pers_famille_presentes = models.CharField('Membres famille présents', max_length=200, blank=True) ref_presents = models.CharField('Intervenants présents', max_length=250, blank=True) autres_pers_presentes = models.CharField('Autres pers. présentes', max_length=100, blank=True) motif_fin_suivi = models.CharField('Motif de fin de suivi', max_length=20, choices=choices.MOTIFS_FIN_SUIVI_CHOICES, blank=True) def __str__(self): return 'Suivi pour la famille {} '.format(self.famille) @property def date_fin_theorique(self): if self.date_fin_suivi: return self.date_fin_suivi if self.date_debut_suivi is None: return None return self.debut_suivi_selon_niveau + timedelta(days=548) # env. 18 mois @cached_property def debut_suivi_selon_niveau(self): """ Date de début de suivi tenant compte d'éventuel changement de niveau entre 2 et 3. """ debut = self.date_debut_suivi if debut is None: return debut niv_prec = None for niv in sorted(self.famille.niveaux.all(), key=lambda niv: niv.date_debut): if ( (niv_prec in [1, 2] and niv.niveau_interv == 3) or (niv_prec == 3 and niv.niveau_interv in [1, 2]) ): debut = niv.date_debut niv_prec = niv.niveau_interv return debut def date_prochain_rapport(self): if self.date_debut_suivi is None or self.date_fin_suivi is not None: return None date_dernier_rapport = self.date_dernier_rapport() niveau = self.famille.niveau_actuel() if date_dernier_rapport is None: # Premier à 9 ou 18 mois, selon niveau delai = 30 * 9 if niveau == 3 else 365 + 182 return self.date_debut_suivi + timedelta(days=delai) else: # Suivants tous les 9 ou 12 mois, selon niveau delai = 30 * 9 if niveau == 3 else 365 return date_dernier_rapport + timedelta(days=delai) def date_dernier_rapport(self): # Using rapports.all() to leverage prefetchs debut_suivi = self.debut_suivi_selon_niveau return max([ rapp.date for rapp in self.famille.rapports.all() if rapp.date > debut_suivi ], default=None) def date_dernier_bilan(self): debut_suivi = self.debut_suivi_selon_niveau # Using bilans.all() to leverage prefetchs return max([ bilan.date for bilan in self.famille.bilans.all() if bilan.date > debut_suivi ], default=None) @property def ope_referent_display(self): return self.ope_referent.nom_prenom if self.ope_referent else '-' @cached_property def etape(self): """L’étape *terminée* du suivi.""" for key, etape in reversed(self.WORKFLOW.items()): if key != 'archivage': if etape.date(self): return etape @cached_property def etape_suivante(self): return self.etape.etape_suivante(self) if self.etape else None def date_suivante(self): etape_date = self.etape.date(self) if not self.etape_suivante or not etape_date: return None return self.etape_suivante.delai_depuis(self, etape_date) def get_mandat_ope_display(self): dic = dict(choices.MANDATS_OPE_CHOICES) return '; '.join([dic[value] for value in self.mandat_ope]) if self.mandat_ope else '-' def get_motif_demande_display(self): dic = dict(choices.MOTIF_DEMANDE_CHOICES) return '; '.join([dic[value] for value in self.motif_demande]) if self.motif_demande else '' @property def ope_referents(self): return [ope for ope in [self.ope_referent, self.ope_referent_2] if ope] class IntervenantManager(models.Manager): def actifs(self, _date=None): return self.filter(Q(date_fin__isnull=True) | Q(date_fin__gt=_date or date.today())) class Intervenant(models.Model): """ Modèle M2M entre Suivi et Utilisateur (utilisé par through de Suivi.intervenants). """ suivi = models.ForeignKey(Suivi, on_delete=models.CASCADE) intervenant = models.ForeignKey(Utilisateur, on_delete=models.CASCADE) role = models.ForeignKey(Role, on_delete=models.CASCADE) date_debut = models.DateField('Date début', default=timezone.now) # date_fin est utilisé pour les interventions s'arrêtant avant la fin du suivi. date_fin = models.DateField('Date fin', null=True, blank=True) objects = IntervenantManager() def __str__(self): return '{}, {} pour {}'.format(self.intervenant, self.role, self.suivi) def can_edit(self, user): return self.suivi.famille.can_edit(user)