X-Git-Url: http://git.auf.org/?p=auf_rh_dae.git;a=blobdiff_plain;f=project%2Frh%2Fmodels.py;h=3c7a53896efbd019ddc3877e1a84b82be6b1bfbc;hp=031f55b5d31f7d9182c034a1422ea0bdbea5ae02;hb=9f6c277ce74205b30ddca1624c727c838644fa6e;hpb=82c5e37da28f9877f27f77589dc6eb11077c8b81;ds=sidebyside diff --git a/project/rh/models.py b/project/rh/models.py index 031f55b..3c7a538 100644 --- a/project/rh/models.py +++ b/project/rh/models.py @@ -4,26 +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.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 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 project.rh.change_list import \ RechercheTemporelle, KEY_STATUT, STATUT_ACTIF, STATUT_INACTIF, \ STATUT_FUTUR -from project.rh.managers import \ - PosteManager, DossierManager, DossierComparaisonManager, \ - PosteComparaisonManager, DeviseManager, ServiceManager, \ - TypeRemunerationManager +from project import groups +from project.rh.managers import ( + PosteManager, + DossierManager, + EmployeManager, + DossierComparaisonManager, + PosteComparaisonManager, + ContratManager, + RemunerationManager, + ArchivableManager, + ) + + +TWOPLACES = Decimal('0.01') + 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" @@ -38,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 @@ -66,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): @@ -81,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 @@ -131,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éé. @@ -221,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 @@ -280,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: @@ -299,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( @@ -334,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'), @@ -377,6 +418,8 @@ class PosteFinancement(PosteFinancement_): Poste, db_column='poste', related_name='rh_financements' ) +reversion.register(PosteFinancement, format='xml') + class PostePiece_(models.Model): """ @@ -385,7 +428,8 @@ class PostePiece_(models.Model): """ 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: @@ -401,8 +445,10 @@ class PostePiece(PostePiece_): 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. @@ -430,7 +476,7 @@ class PosteComparaison(PosteComparaison_): Poste, related_name='rh_comparaisons_internes' ) - objects = NoDeleteManager() +reversion.register(PosteComparaison, format='xml') class PosteCommentaire(Commentaire): @@ -438,11 +484,11 @@ class PosteCommentaire(Commentaire): 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. @@ -450,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) @@ -507,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': @@ -525,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(): @@ -556,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 @@ -563,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): @@ -583,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: @@ -594,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'), @@ -613,7 +730,7 @@ LIEN_PARENTE_CHOICES = ( ) -class AyantDroit(AUFMetadata): +class AyantDroit(models.Model): """ Personne en relation avec un Employe. """ @@ -652,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_zones_administratives(self): + return [ + d.poste.implantation.zone_administrative + for d in self.employe.dossiers.all() + ] - def get_regions(self): - regions = [] - for d in self.employe.dossiers.all(): - regions.append(d.poste.implantation.region) - return regions +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 @@ -681,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é @@ -733,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 @@ -763,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 @@ -947,6 +1081,24 @@ 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) \ @@ -971,6 +1123,131 @@ class Dossier(Dossier_): 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): @@ -980,7 +1257,8 @@ class DossierPiece_(models.Model): """ 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: @@ -996,13 +1274,15 @@ class DossierPiece(DossierPiece_): Dossier, db_column='dossier', related_name='rh_dossierpieces' ) - +reversion.register(DossierPiece, format='xml') class DossierCommentaire(Commentaire): dossier = models.ForeignKey( Dossier, db_column='dossier', related_name='commentaires' ) +reversion.register(DossierCommentaire, format='xml') + class DossierComparaison_(models.Model, DevisableMixin): """ @@ -1032,10 +1312,12 @@ class DossierComparaison(DossierComparaison_): Dossier, related_name='rh_comparaisons' ) +reversion.register(DossierComparaison, format='xml') + ### RÉMUNÉRATION -class RemunerationMixin(AUFMetadata): +class RemunerationMixin(models.Model): # Identification type = models.ForeignKey( @@ -1048,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='+') @@ -1057,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 @@ -1074,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) @@ -1102,16 +1446,12 @@ class Remuneration(Remuneration_): Dossier, db_column='dossier', related_name='rh_remunerations' ) +reversion.register(Remuneration, format='xml') -### CONTRATS - -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 @@ -1122,11 +1462,15 @@ class Contrat_(AUFMetadata): '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: @@ -1144,10 +1488,12 @@ class Contrat(Contrat_): Dossier, db_column='dossier', related_name='rh_contrats' ) +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. @@ -1162,6 +1508,8 @@ class CategorieEmploi(AUFMetadata): def __unicode__(self): return self.nom +reversion.register(CategorieEmploi, format='xml') + class FamilleProfessionnelle(models.Model): """ @@ -1177,8 +1525,10 @@ class FamilleProfessionnelle(models.Model): def __unicode__(self): return self.nom +reversion.register(FamilleProfessionnelle, format='xml') + -class TypePoste(AUFMetadata): +class TypePoste(Archivable): """ Catégorie de Poste. """ @@ -1204,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'] @@ -1240,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.) @@ -1262,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 = ( @@ -1291,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. @@ -1313,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. """ @@ -1343,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'), @@ -1360,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")' }) @@ -1368,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". @@ -1379,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 @@ -1403,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. @@ -1419,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) @@ -1427,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 @@ -1444,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) @@ -1459,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' % ( @@ -1470,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. """ @@ -1505,11 +1868,16 @@ 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 @@ -1538,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) + +