X-Git-Url: http://git.auf.org/?p=auf_rh_dae.git;a=blobdiff_plain;f=project%2Frh%2Fmodels.py;h=3c7a53896efbd019ddc3877e1a84b82be6b1bfbc;hp=48c1cf1145f21fb61b85fb9b51dc14b985802177;hb=9f6c277ce74205b30ddca1624c727c838644fa6e;hpb=7bf286943dc9b66fe1ba7eb1fe8034adb8853956;ds=sidebyside diff --git a/project/rh/models.py b/project/rh/models.py index 48c1cf1..3c7a538 100644 --- a/project/rh/models.py +++ b/project/rh/models.py @@ -4,36 +4,41 @@ import datetime from datetime import date from decimal import Decimal +import reversion from auf.django.emploi.models import \ GENRE_CHOICES, SITUATION_CHOICES # devrait plutot être dans references -from auf.django.metadata.models import AUFMetadata -from auf.django.metadata.managers import NoDeleteManager from auf.django.references import models as ref +from django.contrib.auth.models import User from django.core.files.storage import FileSystemStorage +from django.core.exceptions import MultipleObjectsReturned from django.db import models from django.db.models import Q +from django.db.models.signals import post_save, pre_save from django.conf import settings -from change_list import \ +from project.rh.change_list import \ RechercheTemporelle, KEY_STATUT, STATUT_ACTIF, STATUT_INACTIF, \ STATUT_FUTUR -from managers import \ - PosteManager, DossierManager, DossierComparaisonManager, \ - PosteComparaisonManager, DeviseManager, ServiceManager, \ - TypeRemunerationManager -from validators import validate_date_passee +from project import groups +from project.rh.managers import ( + PosteManager, + DossierManager, + EmployeManager, + DossierComparaisonManager, + PosteComparaisonManager, + ContratManager, + RemunerationManager, + ArchivableManager, + ) + +TWOPLACES = Decimal('0.01') -# Gruick hack pour déterminer d'ou provient l'instanciation d'une classe -# pour l'héritage. Cela permet de faire du dynamic loading par app sans -# avoir à redéfinir dans DAE la FK -def app_context(): - import inspect - models_stack = [s[1].split('/')[-2] - for s in inspect.stack() - if s[1].endswith('models.py')] - return models_stack[-1] +from project.rh.validators import validate_date_passee +# import pour relocaliser le modèle selon la convention (models.py pour +# introspection) +from project.rh.historique import ModificationTraite # Constantes HELP_TEXT_DATE = "format: jj-mm-aaaa" @@ -48,6 +53,9 @@ storage_prive = FileSystemStorage(settings.PRIVE_MEDIA_ROOT, base_url=settings.PRIVE_MEDIA_URL) +class RemunIntegrityException(Exception): + pass + def poste_piece_dispatch(instance, filename): path = "%s/poste/%s/%s" % ( instance._meta.app_label, instance.poste_id, filename @@ -76,6 +84,30 @@ def contrat_dispatch(instance, filename): return path +class DateActiviteMixin(models.Model): + """ + Mixin pour mettre à jour l'activité d'un modèle + """ + class Meta: + abstract = True + date_creation = models.DateTimeField(auto_now_add=True, + null=True, blank=True, + verbose_name=u"Date de création",) + date_modification = models.DateTimeField(auto_now=True, + null=True, blank=True, + verbose_name=u"Date de modification",) + + +class Archivable(models.Model): + archive = models.BooleanField(u'archivé', default=False) + + objects = ArchivableManager() + avec_archives = models.Manager() + + class Meta: + abstract = True + + class DevisableMixin(object): def get_annee_pour_taux_devise(self): @@ -91,39 +123,32 @@ class DevisableMixin(object): return 1 annee = self.get_annee_pour_taux_devise() - taux = [ - tc.taux - for tc in TauxChange.objects.filter(devise=devise, annee=annee) - ] - taux = set(taux) - - if len(taux) == 0: - raise Exception( - u"Pas de taux pour %s en %s" % (devise.code, annee) - ) + taux = TauxChange.objects.filter(devise=devise, annee__lte=annee) \ + .order_by('-annee') + return taux[0].taux - if len(taux) > 1: - raise Exception(u"Il existe plusieurs taux de %s en %s" % - (devise.code, annee)) - else: - return list(taux)[0] - - def montant_euros(self): + def montant_euros_float(self): try: taux = self.taux_devise() except Exception, e: return e if not taux: return None - return int(round(float(self.montant) * float(taux), 2)) + return float(self.montant) * float(taux) + def montant_euros(self): + return int(round(self.montant_euros_float(), 2)) -class Commentaire(AUFMetadata): + +class Commentaire(models.Model): texte = models.TextField() owner = models.ForeignKey( 'auth.User', db_column='owner', related_name='+', verbose_name=u"Commentaire de" ) + date_creation = models.DateTimeField( + u'date', auto_now_add=True, blank=True, null=True + ) class Meta: abstract = True @@ -141,7 +166,7 @@ POSTE_APPEL_CHOICES = ( ) -class Poste_(AUFMetadata): +class Poste_( DateActiviteMixin, models.Model,): """ Un Poste est un emploi (job) à combler dans une implantation. Un Poste peut être comblé par un Employe, auquel cas un Dossier est créé. @@ -166,7 +191,7 @@ class Poste_(AUFMetadata): null=True, verbose_name=u"type de poste" ) service = models.ForeignKey( - 'Service', db_column='service', related_name='+', + 'Service', db_column='service', related_name='%(app_label)s_postes', verbose_name=u"direction/service/pôle support", null=True ) responsable = models.ForeignKey( @@ -231,22 +256,22 @@ class Poste_(AUFMetadata): 'Devise', db_column='devise_max', null=True, related_name='+' ) salaire_min = models.DecimalField( - max_digits=12, decimal_places=2, null=True, default=0 + max_digits=12, decimal_places=2, default=0, ) salaire_max = models.DecimalField( - max_digits=12, decimal_places=2, null=True, default=0 + max_digits=12, decimal_places=2, default=0, ) indemn_min = models.DecimalField( - max_digits=12, decimal_places=2, null=True, default=0 + max_digits=12, decimal_places=2, default=0, ) indemn_max = models.DecimalField( - max_digits=12, decimal_places=2, null=True, default=0 + max_digits=12, decimal_places=2, default=0, ) autre_min = models.DecimalField( - max_digits=12, decimal_places=2, null=True, default=0 + max_digits=12, decimal_places=2, default=0, ) autre_max = models.DecimalField( - max_digits=12, decimal_places=2, null=True, default=0 + max_digits=12, decimal_places=2, default=0, ) # Comparatifs de rémunération @@ -290,10 +315,12 @@ class Poste_(AUFMetadata): # Autres Metadata date_debut = models.DateField( - u"date de début", help_text=HELP_TEXT_DATE, null=True, blank=True + u"date de début", help_text=HELP_TEXT_DATE, null=True, blank=True, + db_index=True ) date_fin = models.DateField( - u"date de fin", help_text=HELP_TEXT_DATE, null=True, blank=True + u"date de fin", help_text=HELP_TEXT_DATE, null=True, blank=True, + db_index=True ) class Meta: @@ -309,10 +336,10 @@ class Poste_(AUFMetadata): ) return representation - prefix_implantation = "implantation__region" + prefix_implantation = "implantation__zone_administrative" - def get_regions(self): - return [self.implantation.region] + def get_zones_administratives(self): + return [self.implantation.zone_administrative] def get_devise(self): vp = ValeurPoint.objects.filter( @@ -344,11 +371,15 @@ class Poste(Poste_): UTILISE pour mettre a jour le flag vacant """ return [ - d.employe for d in self.rh_dossiers - .filter(supprime=False) - .exclude(date_fin__lt=date.today()) + d.employe + for d in self.rh_dossiers.exclude(date_fin__lt=date.today()) ] +reversion.register(Poste, format='xml', follow=[ + 'rh_financements', 'rh_pieces', 'rh_comparaisons_internes', + 'commentaires' +]) + POSTE_FINANCEMENT_CHOICES = ( ('A', 'A - Frais de personnel'), @@ -362,10 +393,6 @@ class PosteFinancement_(models.Model): Pour un Poste, structure d'informations décrivant comment on prévoit financer ce Poste. """ - poste = models.ForeignKey( - '%s.Poste' % app_context(), db_column='poste', - related_name='%(app_label)s_financements' - ) type = models.CharField(max_length=1, choices=POSTE_FINANCEMENT_CHOICES) pourcentage = models.DecimalField( max_digits=12, decimal_places=2, @@ -387,7 +414,11 @@ class PosteFinancement_(models.Model): class PosteFinancement(PosteFinancement_): - pass + poste = models.ForeignKey( + Poste, db_column='poste', related_name='rh_financements' + ) + +reversion.register(PosteFinancement, format='xml') class PostePiece_(models.Model): @@ -395,13 +426,10 @@ class PostePiece_(models.Model): Documents relatifs au Poste. Ex.: Description de poste """ - poste = models.ForeignKey( - '%s.Poste' % app_context(), db_column='poste', - related_name='%(app_label)s_pieces' - ) nom = models.CharField(u"Nom", max_length=255) fichier = models.FileField( - u"Fichier", upload_to=poste_piece_dispatch, storage=storage_prive + u"Fichier", upload_to=poste_piece_dispatch, storage=storage_prive, + max_length=255 ) class Meta: @@ -413,18 +441,18 @@ class PostePiece_(models.Model): class PostePiece(PostePiece_): - pass + poste = models.ForeignKey( + Poste, db_column='poste', related_name='rh_pieces' + ) + +reversion.register(PostePiece, format='xml') -class PosteComparaison_(AUFMetadata, DevisableMixin): +class PosteComparaison_(models.Model, DevisableMixin): """ De la même manière qu'un dossier, un poste peut-être comparé à un autre poste. """ - poste = models.ForeignKey( - '%s.Poste' % app_context(), - related_name='%(app_label)s_comparaisons_internes' - ) objects = PosteComparaisonManager() implantation = models.ForeignKey( @@ -444,25 +472,23 @@ class PosteComparaison_(AUFMetadata, DevisableMixin): class PosteComparaison(PosteComparaison_): - objects = NoDeleteManager() - - -class PosteCommentaire_(Commentaire): poste = models.ForeignKey( - '%s.Poste' % app_context(), db_column='poste', related_name='+' + Poste, related_name='rh_comparaisons_internes' ) - class Meta: - abstract = True +reversion.register(PosteComparaison, format='xml') -class PosteCommentaire(PosteCommentaire_): - pass +class PosteCommentaire(Commentaire): + poste = models.ForeignKey( + Poste, db_column='poste', related_name='commentaires' + ) +reversion.register(PosteCommentaire, format='xml') ### EMPLOYÉ/PERSONNE -class Employe(AUFMetadata): +class Employe(models.Model): """ Personne occupant ou ayant occupé un Poste. Un Employe aura autant de Dossiers qu'il occupe ou a occupé de Postes. @@ -470,6 +496,9 @@ class Employe(AUFMetadata): Cette classe aurait pu avantageusement s'appeler Personne car la notion d'employé n'a pas de sens si aucun Dossier n'existe pour une personne. """ + + objects = EmployeManager() + # Identification nom = models.CharField(max_length=255) prenom = models.CharField(u"prénom", max_length=255) @@ -512,6 +541,9 @@ class Employe(AUFMetadata): ref.Pays, to_field='code', db_column='pays', related_name='employes', null=True, blank=True ) + courriel_perso = models.EmailField( + u'adresse courriel personnelle', blank=True + ) # meta dématérialisation : pour permettre le filtrage nb_postes = models.IntegerField(u"nombre de postes", null=True, blank=True) @@ -524,6 +556,18 @@ class Employe(AUFMetadata): def __unicode__(self): return u'%s %s [%s]' % (self.nom.upper(), self.prenom, self.id) + def get_latest_dossier_ordered_by_date_fin_and_principal(self): + res = self.rh_dossiers.order_by( + '-principal', 'date_fin') + + # Retourne en le premier du queryset si la date de fin est None + # Sinon, retourne le plus récent selon la date de fin. + first = res[0] + if first.date_fin == None: + return first + else: + return res.order_by('-principal', '-date_fin')[0] + def civilite(self): civilite = u'' if self.genre.upper() == u'M': @@ -542,25 +586,70 @@ class Employe(AUFMetadata): def dossiers_passes(self): params = {KEY_STATUT: STATUT_INACTIF, } - search = RechercheTemporelle(params, self.__class__) + search = RechercheTemporelle(params, Dossier) search.purge_params(params) q = search.get_q_temporel(self.rh_dossiers) return self.rh_dossiers.filter(q) def dossiers_futurs(self): params = {KEY_STATUT: STATUT_FUTUR, } - search = RechercheTemporelle(params, self.__class__) + search = RechercheTemporelle(params, Dossier) search.purge_params(params) q = search.get_q_temporel(self.rh_dossiers) return self.rh_dossiers.filter(q) def dossiers_encours(self): params = {KEY_STATUT: STATUT_ACTIF, } - search = RechercheTemporelle(params, self.__class__) + search = RechercheTemporelle(params, Dossier) search.purge_params(params) q = search.get_q_temporel(self.rh_dossiers) return self.rh_dossiers.filter(q) + def dossier_principal_pour_annee(self): + return self.dossier_principal(pour_annee=True) + + def dossier_principal(self, pour_annee=False): + """ + Retourne le dossier principal (ou le plus ancien si il y en a + plusieurs) + + Si pour_annee == True, retourne le ou les dossiers principaux + pour l'annee en cours, sinon, le ou les dossiers principaux + pour la journee en cours. + + TODO: (Refactoring possible): Utiliser meme logique dans + dae/templatetags/dae.py + """ + + today = date.today() + if pour_annee: + year = today.year + year_start = date(year, 1, 1) + year_end = date(year, 12, 31) + + try: + dossier = self.rh_dossiers.filter( + (Q(date_debut__lte=year_end, date_fin__isnull=True) | + Q(date_debut__isnull=True, date_fin__gte=year_start) | + Q(date_debut__lte=year_end, date_fin__gte=year_start) | + Q(date_debut__isnull=True, date_fin__isnull=True)) & + Q(principal=True)).order_by('date_debut')[0] + except IndexError, Dossier.DoesNotExist: + dossier = None + return dossier + else: + try: + dossier = self.rh_dossiers.filter( + (Q(date_debut__lte=today, date_fin__isnull=True) | + Q(date_debut__isnull=True, date_fin__gte=today) | + Q(date_debut__lte=today, date_fin__gte=today) | + Q(date_debut__isnull=True, date_fin__isnull=True)) & + Q(principal=True)).order_by('date_debut')[0] + except IndexError, Dossier.DoesNotExist: + dossier = None + return dossier + + def postes_encours(self): postes_encours = set() for d in self.dossiers_encours(): @@ -573,6 +662,7 @@ class Employe(AUFMetadata): Idée derrière : si on ajout d'autre Dossiers, c'est pour des Postes secondaires. """ + # DEPRECATED : on a maintenant Dossier.principal poste = Poste.objects.none() try: poste = self.dossiers_encours().order_by('date_debut')[0].poste @@ -580,13 +670,18 @@ class Employe(AUFMetadata): pass return poste - prefix_implantation = "rh_dossiers__poste__implantation__region" + prefix_implantation = \ + "rh_dossiers__poste__implantation__zone_administrative" + + def get_zones_administratives(self): + return [ + d.poste.implantation.zone_administrative + for d in self.dossiers.all() + ] - def get_regions(self): - regions = [] - for d in self.dossiers.all(): - regions.append(d.poste.implantation.region) - return regions +reversion.register(Employe, format='xml', follow=[ + 'pieces', 'commentaires', 'ayantdroits' +]) class EmployePiece(models.Model): @@ -600,7 +695,8 @@ class EmployePiece(models.Model): ) nom = models.CharField(max_length=255) fichier = models.FileField( - u"fichier", upload_to=employe_piece_dispatch, storage=storage_prive + u"fichier", upload_to=employe_piece_dispatch, storage=storage_prive, + max_length=255 ) class Meta: @@ -611,16 +707,20 @@ class EmployePiece(models.Model): def __unicode__(self): return u'%s' % (self.nom) +reversion.register(EmployePiece, format='xml') + class EmployeCommentaire(Commentaire): employe = models.ForeignKey( - 'Employe', db_column='employe', related_name='+' + 'Employe', db_column='employe', related_name='commentaires' ) class Meta: verbose_name = u"Employé commentaire" verbose_name_plural = u"Employé commentaires" +reversion.register(EmployeCommentaire, format='xml') + LIEN_PARENTE_CHOICES = ( ('Conjoint', 'Conjoint'), @@ -630,7 +730,7 @@ LIEN_PARENTE_CHOICES = ( ) -class AyantDroit(AUFMetadata): +class AyantDroit(models.Model): """ Personne en relation avec un Employe. """ @@ -669,20 +769,25 @@ class AyantDroit(AUFMetadata): def __unicode__(self): return u'%s %s' % (self.nom.upper(), self.prenom, ) - prefix_implantation = "employe__dossiers__poste__implantation__region" + prefix_implantation = \ + "employe__dossiers__poste__implantation__zone_administrative" - def get_regions(self): - regions = [] - for d in self.employe.dossiers.all(): - regions.append(d.poste.implantation.region) - return regions + def get_zones_administratives(self): + return [ + d.poste.implantation.zone_administrative + for d in self.employe.dossiers.all() + ] + +reversion.register(AyantDroit, format='xml', follow=['commentaires']) class AyantDroitCommentaire(Commentaire): ayant_droit = models.ForeignKey( - 'AyantDroit', db_column='ayant_droit', related_name='+' + 'AyantDroit', db_column='ayant_droit', related_name='commentaires' ) +reversion.register(AyantDroitCommentaire, format='xml') + ### DOSSIER @@ -698,7 +803,7 @@ COMPTE_COMPTA_CHOICES = ( ) -class Dossier_(AUFMetadata, DevisableMixin): +class Dossier_(DateActiviteMixin, models.Model, DevisableMixin,): """ Le Dossier regroupe les informations relatives à l'occupation d'un Poste par un Employe. Un seul Dossier existe par Poste occupé @@ -750,13 +855,25 @@ class Dossier_(AUFMetadata, DevisableMixin): ) # Occupation du Poste par cet Employe (anciennement "mandat") - date_debut = models.DateField(u"date de début d'occupation de poste") + date_debut = models.DateField( + u"date de début d'occupation de poste", db_index=True + ) date_fin = models.DateField( - u"Date de fin d'occupation de poste", null=True, blank=True + u"Date de fin d'occupation de poste", null=True, blank=True, + db_index=True ) + # Meta-data: + est_cadre = models.BooleanField( + u"Est un cadre?", + default=False, + ) + # Comptes - # TODO? + compte_compta = models.CharField(max_length=10, default='aucun', + verbose_name=u'Compte comptabilité', + choices=COMPTE_COMPTA_CHOICES) + compte_courriel = models.BooleanField() class Meta: abstract = True @@ -780,10 +897,10 @@ class Dossier_(AUFMetadata, DevisableMixin): poste = self.poste.nom_feminin return u'%s - %s' % (self.employe, poste) - prefix_implantation = "poste__implantation__region" + prefix_implantation = "poste__implantation__zone_administrative" - def get_regions(self): - return [self.poste.implantation.region] + def get_zones_administratives(self): + return [self.poste.implantation.zone_administrative] def remunerations(self): key = "%s_remunerations" % self._meta.app_label @@ -964,24 +1081,173 @@ class Dossier_(AUFMetadata, DevisableMixin): total += r.montant_euros() return total + def premier_contrat(self): + """contrat avec plus petite date de début""" + try: + contrat = self.rh_contrats.exclude(date_debut=None) \ + .order_by('date_debut')[0] + except IndexError, Contrat.DoesNotExist: + contrat = None + return contrat + + def dernier_contrat(self): + """contrat avec plus grande date de fin""" + try: + contrat = self.rh_contrats.exclude(date_debut=None) \ + .order_by('-date_debut')[0] + except IndexError, Contrat.DoesNotExist: + contrat = None + return contrat + + def actif(self): + today = date.today() + return (self.date_debut is None or self.date_debut <= today) \ + and (self.date_fin is None or self.date_fin >= today) \ + and not (self.date_fin is None and self.date_debut is None) + class Dossier(Dossier_): __doc__ = Dossier_.__doc__ - poste = models.ForeignKey('%s.Poste' % app_context(), - db_column='poste', - related_name='%(app_label)s_dossiers', + poste = models.ForeignKey( + Poste, db_column='poste', related_name='rh_dossiers', help_text=u"Taper le nom du poste ou du type de poste", - ) + ) employe = models.ForeignKey( 'Employe', db_column='employe', help_text=u"Taper le nom de l'employé", - related_name='%(app_label)s_dossiers', verbose_name=u"employé") + related_name='rh_dossiers', verbose_name=u"employé" + ) principal = models.BooleanField( - u"Principal?", default=True, + u"dossier principal", default=True, help_text=( u"Ce dossier est pour le principal poste occupé par l'employé" ) ) + + +reversion.register(Dossier, format='xml', follow=[ + 'rh_dossierpieces', 'rh_comparaisons', 'rh_remunerations', + 'rh_contrats', 'commentaires' +]) + + +class RHDossierClassementRecord(models.Model): + classement = models.ForeignKey( + 'Classement', + related_name='classement_records', + ) + dossier = models.ForeignKey( + 'Dossier', + related_name='classement_records', + ) + date_debut = models.DateField( + u"date de début", + help_text=HELP_TEXT_DATE, + null=True, + blank=True, + db_index=True + ) + date_fin = models.DateField( + u"date de fin", + help_text=HELP_TEXT_DATE, + null=True, + blank=True, + db_index=True + ) + commentaire = models.CharField( + max_length=2048, + blank=True, + null=True, + default='', + ) + + def __unicode__(self): + return self.classement.__unicode__() + + class Meta: + verbose_name = u"Element d'historique de classement" + verbose_name_plural = u"Historique de classement" + + @classmethod + def post_save_handler(cls, + sender, + instance, + created, + using, + **kw): + + today = date.today() + previous_record = None + previous_classement = None + has_changed = False + + # Premièrement, pour les nouvelles instances: + if created: + if not instance.classement: + return + else: + cls.objects.create( + date_debut=instance.date_debut, + classement=instance.classement, + dossier=instance, + ) + return + + # Deuxièmement, pour les instances existantes: + + # Détermine si: + # 1. Est-ce que le classement a changé? + # 2. Est-ce qu'une historique de classement existe déjà + try: + previous_record = cls.objects.get( + dossier=instance, + classement=instance.before_save.classement, + date_fin=None, + ) + except cls.DoesNotExist: + if instance.before_save.classement: + # Il était censé avoir une historique de classement + # donc on le créé. + previous_record = cls.objects.create( + date_debut=instance.before_save.date_debut, + classement=instance.before_save.classement, + dossier=instance, + ) + previous_classement = instance.before_save.classement + except MultipleObjectsReturned: + qs = cls.objects.filter( + dossier=instance, + classement=instance.before_save.classement, + date_fin=None, + ) + latest = qs.latest('date_debut') + qs.exclude(id=latest.id).update(date_fin=today) + previous_record = latest + previous_classement = latest.classement + else: + previous_classement = previous_record.classement + + has_changed = ( + instance.classement != + previous_classement + ) + + # Cas aucun changement: + if not has_changed: + return + + else: + # Classement a changé + if previous_record: + previous_record.date_fin = today + previous_record.save() + + if instance.classement: + cls.objects.create( + date_debut=today, + classement=instance.classement, + dossier=instance, + ) class DossierPiece_(models.Model): @@ -989,13 +1255,10 @@ class DossierPiece_(models.Model): Documents relatifs au Dossier (à l'occupation de ce poste par employé). Ex.: Lettre de motivation. """ - dossier = models.ForeignKey( - '%s.Dossier' % app_context(), - db_column='dossier', related_name='%(app_label)s_dossierpieces' - ) nom = models.CharField(max_length=255) fichier = models.FileField( - upload_to=dossier_piece_dispatch, storage=storage_prive + upload_to=dossier_piece_dispatch, storage=storage_prive, + max_length=255 ) class Meta: @@ -1007,29 +1270,24 @@ class DossierPiece_(models.Model): class DossierPiece(DossierPiece_): - pass - - -class DossierCommentaire_(Commentaire): dossier = models.ForeignKey( - '%s.Dossier' % app_context(), db_column='dossier', related_name='+' + Dossier, db_column='dossier', related_name='rh_dossierpieces' ) - class Meta: - abstract = True +reversion.register(DossierPiece, format='xml') +class DossierCommentaire(Commentaire): + dossier = models.ForeignKey( + Dossier, db_column='dossier', related_name='commentaires' + ) -class DossierCommentaire(DossierCommentaire_): - pass +reversion.register(DossierCommentaire, format='xml') class DossierComparaison_(models.Model, DevisableMixin): """ Photo d'une comparaison salariale au moment de l'embauche. """ - dossier = models.ForeignKey( - '%s.Dossier' % app_context(), related_name='%(app_label)s_comparaisons' - ) objects = DossierComparaisonManager() implantation = models.ForeignKey( @@ -1050,16 +1308,16 @@ class DossierComparaison_(models.Model, DevisableMixin): class DossierComparaison(DossierComparaison_): - pass + dossier = models.ForeignKey( + Dossier, related_name='rh_comparaisons' + ) + +reversion.register(DossierComparaison, format='xml') ### RÉMUNÉRATION -class RemunerationMixin(AUFMetadata): - dossier = models.ForeignKey( - '%s.Dossier' % app_context(), db_column='dossier', - related_name='%(app_label)s_remunerations' - ) +class RemunerationMixin(models.Model): # Identification type = models.ForeignKey( @@ -1072,8 +1330,7 @@ class RemunerationMixin(AUFMetadata): null=True, blank=True ) montant = models.DecimalField( - null=True, blank=True, - default=0, max_digits=12, decimal_places=2 + null=True, blank=True, max_digits=12, decimal_places=2 ) # Annuel (12 mois, 52 semaines, 364 jours?) devise = models.ForeignKey('Devise', db_column='devise', related_name='+') @@ -1081,8 +1338,12 @@ class RemunerationMixin(AUFMetadata): commentaire = models.CharField(max_length=255, null=True, blank=True) # date_debut = anciennement date_effectif - date_debut = models.DateField(u"date de début", null=True, blank=True) - date_fin = models.DateField(u"date de fin", null=True, blank=True) + date_debut = models.DateField( + u"date de début", null=True, blank=True, db_index=True + ) + date_fin = models.DateField( + u"date de fin", null=True, blank=True, db_index=True + ) class Meta: abstract = True @@ -1098,7 +1359,66 @@ class Remuneration_(RemunerationMixin, DevisableMixin): pour un Dossier. Si un Evenement existe, utiliser la structure de rémunération EvenementRemuneration de cet événement. """ + objects = RemunerationManager() + + @staticmethod + def find_yearly_range(from_date, to_date, year): + today = date.today() + year = year or date.today().year + year_start = date(year, 1, 1) + year_end = date(year, 12, 31) + + def constrain_to_year(*dates): + """ + S'assure que les dates soient dans le range year_start a + year_end + """ + return [min(max(year_start, d), year_end) + for d in dates] + + start_date = max( + from_date or year_start, year_start) + end_date = min( + to_date or year_end, year_end) + + start_date, end_date = constrain_to_year(start_date, end_date) + + jours_annee = (year_end - year_start).days + jours_dates = (end_date - start_date).days + factor = Decimal(str(jours_dates)) / Decimal(str(jours_annee)) + + return start_date, end_date, factor + + + def montant_ajuste_euros(self, annee=None): + """ + Le montant ajusté représente le montant annuel, ajusté sur la + période de temps travaillée, multipliée par le ratio de temps + travaillé (en rapport au temps plein). + """ + date_debut, date_fin, factor = self.find_yearly_range( + self.date_debut, + self.date_fin, + annee, + ) + montant_euros = Decimal(str(self.montant_euros_float()) or '0') + + if self.type.nature_remuneration != u'Accessoire': + dossier = getattr(self, 'dossier', None) + if not dossier: + """ + Dans le cas d'un DossierComparaisonRemuneration, il + n'y a plus de reference au dossier. + """ + regime_travail = REGIME_TRAVAIL_DEFAULT + else: + regime_travail = self.dossier.regime_travail + return (montant_euros * factor * + regime_travail / 100) + else: + return montant_euros + def montant_mois(self): return round(self.montant / 12, 2) @@ -1122,37 +1442,35 @@ class Remuneration_(RemunerationMixin, DevisableMixin): class Remuneration(Remuneration_): - pass - + dossier = models.ForeignKey( + Dossier, db_column='dossier', related_name='rh_remunerations' + ) -### CONTRATS +reversion.register(Remuneration, format='xml') -class ContratManager(NoDeleteManager): - def get_query_set(self): - return super(ContratManager, self).get_query_set() \ - .select_related('dossier', 'dossier__poste') +### CONTRATS -class Contrat_(AUFMetadata): +class Contrat_(models.Model): """ Document juridique qui encadre la relation de travail d'un Employe pour un Poste particulier. Pour un Dossier (qui documente cette relation de travail) plusieurs contrats peuvent être associés. """ objects = ContratManager() - dossier = models.ForeignKey( - '%s.Dossier' % app_context(), db_column='dossier', - related_name='%(app_label)s_contrats' - ) type_contrat = models.ForeignKey( 'TypeContrat', db_column='type_contrat', verbose_name=u'type de contrat', related_name='+' ) - date_debut = models.DateField(u"date de début") - date_fin = models.DateField(u"date de fin", null=True, blank=True) + date_debut = models.DateField( + u"date de début", db_index=True + ) + date_fin = models.DateField( + u"date de fin", null=True, blank=True, db_index=True + ) fichier = models.FileField( upload_to=contrat_dispatch, storage=storage_prive, null=True, - blank=True + blank=True, max_length=255 ) class Meta: @@ -1166,80 +1484,16 @@ class Contrat_(AUFMetadata): class Contrat(Contrat_): - pass + dossier = models.ForeignKey( + Dossier, db_column='dossier', related_name='rh_contrats' + ) -### ÉVÉNEMENTS - -#class Evenement_(AUFMetadata): -# """ -# Un Evenement sert à déclarer une situation temporaire (exceptionnelle) -# d'un Dossier qui vient altérer des informations normales liées à un -# Dossier (ex.: la Remuneration). -# -# Ex.: congé de maternité, maladie... -# -# Lors de ces situations exceptionnelles, l'Employe a un régime de travail -# différent et une rémunération en conséquence. On souhaite toutefois -# conserver le Dossier intact afin d'éviter une re-saisie des données lors -# du retour à la normale. -# """ -# dossier = models.ForeignKey( -# '%s.Dossier' % app_context(), db_column='dossier', -# related_name='+' -# ) -# nom = models.CharField(max_length=255) -# date_debut = models.DateField(verbose_name = u"Date de début") -# date_fin = models.DateField(verbose_name = u"Date de fin", -# null=True, blank=True) -# -# class Meta: -# abstract = True -# ordering = ['nom'] -# verbose_name = u"Évènement" -# verbose_name_plural = u"Évènements" -# -# def __unicode__(self): -# return u'%s' % (self.nom) -# -# -#class Evenement(Evenement_): -# __doc__ = Evenement_.__doc__ -# -# -#class EvenementRemuneration_(RemunerationMixin): -# """ -# Structure de rémunération liée à un Evenement qui remplace -# temporairement la Remuneration normale d'un Dossier, pour toute la durée -# de l'Evenement. -# """ -# evenement = models.ForeignKey("Evenement", db_column='evenement', -# related_name='+', -# verbose_name = u"Évènement") -# # TODO : le champ dossier hérité de Remuneration doit être dérivé -# # de l'Evenement associé -# -# class Meta: -# abstract = True -# ordering = ['evenement', 'type__nom', '-date_fin'] -# verbose_name = u"Évènement - rémunération" -# verbose_name_plural = u"Évènements - rémunérations" -# -# -#class EvenementRemuneration(EvenementRemuneration_): -# __doc__ = EvenementRemuneration_.__doc__ -# -# class Meta: -# abstract = True -# -# -#class EvenementRemuneration(EvenementRemuneration_): -# __doc__ = EvenementRemuneration_.__doc__ -# TODO? class ContratPiece(models.Model): +reversion.register(Contrat, format='xml') ### RÉFÉRENCES RH -class CategorieEmploi(AUFMetadata): +class CategorieEmploi(models.Model): """ Catégorie utilisée dans la gestion des Postes. Catégorie supérieure à TypePoste. @@ -1247,15 +1501,34 @@ class CategorieEmploi(AUFMetadata): nom = models.CharField(max_length=255) class Meta: - ordering = ['nom'] + ordering = ('nom',) verbose_name = u"catégorie d'emploi" verbose_name_plural = u"catégories d'emploi" def __unicode__(self): - return u'%s' % (self.nom) + return self.nom +reversion.register(CategorieEmploi, format='xml') -class TypePoste(AUFMetadata): + +class FamilleProfessionnelle(models.Model): + """ + Famille professionnelle d'un poste. + """ + nom = models.CharField(max_length=100) + + class Meta: + ordering = ('nom',) + verbose_name = u'famille professionnelle' + verbose_name_plural = u'familles professionnelles' + + def __unicode__(self): + return self.nom + +reversion.register(FamilleProfessionnelle, format='xml') + + +class TypePoste(Archivable): """ Catégorie de Poste. """ @@ -1268,6 +1541,10 @@ class TypePoste(AUFMetadata): CategorieEmploi, db_column='categorie_emploi', related_name='+', verbose_name=u"catégorie d'emploi" ) + famille_professionnelle = models.ForeignKey( + FamilleProfessionnelle, related_name='types_de_poste', + verbose_name=u"famille professionnelle", blank=True, null=True + ) class Meta: ordering = ['nom'] @@ -1277,35 +1554,40 @@ class TypePoste(AUFMetadata): def __unicode__(self): return u'%s' % (self.nom) +reversion.register(TypePoste, format='xml') + + TYPE_PAIEMENT_CHOICES = ( (u'Régulier', u'Régulier'), (u'Ponctuel', u'Ponctuel'), ) NATURE_REMUNERATION_CHOICES = ( - (u'Accessoire', u'Accessoire'), - (u'Charges', u'Charges'), - (u'Indemnité', u'Indemnité'), - (u'RAS', u'Rémunération autre source'), (u'Traitement', u'Traitement'), + (u'Indemnité', u'Indemnités autres'), + (u'Charges', u'Charges patronales'), + (u'Accessoire', u'Accessoires'), + (u'RAS', u'Rémunération autre source'), ) -class TypeRemuneration(AUFMetadata): +class TypeRemuneration(Archivable): """ Catégorie de Remuneration. """ - objects = TypeRemunerationManager() + + objects = models.Manager() + sans_archives = ArchivableManager() nom = models.CharField(max_length=255) type_paiement = models.CharField( u"type de paiement", max_length=30, choices=TYPE_PAIEMENT_CHOICES ) + nature_remuneration = models.CharField( u"nature de la rémunération", max_length=30, choices=NATURE_REMUNERATION_CHOICES ) - archive = models.BooleanField(verbose_name=u"Archivé", default=False) class Meta: ordering = ['nom'] @@ -1313,14 +1595,12 @@ class TypeRemuneration(AUFMetadata): verbose_name_plural = u"Types de rémunération" def __unicode__(self): - if self.archive: - archive = u"(archivé)" - else: - archive = "" - return u'%s %s' % (self.nom, archive) + return self.nom + +reversion.register(TypeRemuneration, format='xml') -class TypeRevalorisation(AUFMetadata): +class TypeRevalorisation(Archivable): """ Justification du changement de la Remuneration. (Actuellement utilisé dans aucun traitement informatique.) @@ -1335,27 +1615,24 @@ class TypeRevalorisation(AUFMetadata): def __unicode__(self): return u'%s' % (self.nom) +reversion.register(TypeRevalorisation, format='xml') -class Service(AUFMetadata): + +class Service(Archivable): """ Unité administrative où les Postes sont rattachés. """ - objects = ServiceManager() - - archive = models.BooleanField(verbose_name=u"Archivé", default=False) nom = models.CharField(max_length=255) class Meta: ordering = ['nom'] - verbose_name = u"Service" - verbose_name_plural = u"Services" + verbose_name = u"service" + verbose_name_plural = u"services" def __unicode__(self): - if self.archive: - archive = u"(archivé)" - else: - archive = "" - return u'%s %s' % (self.nom, archive) + return self.nom + +reversion.register(Service, format='xml') TYPE_ORGANISME_CHOICES = ( @@ -1364,7 +1641,7 @@ TYPE_ORGANISME_CHOICES = ( ) -class OrganismeBstg(AUFMetadata): +class OrganismeBstg(models.Model): """ Organisation d'où provient un Employe mis à disposition (MAD) de ou détaché (DET) à l'AUF à titre gratuit. @@ -1386,13 +1663,10 @@ class OrganismeBstg(AUFMetadata): def __unicode__(self): return u'%s (%s)' % (self.nom, self.get_type_display()) - prefix_implantation = "pays__region" - - def get_regions(self): - return [self.pays.region] +reversion.register(OrganismeBstg, format='xml') -class Statut(AUFMetadata): +class Statut(Archivable): """ Statut de l'Employe dans le cadre d'un Dossier particulier. """ @@ -1416,6 +1690,8 @@ class Statut(AUFMetadata): def __unicode__(self): return u'%s : %s' % (self.code, self.nom) +reversion.register(Statut, format='xml') + TYPE_CLASSEMENT_CHOICES = ( ('S', 'S -Soutien'), @@ -1433,7 +1709,7 @@ class ClassementManager(models.Manager): Ordonner les spcéfiquement les classements. """ def get_query_set(self): - qs = super(self.__class__, self).get_query_set() + qs = super(ClassementManager, self).get_query_set() qs = qs.extra(select={ 'ponderation': 'FIND_IN_SET(type,"SO,HG,S,T,P,C,D")' }) @@ -1441,7 +1717,12 @@ class ClassementManager(models.Manager): return qs.all() -class Classement_(AUFMetadata): +class ClassementArchivableManager(ClassementManager, + ArchivableManager): + pass + + +class Classement_(Archivable): """ Éléments de classement de la "Grille générique de classement hiérarchique". @@ -1452,12 +1733,13 @@ class Classement_(AUFMetadata): salaire de base = coefficient * valeur du point de l'Implantation du Poste """ objects = ClassementManager() + sans_archives = ClassementArchivableManager() # Identification type = models.CharField(max_length=10, choices=TYPE_CLASSEMENT_CHOICES) echelon = models.IntegerField(u"échelon", blank=True, default=0) degre = models.IntegerField(u"degré", blank=True, default=0) - coefficient = models.FloatField(u"coefficient", default=0, null=True) + coefficient = models.FloatField(u"coefficient", blank=True, null=True) # Méta # annee # au lieu de date_debut et date_fin @@ -1476,8 +1758,10 @@ class Classement_(AUFMetadata): class Classement(Classement_): __doc__ = Classement_.__doc__ +reversion.register(Classement, format='xml') + -class TauxChange_(AUFMetadata): +class TauxChange_(models.Model): """ Taux de change de la devise vers l'euro (EUR) pour chaque année budgétaire. @@ -1492,6 +1776,7 @@ class TauxChange_(AUFMetadata): ordering = ['-annee', 'devise__code'] verbose_name = u"Taux de change" verbose_name_plural = u"Taux de change" + unique_together = ('devise', 'annee') def __unicode__(self): return u'%s : %s € (%s)' % (self.devise, self.taux, self.annee) @@ -1500,15 +1785,17 @@ class TauxChange_(AUFMetadata): class TauxChange(TauxChange_): __doc__ = TauxChange_.__doc__ +reversion.register(TauxChange, format='xml') -class ValeurPointManager(NoDeleteManager): + +class ValeurPointManager(models.Manager): def get_query_set(self): return super(ValeurPointManager, self).get_query_set() \ - .select_related('devise', 'implantation') + .select_related('devise', 'implantation') -class ValeurPoint_(AUFMetadata): +class ValeurPoint_(models.Model): """ Utile pour connaître, pour un Dossier, le salaire de base théorique lié au classement dans la grille. La ValeurPoint s'obtient par l'implantation @@ -1517,6 +1804,7 @@ class ValeurPoint_(AUFMetadata): salaire de base = coefficient * valeur du point de l'Implantation du Poste """ + objects = models.Manager() actuelles = ValeurPointManager() valeur = models.FloatField(null=True) @@ -1532,6 +1820,7 @@ class ValeurPoint_(AUFMetadata): abstract = True verbose_name = u"Valeur du point" verbose_name_plural = u"Valeurs du point" + unique_together = ('implantation', 'annee') def __unicode__(self): return u'%s %s %s [%s] %s' % ( @@ -1543,27 +1832,28 @@ class ValeurPoint_(AUFMetadata): class ValeurPoint(ValeurPoint_): __doc__ = ValeurPoint_.__doc__ +reversion.register(ValeurPoint, format='xml') + -class Devise(AUFMetadata): +class Devise(Archivable): """ Devise monétaire. """ - objects = DeviseManager() - - archive = models.BooleanField(verbose_name=u"Archivé", default=False) code = models.CharField(max_length=10, unique=True) nom = models.CharField(max_length=255) class Meta: ordering = ['code'] - verbose_name = u"Devise" - verbose_name_plural = u"Devises" + verbose_name = u"devise" + verbose_name_plural = u"devises" def __unicode__(self): return u'%s - %s' % (self.code, self.nom) +reversion.register(Devise, format='xml') + -class TypeContrat(AUFMetadata): +class TypeContrat(Archivable): """ Type de contrat. """ @@ -1578,12 +1868,18 @@ class TypeContrat(AUFMetadata): def __unicode__(self): return u'%s' % (self.nom) +reversion.register(TypeContrat, format='xml') + ### AUTRES class ResponsableImplantationProxy(ref.Implantation): + def save(self): + pass + class Meta: + managed = False proxy = True verbose_name = u"Responsable d'implantation" verbose_name_plural = u"Responsables d'implantation" @@ -1610,3 +1906,223 @@ class ResponsableImplantation(models.Model): ordering = ['implantation__nom'] verbose_name = "Responsable d'implantation" verbose_name_plural = "Responsables d'implantation" + +reversion.register(ResponsableImplantation, format='xml') + + +class UserProfile(models.Model): + user = models.OneToOneField(User, related_name='profile') + zones_administratives = models.ManyToManyField( + ref.ZoneAdministrative, + related_name='profiles' + ) + class Meta: + verbose_name = "Permissions sur zones administratives" + verbose_name_plural = "Permissions sur zones administratives" + + def __unicode__(self): + return self.user.__unicode__() + +reversion.register(UserProfile, format='xml') + + + +TYPES_CHANGEMENT = ( + ('NO', 'Arrivée'), + ('MO', 'Mobilité'), + ('DE', 'Départ'), + ) + + +class ChangementPersonnelNotifications(models.Model): + class Meta: + verbose_name = u"Destinataire pour notices de mouvement de personnel" + verbose_name_plural = u"Destinataires pour notices de mouvement de personnel" + + type = models.CharField( + max_length=2, + choices = TYPES_CHANGEMENT, + unique=True, + ) + + destinataires = models.ManyToManyField( + ref.Employe, + related_name='changement_notifications', + ) + + def __unicode__(self): + return '%s: %s' % ( + self.get_type_display(), ','.join( + self.destinataires.all().values_list( + 'courriel', flat=True)) + ) + + +class ChangementPersonnel(models.Model): + """ + Une notice qui enregistre un changement de personnel, incluant: + + * Nouveaux employés + * Mouvement de personnel + * Départ d'employé + """ + + class Meta: + verbose_name = u"Mouvement de personnel" + verbose_name_plural = u"Mouvements de personnel" + + def __unicode__(self): + return '%s: %s' % (self.dossier.__unicode__(), + self.get_type_display()) + + @classmethod + def create_changement(cls, dossier, type): + # If this employe has existing Changement, set them to invalid. + cls.objects.filter(dossier__employe=dossier.employe).update(valide=False) + + # Create a new one. + cls.objects.create( + dossier=dossier, + type=type, + valide=True, + communique=False, + ) + + + @classmethod + def post_save_handler(cls, + sender, + instance, + created, + using, + **kw): + + # This defines the time limit used when checking in previous + # files to see if an employee if new. Basically, if emloyee + # left his position new_file.date_debut - + # NEW_EMPLOYE_THRESHOLD +1 ago (compared to date_debut), then + # if a new file is created for this employee, he will bec + # onsidered "NEW" and a notice will be created to this effect. + NEW_EMPLOYE_THRESHOLD = datetime.timedelta(7) # 7 days. + + other_dossier_qs = instance.employe.rh_dossiers.exclude( + id=instance.id) + dd = instance.date_debut + df = instance.date_fin + today = date.today() + + # Here, verify differences between the instance, before and + # after the save. + df_has_changed = False + + if created: + if df != None: + df_has_changed = True + else: + df_has_changed = (df != instance.before_save.date_fin and + df != None) + + + # VERIFICATIONS: + + # Date de fin est None et c'est une nouvelle instance de + # Dossier + if not df and created: + # QS for finding other dossiers with a date_fin of None OR + # with a date_fin >= to this dossier's date_debut + exists_recent_file_qs = other_dossier_qs.filter( + Q(date_fin__isnull=True) | + Q(date_fin__gte=dd - NEW_EMPLOYE_THRESHOLD) + ) + + # 1. If existe un Dossier récent + if exists_recent_file_qs.count() > 0: + cls.create_changement( + instance, + 'MO', + ) + # 2. Il n'existe un Dossier récent, et c'est une nouvelle + # instance de Dossier: + else: + cls.create_changement( + instance, + 'NO', + ) + + elif not df and not created and cls.objects.filter( + valide=True, + date_creation__gte=today - NEW_EMPLOYE_THRESHOLD, + type='DE', + ).count() > 0: + cls.create_changement( + instance, + 'MO', + ) + + # Date de fin a été modifiée: + if df_has_changed: + # QS for other active files (date_fin == None), excludes + # instance. + exists_active_files_qs = other_dossier_qs.filter( + Q(date_fin__isnull=True)) + + # 3. Date de fin a été modifiée et il n'existe aucun autre + # dossier actifs: Depart + if exists_active_files_qs.count() == 0: + cls.create_changement( + instance, + 'DE', + ) + # 4. Dossier a une nouvelle date de fin par contre + # d'autres dossiers actifs existent déjà: Mouvement + else: + cls.create_changement( + instance, + 'MO', + ) + + + dossier = models.ForeignKey( + Dossier, + related_name='mouvements', + ) + + valide = models.BooleanField(default=True) + date_creation = models.DateTimeField( + auto_now_add=True) + communique = models.BooleanField( + u'Communiqué', + default=False, + ) + date_communication = models.DateTimeField( + null=True, + blank=True, + ) + + type = models.CharField( + max_length=2, + choices = TYPES_CHANGEMENT, + ) + +reversion.register(ChangementPersonnel, format='xml') + + +def dossier_pre_save_handler(sender, + instance, + using, + **kw): + # Store a copy of the model before save is called. + if instance.pk is not None: + instance.before_save = Dossier.objects.get(pk=instance.pk) + else: + instance.before_save = None + + +# Connect a pre_save handler that assigns a copy of the model as an +# attribute in order to compare it in post_save. +pre_save.connect(dossier_pre_save_handler, sender=Dossier) + +post_save.connect(ChangementPersonnel.post_save_handler, sender=Dossier) +post_save.connect(RHDossierClassementRecord.post_save_handler, sender=Dossier) + +