Esempio n. 1
0
class MyWorkflowEnabled(dxmodels.WorkflowEnabled, models.Model):
    OTHER_CHOICES = (
        ('aaa', "AAA"),
        ('bbb', "BBB"),
    )

    state = dxmodels.StateField(MyWorkflow)
    other = models.CharField(max_length=4, choices=OTHER_CHOICES)

    def fail_if_fortytwo(self, res, *args, **kwargs):
        if res == 42:
            raise ValueError()

    @dxmodels.transition(after=fail_if_fortytwo)
    def gobaz(self, foo, save=True):
        return foo * 2

    @xworkflows.on_enter_state(MyWorkflow.states.bar)
    def hook_enter_baz(self, *args, **kwargs):
        self.other = 'aaa'
Esempio n. 2
0
class Aid(xwf_models.WorkflowEnabled, models.Model):
    """Represents a single Aid."""

    TYPES = Choices(
        ('grant', _('Grant')),
        ('loan', _('Loan')),
        ('recoverable_advance', _('Recoverable advance')),
        ('technical', _('Technical engineering')),
        ('financial', _('Financial engineering')),
        ('legal', _('Legal engineering')),
        ('other', _('Other')),
    )

    FINANCIAL_AIDS = ('grant', 'loan', 'recoverable_advance', 'other')

    TECHNICAL_AIDS = ('technical', 'financial', 'legal')

    PERIMETERS = Choices(
        ('europe', _('Europe')),
        ('france', _('France')),
        ('region', _('Region')),
        ('department', _('Department')),
        ('commune', _('Commune')),
        ('mainland', _('Mainland')),
        ('overseas', _('Overseas')),
        ('other', _('Other')),
    )

    STEPS = Choices(
        ('preop', _('Preoperational')),
        ('op', _('Operational')),
        ('postop', _('Postoperation')),
    )

    AUDIANCES = Choices(
        ('commune', _('Communes')),
        ('epci', _('Audiance EPCI')),
        ('unions', ('Intermunicipal unions')),
        ('department', _('Departments')),
        ('region', _('Regions')),
        ('association', _('Associations')),
        ('private_sector', _('Private sector')),
        ('public_org', _('Public organizations')),
        ('lessor', _('Audiance lessors')),
        ('researcher', _('Research')),
        ('private_person', _('Individuals')),
        ('farmer', _('Farmers')),
        ('other', _('Other')),
    )

    DESTINATIONS = Choices(
        ('service', _('Service (AMO, survey)')),
        ('works', _('Works')),
        ('supply', _('Supply')),
    )

    RECURRENCE = Choices(
        ('oneoff', _('One off')),
        ('ongoing', _('Ongoing')),
        ('recurring', _('Recurring')),
    )

    IMPORT_LICENCES = Choices(
        ('unknown', _('Unknown')),
        ('openlicence20', _('Open licence 2.0')),
    )

    objects = ExistingAidsManager()
    all_aids = AidQuerySet.as_manager()
    amendments = AmendmentManager()

    slug = models.SlugField(
        _('Slug'),
        help_text=_('Let it empty so it will be autopopulated.'),
        blank=True)
    name = models.CharField(
        _('Name'),
        max_length=180,
        help_text=_('A good title describes the purpose of the help and '
                    'should speak to the recipient.'),
        null=False,
        blank=False)
    author = models.ForeignKey('accounts.User',
                               on_delete=models.PROTECT,
                               verbose_name=_('Author'),
                               help_text=_('Who is submitting the aid?'),
                               null=True)
    categories = models.ManyToManyField('categories.Category',
                                        verbose_name=_('Categories'),
                                        related_name='aids',
                                        blank=True)
    financers = models.ManyToManyField('backers.Backer',
                                       related_name='financed_aids',
                                       verbose_name=_('Financers'))
    financer_suggestion = models.CharField(_('Financer suggestion'),
                                           max_length=256,
                                           blank=True)
    instructors = models.ManyToManyField('backers.Backer',
                                         blank=True,
                                         related_name='instructed_aids',
                                         verbose_name=_('Instructors'))
    instructor_suggestion = models.CharField(_('Instructor suggestion'),
                                             max_length=256,
                                             blank=True)
    description = models.TextField(
        _('Full description of the aid and its objectives'), blank=False)
    eligibility = models.TextField(_('Eligibility'), blank=True)
    perimeter = models.ForeignKey(
        'geofr.Perimeter',
        verbose_name=_('Perimeter'),
        on_delete=models.PROTECT,
        null=True,
        blank=True,
        help_text=_('What is the aid broadcasting perimeter?'))
    mobilization_steps = ChoiceArrayField(verbose_name=_('Mobilization step'),
                                          null=True,
                                          blank=True,
                                          base_field=models.CharField(
                                              max_length=32,
                                              choices=STEPS,
                                              default=STEPS.preop))
    origin_url = models.URLField(_('Origin URL'), blank=True)
    application_url = models.URLField(_('Application url'), blank=True)
    targeted_audiances = ChoiceArrayField(
        verbose_name=_('Targeted audiances'),
        null=True,
        blank=True,
        base_field=models.CharField(max_length=32, choices=AUDIANCES))
    aid_types = ChoiceArrayField(verbose_name=_('Aid types'),
                                 null=True,
                                 blank=True,
                                 base_field=models.CharField(max_length=32,
                                                             choices=TYPES),
                                 help_text=_('Specify the aid type or types.'))
    destinations = ChoiceArrayField(verbose_name=_('Destinations'),
                                    null=True,
                                    blank=True,
                                    base_field=models.CharField(
                                        max_length=32, choices=DESTINATIONS))
    start_date = models.DateField(
        _('Start date'),
        null=True,
        blank=True,
        help_text=_('When is the application opening?'))
    predeposit_date = models.DateField(
        _('Predeposit date'),
        null=True,
        blank=True,
        help_text=_('When is the pre-deposit date, if applicable?'))
    submission_deadline = models.DateField(
        _('Submission deadline'),
        null=True,
        blank=True,
        help_text=_('When is the submission deadline?'))
    subvention_rate = PercentRangeField(
        _('Subvention rate, min. and max. (in round %)'),
        null=True,
        blank=True,
        help_text=_('If fixed rate, only fill the max. rate.'))
    subvention_comment = models.CharField(
        _('Subvention rate, optional comment'), max_length=256, blank=True)
    contact = models.TextField(_('Contact'), blank=True)
    contact_email = models.EmailField(_('Contact email'), blank=True)
    contact_phone = models.CharField(_('Contact phone number'),
                                     max_length=35,
                                     blank=True)
    contact_detail = models.CharField(_('Contact detail'),
                                      max_length=256,
                                      blank=True)
    recurrence = models.CharField(
        _('Recurrence'),
        help_text=_('Is this a one-off aid, is it recurring or ongoing?'),
        max_length=16,
        choices=RECURRENCE,
        blank=True)
    is_call_for_project = models.BooleanField(
        _('Call for project / Call for expressions of interest'), null=True)
    status = xwf_models.StateField(AidWorkflow, verbose_name=_('Status'))
    date_created = models.DateTimeField(_('Date created'),
                                        default=timezone.now)
    date_updated = models.DateTimeField(_('Date updated'), auto_now=True)
    date_published = models.DateTimeField(_('First publication date'),
                                          null=True,
                                          blank=True)

    # Third-party data import related fields
    is_imported = models.BooleanField(_('Is imported?'), default=False)
    # Even if this field is a CharField, we make it nullable with `null=True`
    # because null values are not taken into account by postgresql when
    # enforcing the `unique` constraint, which is very handy for us.
    import_uniqueid = models.CharField(
        _('Unique identifier for imported data'),
        max_length=200,
        unique=True,
        null=True,
        blank=True)
    import_data_url = models.URLField(_('Origin url of the imported data'),
                                      null=True,
                                      blank=True)
    import_share_licence = models.CharField(
        _('Under which license was this aid shared?'),
        max_length=50,
        choices=IMPORT_LICENCES,
        blank=True)
    import_last_access = models.DateField(_('Date of the latest access'),
                                          null=True,
                                          blank=True)

    # This field is used to index searchable text content
    search_vector = SearchVectorField(_('Search vector'), null=True)

    # This is where we store tags
    tags = ArrayField(models.CharField(max_length=50, blank=True),
                      verbose_name=_('Tags'),
                      default=list,
                      size=30,
                      blank=True)
    _tags_m2m = models.ManyToManyField('tags.Tag',
                                       related_name='aids',
                                       verbose_name=_('Tags'))

    # Those fields handle the "aid amendment" feature
    # Users, including anonymous, can suggest amendments to existing aids.
    # We store a suggested edit as a clone of the original aid, with the
    # following field as True.
    is_amendment = models.BooleanField(_('Is amendment'), default=False)
    amended_aid = models.ForeignKey('aids.Aid',
                                    verbose_name=_('Amended aid'),
                                    on_delete=models.CASCADE,
                                    null=True)
    amendment_author_name = models.CharField(_('Amendment author'),
                                             max_length=256,
                                             blank=True)
    amendment_author_email = models.EmailField(_('Amendment author email'),
                                               null=True,
                                               blank=True)
    amendment_author_org = models.CharField(_('Amendment author organization'),
                                            max_length=255,
                                            blank=True)
    amendment_comment = models.TextField(_('Amendment comment'), blank=True)

    class Meta:
        verbose_name = _('Aid')
        verbose_name_plural = _('Aids')
        indexes = [
            GinIndex(fields=['search_vector']),
        ]

    def set_slug(self):
        """Set the object's slug.

        Lots of aids have duplicate name, so we prefix the slug with random
        characters."""
        if not self.id:
            full_title = '{}-{}'.format(str(uuid4())[:4], self.name)
            self.slug = slugify(full_title)[:50]

    def set_publication_date(self):
        """Set the object's publication date.

        We set the first publication date once and for all when the aid is
        first published.
        """
        if self.is_published() and self.date_published is None:
            self.date_published = timezone.now()

    def set_search_vector(self, financers=None, instructors=None):
        """Update the full text cache field."""

        # Note: we use `SearchVector(Value(self.field))` instead of
        # `SearchVector('field')` because the latter only works for updates,
        # not when inserting new records.
        #
        # Note 2: we have to pass the financers parameter instead of using
        # `self.financers.all()` because that last expression would not work
        # during an object creation.
        search_vector = \
            SearchVector(
                Value(self.name, output_field=models.CharField()),
                weight='A',
                config='french') + \
            SearchVector(
                Value(self.eligibility, output_field=models.CharField()),
                weight='D',
                config='french') + \
            SearchVector(
                Value(self.description, output_field=models.CharField()),
                weight='B',
                config='french') + \
            SearchVector(
                Value(' '.join(self.tags), output_field=models.CharField()),
                weight='A',
                config='french')

        if financers:
            search_vector += SearchVector(Value(
                ' '.join(str(backer) for backer in financers),
                output_field=models.CharField()),
                                          weight='D',
                                          config='french')

        if instructors:
            search_vector += SearchVector(Value(
                ' '.join(str(backer) for backer in instructors),
                output_field=models.CharField()),
                                          weight='D',
                                          config='french')

        self.search_vector = search_vector

    def populate_tags(self):
        """Populates the `_tags_m2m` field.

        cache `_tags_m2m` field with values from the `tags` field.

        Tag that do not exist will be created.
        """
        all_tag_names = self.tags
        existing_tag_objects = Tag.objects.filter(name__in=all_tag_names)
        existing_tag_names = [tag.name for tag in existing_tag_objects]
        missing_tag_names = list(set(all_tag_names) - set(existing_tag_names))
        new_tags = [Tag(name=tag) for tag in missing_tag_names]
        new_tag_objects = Tag.objects.bulk_create(new_tags)

        all_tag_objects = list(existing_tag_objects) + list(new_tag_objects)
        self._tags_m2m.set(all_tag_objects, clear=True)

    def save(self, *args, **kwargs):
        self.set_slug()
        self.set_publication_date()
        return super().save(*args, **kwargs)

    def __str__(self):
        return self.name

    def get_absolute_url(self):
        return reverse('aid_detail_view', args=[self.slug])

    def get_admin_url(self):
        return reverse('admin:aids_aid_change', args=[self.id])

    def is_draft(self):
        return self.status == AidWorkflow.states.draft

    def is_under_review(self):
        return self.status == AidWorkflow.states.reviewable

    def is_published(self):
        return self.status == AidWorkflow.states.published

    def is_financial(self):
        """Does this aid have financial parts?"""
        aid_types = self.aid_types or []
        return bool(set(aid_types) & set(self.FINANCIAL_AIDS))

    def is_technical(self):
        """Does this aid have technical parts?"""
        aid_types = self.aid_types or []
        return bool(set(aid_types) & set(self.TECHNICAL_AIDS))

    def is_ongoing(self):
        return self.recurrence == self.RECURRENCE.ongoing

    def has_approaching_deadline(self):
        if not self.submission_deadline:
            return False

        delta = self.submission_deadline - timezone.now().date()
        return delta.days <= settings.APPROACHING_DEADLINE_DELTA

    def has_expired(self):
        if not self.submission_deadline:
            return False

        today = timezone.now().date()
        return self.submission_deadline < today

    def is_live(self):
        """True if the aid must be displayed on the site."""
        return self.is_published() and not self.has_expired()
Esempio n. 3
0
class Case(xwf_models.WorkflowEnabled, models.Model):

    GENDER_CHOICES = (
        ("F", 'Female'),
        ("M", 'Male'),
    )
    LANGUAGE_CHOICES = (("French", "French"), ("English", "English"),
                        ("Spanish", "Spanish"))
    NATURE_CHOICES = (("Emergency", "Emergency"), ("Urgent", "Urgent"),
                      ("Life changing", "Life changing"))
    CLOSING_REASON_CHOICES = (
        (0, "Success/Transfer Accepted"),
        (1, "Patient died"),
        (2, "Not fulfilling criteria"),
        (3, "No Partner Organisation found"),
        (4, "Other"),
    )

    title = models.CharField(max_length=100)
    language = models.CharField(choices=LANGUAGE_CHOICES,
                                max_length=10,
                                default='')
    nature_of_referral = models.CharField(choices=NATURE_CHOICES,
                                          max_length=20,
                                          default='Emergency')
    patient_id = models.IntegerField(default=0)
    location = models.CharField(max_length=200, default='', blank=True)
    country = models.CharField(max_length=100)
    age = models.CharField(max_length=50, blank=True, default='')
    birth_date = models.CharField(max_length=50, blank=True, default='')
    sex = models.CharField(choices=GENDER_CHOICES, default="F", max_length=10)
    description = models.TextField(blank=True, default='')
    history_description = models.TextField(blank=True, default='')
    diagnosis = models.TextField(blank=True, default='')
    past_medical_history = models.TextField(blank=True, default='')
    physical_examination = models.TextField(blank=True, default='')
    investigations = models.TextField(blank=True, default='')
    current_treatment = models.TextField(blank=True, default='')
    justification = models.TextField(blank=True, default='')
    recommendation = models.TextField(blank=True, default='')
    outcome = models.CharField(max_length=100, blank=True, default='')

    categories = models.ManyToManyField(
        to=Category,
        related_name='cases',
    )
    consent = models.BooleanField(default=False)
    created = models.DateTimeField(auto_now_add=True)
    status = xwf_models.StateField(CaseWorkflow)
    organisations = models.ManyToManyField(
        to=Organisation,
        blank=True,
        through='Partnership',
    )
    created_by = models.ForeignKey(to=User,
                                   related_name="cases_created",
                                   on_delete=models.CASCADE,
                                   blank=True,
                                   null=True)
    closing_reason = models.IntegerField(choices=CLOSING_REASON_CHOICES,
                                         default=4)

    @xworkflows.transition_check("reject")
    def hook(self, *args, **kwargs):
        if self.partnered_organisations.filter(status="assigned").exists():
            return False
        return True

    class Meta:
        permissions = [
            ("validate_case", "Can validate cases"),
            ("close_case", "Can close cases"),
            ("reopen_case", "Can reopen cases"),
            ("reject_case", "Can reject cases"),
            ("assign_organisations",
             "Can assign a case to a matched and accepted organisation"),
            ("match_organisations", "Can match organisations to cases"),
            ("update_match",
             "Can set a matched partnership to accepted/rejected"),
            ("view_dashboard",
             "Can look at the dashboard to see statistics and insights."),
            ("view_general_info", "Can view general information about a case"),
            ("update_general_info",
             "Can update general information about a case"),
            ("view_medical_info", "Can view medical information about a case"),
            ("update_medical_info",
             "Can update medical information about a case"),
        ]

    def __str__(self):
        return f'Case {self.id}: {self.title}'
class FluxoAprovacaoPartindoDaEscola(xwf_models.WorkflowEnabled, models.Model):
    workflow_class = PedidoAPartirDaEscolaWorkflow
    status = xwf_models.StateField(workflow_class)
    DIAS_PARA_CANCELAR = 2

    rastro_escola = models.ForeignKey(
        'escola.Escola',
        on_delete=models.DO_NOTHING,
        null=True,
        blank=True,
        related_name='%(app_label)s_%(class)s_rastro_escola',
        editable=False)
    rastro_dre = models.ForeignKey(
        'escola.DiretoriaRegional',
        on_delete=models.DO_NOTHING,
        null=True,
        related_name='%(app_label)s_%(class)s_rastro_dre',
        blank=True,
        editable=False)
    rastro_lote = models.ForeignKey(
        'escola.Lote',
        on_delete=models.DO_NOTHING,
        null=True,
        blank=True,
        related_name='%(app_label)s_%(class)s_rastro_lote',
        editable=False)
    rastro_terceirizada = models.ForeignKey(
        'terceirizada.Terceirizada',
        on_delete=models.DO_NOTHING,
        null=True,
        blank=True,
        related_name='%(app_label)s_%(class)s_rastro_terceirizada',
        editable=False)

    def _salva_rastro_solicitacao(self):
        self.rastro_escola = self.escola
        self.rastro_dre = self.escola.diretoria_regional
        self.rastro_lote = self.escola.lote
        self.rastro_terceirizada = self.escola.lote.terceirizada
        self.save()

    def cancelar_pedido(self, user, justificativa=''):
        """O objeto que herdar de FluxoAprovacaoPartindoDaEscola, deve ter um property data.

        Dado dias de antecedencia de prazo, verifica se pode e altera o estado
        """
        dia_antecedencia = datetime.date.today() + datetime.timedelta(
            days=self.DIAS_PARA_CANCELAR)
        data_do_evento = self.data
        if isinstance(data_do_evento, datetime.datetime):
            # TODO: verificar por que os models estao retornando datetime em vez de date
            data_do_evento = data_do_evento.date()

        if (data_do_evento > dia_antecedencia) and (
                self.status != self.workflow_class.ESCOLA_CANCELOU):
            self.status = self.workflow_class.ESCOLA_CANCELOU
            self.salvar_log_transicao(
                status_evento=LogSolicitacoesUsuario.ESCOLA_CANCELOU,
                usuario=user,
                justificativa=justificativa)
            self.save()
        elif self.status == self.workflow_class.ESCOLA_CANCELOU:
            raise xworkflows.InvalidTransitionError('Já está cancelada')
        else:
            raise xworkflows.InvalidTransitionError(
                f'Só pode cancelar com no mínimo {self.DIAS_PARA_CANCELAR} dia(s) de antecedência'
            )

    def cancelamento_automatico_apos_vencimento(self):
        """Chamado automaticamente quando o pedido já passou do dia de atendimento e não chegou ao fim do fluxo."""
        self.status = self.workflow_class.CANCELADO_AUTOMATICAMENTE

    @property
    def pode_excluir(self):
        return self.status == self.workflow_class.RASCUNHO

    @property
    def ta_na_dre(self):
        return self.status == self.workflow_class.DRE_A_VALIDAR

    @property
    def ta_na_escola(self):
        return self.status in [
            self.workflow_class.RASCUNHO,
            self.workflow_class.DRE_PEDIU_ESCOLA_REVISAR
        ]

    @property
    def ta_na_codae(self):
        return self.status == self.workflow_class.DRE_VALIDADO

    @property
    def ta_na_terceirizada(self):
        return self.status == self.workflow_class.CODAE_AUTORIZADO

    @property
    def _partes_interessadas_inicio_fluxo(self):
        """Quando a escola faz a solicitação, as pessoas da DRE são as partes interessadas.

        Será retornada uma lista de emails para envio via celery.
        """
        email_query_set = self.rastro_dre.vinculos.filter(
            ativo=True).values_list('usuario__email', flat=False)
        return [email for email in email_query_set]

    @property
    def partes_interessadas_dre_valida(self):
        # TODO: definir partes interessadas
        return []

    @property
    def partes_interessadas_codae_autoriza(self):
        # TODO: definir partes interessadas
        return []

    @property
    def partes_interessadas_terceirizadas_tomou_ciencia(self):
        # TODO: definir partes interessadas
        return []

    @property
    def template_mensagem(self):
        raise NotImplementedError(
            'Deve criar um property que recupera o assunto e corpo mensagem desse objeto'
        )

    def salvar_log_transicao(self, status_evento, usuario, **kwargs):
        raise NotImplementedError('Deve criar um método salvar_log_transicao')

    #
    # Esses hooks são chamados automaticamente após a
    # transition do status ser chamada.
    # Ex. >>> alimentacao_continua.inicia_fluxo(param1, param2, key1='val')
    #

    @xworkflows.after_transition('inicia_fluxo')
    def _inicia_fluxo_hook(self, *args, **kwargs):
        self.foi_solicitado_fora_do_prazo = self.prioridade in [
            'PRIORITARIO', 'LIMITE'
        ]
        self._salva_rastro_solicitacao()
        user = kwargs['user']
        assunto, corpo = self.template_mensagem
        envia_email_em_massa_task.delay(
            assunto=assunto,
            corpo=corpo,
            emails=self._partes_interessadas_inicio_fluxo,
            html=None)
        self.salvar_log_transicao(
            status_evento=LogSolicitacoesUsuario.INICIO_FLUXO, usuario=user)

    @xworkflows.after_transition('dre_valida')
    def _dre_valida_hook(self, *args, **kwargs):
        user = kwargs['user']
        if user:
            assunto, corpo = self.template_mensagem

            self.salvar_log_transicao(
                status_evento=LogSolicitacoesUsuario.DRE_VALIDOU, usuario=user)

    @xworkflows.after_transition('dre_pede_revisao')
    def _dre_pede_revisao_hook(self, *args, **kwargs):
        user = kwargs['user']
        if user:
            assunto, corpo = self.template_mensagem
            self.salvar_log_transicao(
                status_evento=LogSolicitacoesUsuario.DRE_PEDIU_REVISAO,
                usuario=user)

    @xworkflows.after_transition('dre_nao_valida')
    def _dre_nao_valida_hook(self, *args, **kwargs):
        user = kwargs['user']
        justificativa = kwargs.get('justificativa', '')
        if user:
            assunto, corpo = self.template_mensagem
            self.salvar_log_transicao(
                status_evento=LogSolicitacoesUsuario.DRE_NAO_VALIDOU,
                justificativa=justificativa,
                usuario=user)

    @xworkflows.after_transition('escola_revisa')
    def _escola_revisa_hook(self, *args, **kwargs):
        user = kwargs['user']
        if user:
            assunto, corpo = self.template_mensagem
            self.salvar_log_transicao(
                status_evento=LogSolicitacoesUsuario.ESCOLA_REVISOU,
                usuario=user)

    @xworkflows.after_transition('codae_questiona')
    def _codae_questiona_hook(self, *args, **kwargs):
        user = kwargs['user']
        justificativa = kwargs.get('justificativa', '')
        if user:
            assunto, corpo = self.template_mensagem
            self.salvar_log_transicao(
                status_evento=LogSolicitacoesUsuario.CODAE_QUESTIONOU,
                justificativa=justificativa,
                usuario=user)

    @xworkflows.before_transition('codae_autoriza_questionamento')
    @xworkflows.before_transition('codae_autoriza')
    def _codae_autoriza_hook_antes(self, *args, **kwargs):
        if (self.foi_solicitado_fora_do_prazo
                and self.status != PedidoAPartirDaEscolaWorkflow.
                TERCEIRIZADA_RESPONDEU_QUESTIONAMENTO):  # noqa #129
            raise xworkflows.InvalidTransitionError(
                f'CODAE não pode autorizar direto caso seja em cima da hora, deve questionar'
            )

    @xworkflows.after_transition('codae_autoriza_questionamento')
    @xworkflows.after_transition('codae_autoriza')
    def _codae_autoriza_hook(self, *args, **kwargs):
        user = kwargs['user']
        justificativa = kwargs.get('justificativa', '')
        if user:
            assunto, corpo = self.template_mensagem
            self.salvar_log_transicao(
                status_evento=LogSolicitacoesUsuario.CODAE_AUTORIZOU,
                usuario=user,
                justificativa=justificativa)

    @xworkflows.after_transition('codae_nega_questionamento')
    @xworkflows.after_transition('codae_nega')
    def _codae_recusou_hook(self, *args, **kwargs):
        user = kwargs['user']
        justificativa = kwargs.get('justificativa', '')
        if user:
            assunto, corpo = self.template_mensagem
            self.salvar_log_transicao(
                status_evento=LogSolicitacoesUsuario.CODAE_NEGOU,
                usuario=user,
                justificativa=justificativa)

    @xworkflows.after_transition('terceirizada_toma_ciencia')
    def _terceirizada_toma_ciencia_hook(self, *args, **kwargs):
        user = kwargs['user']
        if user:
            assunto, corpo = self.template_mensagem
            self.salvar_log_transicao(status_evento=LogSolicitacoesUsuario.
                                      TERCEIRIZADA_TOMOU_CIENCIA,
                                      usuario=user)

    @xworkflows.after_transition('terceirizada_responde_questionamento')
    def _terceirizada_responde_questionamento_hook(self, *args, **kwargs):
        user = kwargs['user']
        justificativa = kwargs.get('justificativa', '')
        resposta_sim_nao = kwargs.get('resposta_sim_nao', False)
        if user:
            assunto, corpo = self.template_mensagem
            self.salvar_log_transicao(status_evento=LogSolicitacoesUsuario.
                                      TERCEIRIZADA_RESPONDEU_QUESTIONAMENTO,
                                      justificativa=justificativa,
                                      resposta_sim_nao=resposta_sim_nao,
                                      usuario=user)

    class Meta:
        abstract = True
Esempio n. 5
0
        class AbstractWorkflowEnabled(xwf_models.WorkflowEnabled,
                                      django_models.Model):
            state = xwf_models.StateField(models.MyWorkflow)

            class Meta:
                abstract = True
Esempio n. 6
0
class SomeWorkflowEnabled(dxmodels.WorkflowEnabled, models.Model):
    state = dxmodels.StateField(SomeWorkflow)
Esempio n. 7
0
class Aid(xwf_models.WorkflowEnabled, models.Model):
    """Represents a single Aid."""

    TYPES = Choices(*TYPES_ALL)

    STEPS = Choices(
        ('preop', 'Réflexion / conception'),
        ('op', 'Mise en œuvre / réalisation'),
        ('postop', 'Usage / valorisation'),
    )

    AUDIENCES = Choices(*AUDIENCES_ALL)

    DESTINATIONS = Choices(
        ('supply', 'Dépenses de fonctionnement'),
        ('investment', "Dépenses d'investissement"),
    )

    RECURRENCES = Choices(
        ('oneoff', 'Ponctuelle'),
        ('ongoing', 'Permanente'),
        ('recurring', 'Récurrente'),
    )

    objects = ExistingAidsManager()
    all_aids = AidQuerySet.as_manager()
    deleted_aids = DeletedAidsManager()
    amendments = AmendmentManager()

    slug = models.SlugField("Fragment d'URL",
                            help_text='Laisser vide pour autoremplir.',
                            blank=True)
    name = models.CharField(
        'Nom',
        max_length=180,
        help_text=
        "Le titre doit commencer par un verbe à l’infinitif pour que l'objectif de l'aide soit explicite vis-à-vis de ses bénéficiaires.",  # noqa
        null=False,
        blank=False)
    name_initial = models.CharField(
        'Nom initial',
        max_length=180,
        help_text=
        "Comment cette aide s’intitule-t-elle au sein de votre structure ? Exemple : AAP Mob’Biodiv",  # noqa
        null=True,
        blank=True)
    short_title = models.CharField(
        'Titre court',
        max_length=64,
        help_text='Un titre plus concis, pour affichage spécifique.',
        blank=True)
    author = models.ForeignKey('accounts.User',
                               verbose_name='Auteur',
                               on_delete=models.PROTECT,
                               related_name='aids',
                               help_text='Qui renseigne cette aide ?',
                               null=True)
    categories = models.ManyToManyField('categories.Category',
                                        verbose_name='Sous-thématiques',
                                        related_name='aids',
                                        blank=True)
    financers = models.ManyToManyField('backers.Backer',
                                       verbose_name="Porteurs d'aides",
                                       through=AidFinancer,
                                       related_name='financed_aids')
    financer_suggestion = models.CharField("Porteurs d'aides suggérés",
                                           max_length=256,
                                           blank=True)
    instructors = models.ManyToManyField('backers.Backer',
                                         verbose_name='Instructeurs',
                                         through=AidInstructor,
                                         related_name='instructed_aids',
                                         blank=True)
    instructor_suggestion = models.CharField('Instructeurs suggérés',
                                             max_length=256,
                                             blank=True)
    description = models.TextField(
        "Description complète de l'aide et de ses objectifs", blank=False)
    project_examples = models.TextField('Exemples de projets réalisables',
                                        default='',
                                        blank=True)
    projects = models.ManyToManyField('projects.Project',
                                      through='AidProject',
                                      verbose_name='Projets',
                                      blank=True)
    eligibility = models.TextField('Éligibilité', blank=True)
    perimeter = models.ForeignKey(
        'geofr.Perimeter',
        verbose_name='Périmètre',
        on_delete=models.PROTECT,
        help_text="Sur quel périmètre l'aide est-elle diffusée ?",
        null=True,
        blank=True)
    perimeter_suggestion = models.CharField('Périmètre suggéré',
                                            max_length=256,
                                            null=True,
                                            blank=True)
    mobilization_steps = ChoiceArrayField(
        verbose_name=
        "État d'avancement du projet pour bénéficier du dispositif",
        null=True,
        blank=True,
        base_field=models.CharField(max_length=32,
                                    choices=STEPS,
                                    default=STEPS.preop))
    origin_url = models.URLField("URL d'origine", max_length=500, blank=True)
    application_url = models.URLField("Lien vers une démarche en ligne",
                                      max_length=500,
                                      blank=True)
    targeted_audiences = ChoiceArrayField(
        verbose_name="Bénéficiaires de l'aide",
        null=True,
        blank=True,
        base_field=models.CharField(max_length=32, choices=AUDIENCES))
    aid_types = ChoiceArrayField(
        verbose_name="Types d'aide",
        null=True,
        blank=True,
        base_field=models.CharField(max_length=32, choices=TYPES),
        help_text="Précisez le ou les types de l'aide.")
    is_generic = models.BooleanField(
        'Aide générique ?',
        help_text='Cette aide est-elle générique ?',
        default=False)
    generic_aid = models.ForeignKey(
        'aids.Aid',
        verbose_name='Aide générique',
        on_delete=models.CASCADE,
        related_name='local_aids',
        limit_choices_to={'is_generic': True},
        help_text='Aide générique associée à une aide locale.',
        null=True,
        blank=True)
    local_characteristics = models.TextField('Spécificités locales',
                                             blank=True)
    destinations = ChoiceArrayField(
        verbose_name='Types de dépenses / actions couvertes',
        null=True,
        blank=True,
        base_field=models.CharField(max_length=32, choices=DESTINATIONS))
    start_date = models.DateField(
        "Date d'ouverture",
        help_text="À quelle date l'aide est-elle ouverte aux candidatures ?",
        null=True,
        blank=True)
    predeposit_date = models.DateField(
        'Date de pré-dépôt',
        help_text=
        "Quelle est la date de pré-dépôt des dossiers, si applicable ?",
        null=True,
        blank=True)
    submission_deadline = models.DateField(
        'Date de clôture',
        help_text="Quelle est la date de clôture de dépôt des dossiers ?",
        null=True,
        blank=True)
    subvention_rate = PercentRangeField(
        "Taux de subvention, min. et max. (en %, nombre entier)",
        help_text="Si le taux est fixe, remplissez uniquement le taux max.",
        null=True,
        blank=True)
    subvention_comment = models.CharField(
        "Taux de subvention (commentaire optionnel)",
        max_length=100,
        blank=True)
    recoverable_advance_amount = models.PositiveIntegerField(
        'Montant de l\'avance récupérable', null=True, blank=True)
    loan_amount = models.PositiveIntegerField('Montant du prêt',
                                              null=True,
                                              blank=True)
    other_financial_aid_comment = models.CharField(
        'Autre aide financière (commentaire optionnel)',
        max_length=100,
        blank=True)
    contact = models.TextField('Contact', blank=True)
    contact_email = models.EmailField('Adresse e-mail de contact', blank=True)
    contact_phone = models.CharField('Numéro de téléphone',
                                     max_length=35,
                                     blank=True)
    contact_detail = models.CharField('Contact (détail)',
                                      max_length=256,
                                      blank=True)
    recurrence = models.CharField(
        'Récurrence',
        help_text="L'aide est-elle ponctuelle, permanente, ou récurrente ?",
        max_length=16,
        choices=RECURRENCES,
        blank=True)
    is_call_for_project = models.BooleanField(
        "Appel à projet / Manifestation d'intérêt", null=True)
    programs = models.ManyToManyField('programs.Program',
                                      verbose_name='Programmes',
                                      related_name='aids',
                                      blank=True)
    status = xwf_models.StateField(AidWorkflow, verbose_name='Statut')

    # Eligibility
    eligibility_test = models.ForeignKey('eligibility.EligibilityTest',
                                         verbose_name="Test d'éligibilité",
                                         on_delete=models.PROTECT,
                                         related_name='aids',
                                         null=True,
                                         blank=True)

    # Dates
    date_created = models.DateTimeField('Date de création',
                                        default=timezone.now)
    date_updated = models.DateTimeField('Date de mise à jour', auto_now=True)
    date_published = models.DateTimeField('Première date de publication',
                                          null=True,
                                          blank=True)

    # Specific to France Relance features
    in_france_relance = models.BooleanField(
        'France Relance ?',
        help_text='Cette aide est-elle éligible au programme France Relance ?',
        default=False)

    # Disable send_publication_email's task
    author_notification = models.BooleanField(
        "Envoyer un email à l'auteur de l'aide ?",
        help_text="Un email doit-il être envoyé à l'auteur de cette aide \
        au moment de sa publication ?",
        default=True)

    # Third-party data import related fields
    is_imported = models.BooleanField('Importé ?', default=False)
    import_data_source = models.ForeignKey('dataproviders.DataSource',
                                           verbose_name='Source de données',
                                           on_delete=models.PROTECT,
                                           related_name='aids',
                                           null=True)
    # Even if this field is a CharField, we make it nullable with `null=True`
    # because null values are not taken into account by postgresql when
    # enforcing the `unique` constraint, which is very handy for us.
    import_uniqueid = models.CharField("Identifiant d'import unique",
                                       max_length=200,
                                       unique=True,
                                       null=True,
                                       blank=True)
    import_data_url = models.URLField("URL d'origine de la donnée importée",
                                      null=True,
                                      blank=True)
    import_share_licence = models.CharField(
        "Sous quelle licence cette aide a-t-elle été partagée ?",
        max_length=50,
        choices=IMPORT_LICENCES,
        blank=True)
    import_last_access = models.DateField('Date du dernier accès',
                                          null=True,
                                          blank=True)
    import_raw_object = models.JSONField('Donnée JSON brute',
                                         editable=False,
                                         null=True)

    # This field is used to index searchable text content
    search_vector_unaccented = SearchVectorField('Search vector unaccented',
                                                 null=True)

    # Those fields handle the "aid amendment" feature
    # Users, including anonymous, can suggest amendments to existing aids.
    # We store a suggested edit as a clone of the original aid, with the
    # following field as True.
    is_amendment = models.BooleanField('Est un amendement', default=False)
    amended_aid = models.ForeignKey('aids.Aid',
                                    verbose_name='Aide amendée',
                                    on_delete=models.CASCADE,
                                    null=True)
    amendment_author_name = models.CharField("Auteur de l'amendement",
                                             max_length=256,
                                             blank=True)
    amendment_author_email = models.EmailField(
        "E-mail de l'auteur de l'amendement", null=True, blank=True)
    amendment_author_org = models.CharField(
        "Structure de l'auteur de l'amendement", max_length=255, blank=True)
    amendment_comment = models.TextField('Commentaire', blank=True)

    class Meta:
        verbose_name = 'Aide'
        verbose_name_plural = 'Aides'
        indexes = [
            GinIndex(fields=['search_vector_unaccented']),
        ]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # We store here the current status as we need to check if it
        # has change - check what we do when saving the Aid instance.
        self.original_status = self.status

    def set_slug(self):
        """Set the object's slug.

        Lots of aids have duplicate name, so we prefix the slug with random
        characters."""
        if not self.id:
            full_title = '{}-{}'.format(str(uuid4())[:4], self.name)
            self.slug = slugify(full_title)[:50]

    def set_publication_date(self):
        """Set the object's publication date.

        We set the first publication date once and for all when the aid is
        first published.
        """
        if self.is_published() and self.date_published is None:
            self.date_published = timezone.now()

    def set_search_vector_unaccented(self, financers=None, instructors=None):
        """Update the full text unaccented cache field."""

        # Note: we use `SearchVector(Value(self.field))` instead of
        # `SearchVector('field')` because the latter only works for updates,
        # not when inserting new records.
        #
        # Note 2: we have to pass the financers parameter instead of using
        # `self.financers.all()` because that last expression would not work
        # during an object creation.
        search_vector_unaccented = \
            SearchVector(
                Value(self.name, output_field=models.CharField()),
                weight='A',
                config='french_unaccent') + \
            SearchVector(
                Value(self.name_initial, output_field=models.CharField()),
                weight='A',
                config='french_unaccent') + \
            SearchVector(
                Value(self.description, output_field=models.CharField()),
                weight='B',
                config='french_unaccent') + \
            SearchVector(
                Value(self.project_examples, output_field=models.CharField()),
                weight='B',
                config='french_unaccent') + \
            SearchVector(
                Value(self.eligibility, output_field=models.CharField()),
                weight='D',
                config='french_unaccent')

        if financers:
            search_vector_unaccented += SearchVector(Value(
                ' '.join(str(backer) for backer in financers),
                output_field=models.CharField()),
                                                     weight='D',
                                                     config='french_unaccent')

        if instructors:
            search_vector_unaccented += SearchVector(Value(
                ' '.join(str(backer) for backer in instructors),
                output_field=models.CharField()),
                                                     weight='D',
                                                     config='french_unaccent')

        self.search_vector_unaccented = search_vector_unaccented

    def save(self, *args, **kwargs):
        self.set_slug()
        self.set_publication_date()
        is_new = not self.id  # There's no ID => newly created aid
        is_being_published = self.is_published() and self.status_has_changed()
        if not is_new and is_being_published and self.author_notification and not self.is_imported:
            send_publication_email.delay(aid_id=self.id)
        return super().save(*args, **kwargs)

    def __str__(self):
        return self.name

    def get_absolute_url(self):
        return reverse('aid_detail_view', args=[self.slug])

    def get_admin_url(self):
        return reverse('admin:aids_aid_change', args=[self.id])

    def get_sorted_local_aids(self):
        return self.local_aids.live() \
            .select_related('perimeter') \
            .order_by('perimeter__name')

    def is_draft(self):
        return self.status == AidWorkflow.states.draft

    def is_under_review(self):
        return self.status == AidWorkflow.states.reviewable

    def is_published(self):
        return self.status == AidWorkflow.states.published

    def status_has_changed(self):
        return self.original_status != self.status

    def is_financial(self):
        """Does this aid have financial parts?"""
        aid_types = self.aid_types or []
        return bool(set(aid_types) & set(FINANCIAL_AIDS_LIST))

    def is_grant(self):
        """Does this aid is a grant?"""
        aid_types = self.aid_types or []
        return bool(set(aid_types) & set((('grant', 'Subvention'))))

    def is_loan(self):
        """Does this aid is a loan?"""
        aid_types = self.aid_types or []
        return bool(set(aid_types) & set((('loan', 'Prêt'))))

    def is_technical(self):
        """Does this aid have technical parts?"""
        aid_types = self.aid_types or []
        return bool(set(aid_types) & set(TECHNICAL_AIDS_LIST))

    def is_ongoing(self):
        return self.recurrence == self.RECURRENCES.ongoing

    def has_calendar(self):
        """Does the aid has valid calendar data?."""

        if self.is_ongoing():
            return False

        return any(
            (self.start_date, self.predeposit_date, self.submission_deadline))

    def has_approaching_deadline(self):
        if self.is_ongoing() or not self.submission_deadline:
            return False

        delta = self.submission_deadline - timezone.now().date()
        return delta.days >= 0 \
            and delta.days <= settings.APPROACHING_DEADLINE_DELTA

    def days_before_deadline(self):
        if not self.submission_deadline or self.is_ongoing():
            return None

        today = timezone.now().date()
        deadline_delta = self.submission_deadline - today
        return deadline_delta.days

    def is_coming_soon(self):
        if not self.start_date:
            return False

        today = timezone.now().date()
        return self.start_date > today

    def has_expired(self):
        if not self.submission_deadline:
            return False

        today = timezone.now().date()
        return self.submission_deadline < today

    def is_live(self):
        """True if the aid must be displayed on the site."""
        return self.is_published() and not self.has_expired()

    def has_projects(self):
        return self.projects is not None

    def get_live_status_display(self):
        status = 'Affichée' if self.is_live() else 'Non affichée'
        return status

    def has_eligibility_test(self):
        return self.eligibility_test is not None

    def is_local(self):
        return self.generic_aid is not None

    def is_corporate_aid(self):
        return (self.targeted_audiences
                and self.AUDIENCES.private_sector in self.targeted_audiences)

    def clone_m2m(self, source_aid):
        """
        Clones the many-to-many fields for the the given source aid.
        """
        m2m_fields = self._meta.many_to_many
        projects_field = self._meta.get_field('projects')

        for field in m2m_fields:
            if field != projects_field:
                for item in field.value_from_object(source_aid):
                    getattr(self, field.attname).add(item)
        self.save()
Esempio n. 8
0
class Aid(xwf_models.WorkflowEnabled, models.Model):
    """Represents a single Aid."""

    TYPES = Choices(
        ('grant', _('Grant')),
        ('loan', _('Loan')),
        ('recoverable_advance', _('Recoverable advance')),
        ('interest_subsidy', _('Interest subsidy')),
        ('guidance', _('Guidance')),
        ('networking', _('Networking')),
        ('valorisation', _('Valorisation')),
    )

    FINANCIAL_AIDS = ('grant', 'loan', 'recoverable_advance',
                      'interest_subsidy')

    TECHNICAL_AIDS = ('guidance', 'networking', 'valorisation')

    PERIMETERS = Choices(
        ('europe', _('Europe')),
        ('france', _('France')),
        ('region', _('Region')),
        ('department', _('Department')),
        ('commune', _('Commune')),
        ('mainland', _('Mainland')),
        ('overseas', _('Overseas')),
        ('other', _('Other')),
    )

    STEPS = Choices(
        ('preop', _('Preoperational')),
        ('op', _('Operational')),
        ('postop', _('Postoperation')),
    )

    AUDIANCES = Choices(
        ('commune', _('Commune')),
        ('department', _('Department')),
        ('region', _('Region')),
        ('epci', _('Audiance EPCI')),
        ('lessor', _('Audiance lessor')),
        ('association', _('Association')),
        ('private_person', _('Individual')),
        ('researcher', _('Research')),
        ('private_sector', _('Private sector')),
    )

    DESTINATIONS = Choices(
        ('service', _('Service (AMO, survey)')),
        ('works', _('Works')),
        ('supply', _('Supply')),
    )

    RECURRENCE = Choices(
        ('oneoff', _('One off')),
        ('ongoing', _('Ongoing')),
        ('recurring', _('Recurring')),
    )

    objects = ExistingAidsManager()
    all_aids = AidQuerySet.as_manager()

    slug = models.SlugField(
        _('Slug'),
        help_text=_('Let it empty so it will be autopopulated.'),
        blank=True)
    name = models.CharField(
        _('Name'),
        max_length=256,
        null=False, blank=False)
    author = models.ForeignKey(
        'accounts.User',
        on_delete=models.PROTECT,
        verbose_name=_('Author'),
        help_text=_('Who is submitting the aid?'))
    backers = models.ManyToManyField(
        'backers.Backer',
        related_name='aids',
        verbose_name=_('Backers'),
        help_text=_('On a national level if appropriate'))
    description = models.TextField(
        _('Short description'),
        blank=False)
    eligibility = models.TextField(
        _('Eligibility'),
        blank=True)
    perimeter = models.ForeignKey(
        'geofr.Perimeter',
        verbose_name=_('Perimeter'),
        on_delete=models.PROTECT,
        null=True, blank=True,
        help_text=_('What is the aid broadcasting perimeter?'))
    mobilization_steps = ChoiceArrayField(
        verbose_name=_('Mobilization step'),
        null=True, blank=True,
        base_field=models.CharField(
            max_length=32,
            choices=STEPS,
            default=STEPS.preop))
    url = models.URLField(
        _('URL'),
        blank=True)
    application_url = models.URLField(
        _('Application url'),
        blank=True)
    targeted_audiances = ChoiceArrayField(
        verbose_name=_('Targeted audiances'),
        null=True, blank=True,
        base_field=models.CharField(
            max_length=32,
            choices=AUDIANCES))
    aid_types = ChoiceArrayField(
        verbose_name=_('Aid types'),
        null=True, blank=True,
        base_field=models.CharField(
            max_length=32,
            choices=TYPES),
        help_text=_('Specify the help type or types.'))
    destinations = ChoiceArrayField(
        verbose_name=_('Destinations'),
        null=True,
        blank=True,
        base_field=models.CharField(
            max_length=32,
            choices=DESTINATIONS))
    start_date = models.DateField(
        _('Start date'),
        null=True, blank=True,
        help_text=_('When is the application opening?'))
    predeposit_date = models.DateField(
        _('Predeposit date'),
        null=True, blank=True,
        help_text=_('When is the pre-deposit date, if applicable?'))
    submission_deadline = models.DateField(
        _('Submission deadline'),
        null=True, blank=True,
        help_text=_('When is the submission deadline?'))
    subvention_rate = models.DecimalField(
        _('Subvention rate (in %)'),
        max_digits=6,
        decimal_places=2,
        null=True, blank=True,
        help_text=_('If this is a subvention aid, specify the rate.'))
    contact_email = models.EmailField(
        _('Contact email'),
        blank=True)
    contact_phone = models.CharField(
        _('Contact phone number'),
        max_length=20,
        blank=True)
    contact_detail = models.CharField(
        _('Contact detail'),
        max_length=256,
        blank=True)
    recurrence = models.CharField(
        _('Recurrence'),
        help_text=_('Is this a one-off aid, is it recurring or ongoing?'),
        max_length=16,
        choices=RECURRENCE,
        blank=True)
    status = xwf_models.StateField(
        AidWorkflow,
        verbose_name=_('Status'))
    date_created = models.DateTimeField(
        _('Date created'),
        default=timezone.now)
    date_updated = models.DateTimeField(
        _('Date updated'),
        auto_now=True)

    # Third-party data import related fields
    is_imported = models.BooleanField(
        _('Is imported?'),
        default=False)
    import_uniqueid = models.CharField(
        _('Unique identifier for imported data'),
        max_length=20,
        blank=True)

    # This field is used to index searchable text content
    search_vector = SearchVectorField(
        _('Search vector'),
        null=True)

    # This is where we store tags
    tags = ArrayField(
        models.CharField(max_length=50, blank=True),
        verbose_name=_('Tags'),
        default=list,
        size=16,
        blank=True)
    _tags_m2m = models.ManyToManyField(
        'tags.Tag',
        related_name='aids',
        verbose_name=_('Tags'))

    class Meta:
        verbose_name = _('Aid')
        verbose_name_plural = _('Aids')
        indexes = [
            GinIndex(fields=['search_vector']),
        ]

    def set_slug(self):
        """Set the object's slug.

        Lots of aids have duplicate name, so we prefix the slug with random
        characters."""
        if not self.id:
            full_title = '{}-{}'.format(str(uuid4())[:4], self.name)
            self.slug = slugify(full_title)[:50]

    def set_search_vector(self):
        """Update the full text cache field."""

        # Note: we use `SearchVector(Value(self.field))` instead of
        # `SearchVector('field')` because the latter only works for updates,
        # not when inserting new records.
        self.search_vector = \
            SearchVector(Value(self.name), weight='A', config='french') + \
            SearchVector(
                Value(self.eligibility),
                weight='D',
                config='french') + \
            SearchVector(
                Value(self.description),
                weight='B',
                config='french') + \
            SearchVector(
                Value(' '.join(self.tags)),
                weight='A',
                config='french')

    def save(self, *args, **kwargs):
        self.set_slug()
        self.set_search_vector()
        return super().save(*args, **kwargs)

    def __str__(self):
        return self.name

    def get_absolute_url(self):
        return reverse('aid_detail_view', args=[self.slug])

    def get_admin_url(self):
        return reverse('admin:aids_aid_change', args=[self.id])

    def is_draft(self):
        return self.status == AidWorkflow.states.draft

    def is_under_review(self):
        return self.status == AidWorkflow.states.reviewable

    def is_published(self):
        return self.status == AidWorkflow.states.published

    def is_financial(self):
        """Does this aid have financial parts?"""
        return bool(set(self.aid_types) & set(self.FINANCIAL_AIDS))

    def is_technical(self):
        """Does this aid have technical parts?"""
        return bool(set(self.aid_types) & set(self.TECHNICAL_AIDS))

    def has_appreaching_deadline(self):
        if not self.submission_deadline:
            return False

        delta = self.submission_deadline - timezone.now().date()
        return delta.days <= settings.APPROACHING_DEADLINE_DELTA
Esempio n. 9
0
class PromotionPost(xwf_models.WorkflowEnabled, models.Model):

    title = models.CharField(
        'Titre',
        max_length=256,
        db_index=True)
    slug = models.SlugField(
        "Fragment d'URL",
        help_text='Laisser vide pour autoremplir.',
        blank=True)
    short_text = models.TextField(
        "Texte d'introduction",
        help_text="Introduction concise (inférieure à 256 caractères).",
        max_length=256,
        null=True, blank=True)

    button_link = models.URLField(
        'Lien du bouton',
        blank=False)
    button_title = models.CharField(
        'Titre du bouton',
        max_length=120,
        db_index=True)

    perimeter = models.ForeignKey(
        'geofr.Perimeter',
        verbose_name='Périmètre',
        on_delete=models.PROTECT,
        null=True, blank=True)
    categories = models.ManyToManyField(
        'categories.Category',
        verbose_name='Sous-thématiques',
        related_name='promotionsPost',
        blank=True)
    programs = models.ManyToManyField(
        'programs.Program',
        verbose_name='Programmes',
        related_name='promotionsPost',
        blank=True)
    backers = models.ManyToManyField(
        'backers.Backer',
        verbose_name="Porteurs d'aides",
        related_name='promotionsPost',
        blank=True)

    status = xwf_models.StateField(
        PromotionPostWorkflow,
        verbose_name='Statut')

    date_created = models.DateTimeField(
        'Date de création',
        default=timezone.now)
    date_updated = models.DateTimeField(
        'Date de mise à jour',
        auto_now=True)

    class Meta:
        verbose_name = 'Communication promotionnelle'
        verbose_name_plural = 'Communications promotionnelles'

    def __str__(self):
        return self.title

    def set_slug(self):
        """Set the object's slug if it is missing."""
        if not self.slug:
            self.slug = slugify(self.title)[:50]

    def save(self, *args, **kwargs):
        self.set_slug()
        return super().save(*args, **kwargs)
Esempio n. 10
0
class BlogPost(xwf_models.WorkflowEnabled, models.Model):

    objects = BlogPostQuerySet.as_manager()

    title = models.CharField(
        'Titre',
        max_length=256,
        db_index=True)
    slug = models.SlugField(
        "Fragment d'URL",
        help_text='Laisser vide pour autoremplir.',
        blank=True)
    short_text = models.TextField(
        "Texte d'introduction",
        help_text="Introduction concise (inférieure à 256 caractères).",
        max_length=256,
        null=True, blank=True)
    text = models.TextField(
        "Contenu",
        blank=False)
    logo = models.FileField(
        "Illustration",
        help_text='Évitez les fichiers trop lourds. Préférez les fichiers svg.',
        upload_to=logo_upload_to,
        null=True, blank=True)
    author = models.ForeignKey(
        'accounts.User',
        verbose_name='Auteur',
        on_delete=models.PROTECT,
        related_name='blog_posts',
        null=True)

    category = models.ForeignKey(
        'BlogPostCategory',
        verbose_name="Catégorie de l'article de blog",
        on_delete=models.PROTECT,
        related_name='categories',
        null=True, blank=True)

    status = xwf_models.StateField(
        BlogPostWorkflow,
        verbose_name='Statut')

    # SEO
    meta_title = models.CharField(
        _('Meta title'),
        max_length=180,
        blank=True, default='',
        help_text=_('This will be displayed in SERPs. '
                    'Keep it under 60 characters. '
                    'Leave empty and we will reuse the post\'s title.'))
    meta_description = models.TextField(
        _('Meta description'),
        blank=True, default='',
        max_length=256,
        help_text=_('This will be displayed in SERPs. '
                    'Keep it under 120 characters.'))

    date_created = models.DateTimeField(
        'Date de création',
        default=timezone.now)
    date_updated = models.DateTimeField(
        'Date de mise à jour',
        auto_now=True)
    date_published = models.DateTimeField(
        'Première date de publication',
        null=True, blank=True)

    class Meta:
        verbose_name = 'Article de blog'
        verbose_name_plural = 'Articles de blog'
        ordering = ['-date_created']

    def __str__(self):
        return self.title

    def set_slug(self):
        """Set the object's slug if it is missing."""
        if not self.slug:
            self.slug = slugify(self.title)[:50]

    def set_publication_date(self):
        if self.is_published() and self.date_published is None:
            self.date_published = timezone.now()

    def save(self, *args, **kwargs):
        self.set_slug()
        self.set_publication_date()
        return super().save(*args, **kwargs)

    def get_absolute_url(self):
        return reverse('blog_post_detail_view', args=[self.slug])

    def is_draft(self):
        return self.status == BlogPostWorkflow.states.draft

    def is_published(self):
        return self.status == BlogPostWorkflow.states.published
class FluxoDietaEspecialPartindoDaEscola(xwf_models.WorkflowEnabled,
                                         models.Model):
    workflow_class = DietaEspecialWorkflow
    status = xwf_models.StateField(workflow_class)

    rastro_escola = models.ForeignKey(
        'escola.Escola',
        on_delete=models.DO_NOTHING,
        null=True,
        blank=True,
        related_name='%(app_label)s_%(class)s_rastro_escola',
        editable=False)
    rastro_dre = models.ForeignKey(
        'escola.DiretoriaRegional',
        on_delete=models.DO_NOTHING,
        null=True,
        related_name='%(app_label)s_%(class)s_rastro_dre',
        blank=True,
        editable=False)
    rastro_lote = models.ForeignKey(
        'escola.Lote',
        on_delete=models.DO_NOTHING,
        null=True,
        blank=True,
        related_name='%(app_label)s_%(class)s_rastro_lote',
        editable=False)
    rastro_terceirizada = models.ForeignKey(
        'terceirizada.Terceirizada',
        on_delete=models.DO_NOTHING,
        null=True,
        blank=True,
        related_name='%(app_label)s_%(class)s_rastro_terceirizada',
        editable=False)

    def _salva_rastro_solicitacao(self):
        escola = self.criado_por.vinculo_atual.instituicao
        self.rastro_escola = escola
        self.rastro_dre = escola.diretoria_regional
        self.rastro_lote = escola.lote
        self.rastro_terceirizada = escola.lote.terceirizada
        self.save()

    @property
    def _partes_interessadas_inicio_fluxo(self):
        """Quando a escola faz a solicitação, as pessoas da DRE são as partes interessadas.

        Será retornada uma lista de emails para envio via celery.
        """
        email_query_set = self.rastro_escola.vinculos.filter(
            ativo=True).values_list('usuario__email', flat=False)
        return [email for email in email_query_set]

    @property
    def partes_interessadas_codae_negou(self):
        # TODO: definir partes interessadas
        return []

    @property
    def partes_interessadas_codae_autorizou(self):
        # TODO: definir partes interessadas
        return []

    @property
    def partes_interessadas_terceirizadas_tomou_ciencia(self):
        # TODO: definir partes interessadas
        return []

    @property
    def template_mensagem(self):
        raise NotImplementedError(
            'Deve criar um property que recupera o assunto e corpo mensagem desse objeto'
        )

    @xworkflows.after_transition('inicia_fluxo')
    def _inicia_fluxo_hook(self, *args, **kwargs):
        self._salva_rastro_solicitacao()
        user = kwargs['user']
        assunto, corpo = self.template_mensagem
        envia_email_em_massa_task.delay(
            assunto=assunto,
            corpo=corpo,
            emails=self._partes_interessadas_inicio_fluxo,
            html=None)
        self.salvar_log_transicao(
            status_evento=LogSolicitacoesUsuario.INICIO_FLUXO, usuario=user)

    @xworkflows.after_transition('codae_autoriza')
    def _codae_autoriza_hook(self, *args, **kwargs):
        user = kwargs['user']
        assunto, corpo = self.template_mensagem
        self.salvar_log_transicao(
            status_evento=LogSolicitacoesUsuario.CODAE_AUTORIZOU, usuario=user)
        self._salva_rastro_solicitacao()

    @xworkflows.after_transition('codae_nega')
    def _codae_nega_hook(self, *args, **kwargs):
        user = kwargs['user']
        assunto, corpo = self.template_mensagem
        self.salvar_log_transicao(
            status_evento=LogSolicitacoesUsuario.CODAE_NEGOU, usuario=user)
        self._salva_rastro_solicitacao()

    @xworkflows.after_transition('terceirizada_toma_ciencia')
    def _terceirizada_toma_ciencia_hook(self, *args, **kwargs):
        user = kwargs['user']
        if user:
            assunto, corpo = self.template_mensagem
            self.salvar_log_transicao(status_evento=LogSolicitacoesUsuario.
                                      TERCEIRIZADA_TOMOU_CIENCIA,
                                      usuario=user)

    class Meta:
        abstract = True
class FluxoInformativoPartindoDaEscola(xwf_models.WorkflowEnabled,
                                       models.Model):
    workflow_class = InformativoPartindoDaEscolaWorkflow
    status = xwf_models.StateField(workflow_class)

    rastro_escola = models.ForeignKey(
        'escola.Escola',
        on_delete=models.DO_NOTHING,
        null=True,
        blank=True,
        related_name='%(app_label)s_%(class)s_rastro_escola',
        editable=False)
    rastro_dre = models.ForeignKey(
        'escola.DiretoriaRegional',
        on_delete=models.DO_NOTHING,
        null=True,
        related_name='%(app_label)s_%(class)s_rastro_dre',
        blank=True,
        editable=False)
    rastro_lote = models.ForeignKey(
        'escola.Lote',
        on_delete=models.DO_NOTHING,
        null=True,
        blank=True,
        related_name='%(app_label)s_%(class)s_rastro_lote',
        editable=False)
    rastro_terceirizada = models.ForeignKey(
        'terceirizada.Terceirizada',
        on_delete=models.DO_NOTHING,
        null=True,
        blank=True,
        related_name='%(app_label)s_%(class)s_rastro_terceirizada',
        editable=False)

    def _salva_rastro_solicitacao(self):
        self.rastro_escola = self.escola
        self.rastro_dre = self.escola.diretoria_regional
        self.rastro_lote = self.escola.lote
        self.rastro_terceirizada = self.escola.lote.terceirizada
        self.save()

    @property
    def pode_excluir(self):
        return self.status == self.workflow_class.RASCUNHO

    @property
    def partes_interessadas_informacao(self):
        # TODO: definir partes interessadas
        return []

    @property
    def partes_interessadas_terceirizadas_tomou_ciencia(self):
        # TODO: definir partes interessadas
        return []

    @property
    def template_mensagem(self):
        raise NotImplementedError(
            'Deve criar um property que recupera o assunto e corpo mensagem desse objeto'
        )

    @xworkflows.after_transition('informa')
    def _informa_hook(self, *args, **kwargs):
        user = kwargs['user']
        assunto, corpo = self.template_mensagem
        self.salvar_log_transicao(
            status_evento=LogSolicitacoesUsuario.INICIO_FLUXO, usuario=user)
        self._salva_rastro_solicitacao()

    @xworkflows.after_transition('terceirizada_toma_ciencia')
    def _terceirizada_toma_ciencia_hook(self, *args, **kwargs):
        user = kwargs['user']
        if user:
            assunto, corpo = self.template_mensagem
            self.salvar_log_transicao(status_evento=LogSolicitacoesUsuario.
                                      TERCEIRIZADA_TOMOU_CIENCIA,
                                      usuario=user)

    class Meta:
        abstract = True
class FluxoAprovacaoPartindoDaDiretoriaRegional(xwf_models.WorkflowEnabled,
                                                models.Model):
    workflow_class = PedidoAPartirDaDiretoriaRegionalWorkflow
    status = xwf_models.StateField(workflow_class)
    DIAS_PARA_CANCELAR = 2

    rastro_escolas = models.ManyToManyField(
        'escola.Escola',
        blank=True,
        related_name='%(app_label)s_%(class)s_rastro_escola',
        editable=False)
    rastro_dre = models.ForeignKey(
        'escola.DiretoriaRegional',
        on_delete=models.DO_NOTHING,
        null=True,
        related_name='%(app_label)s_%(class)s_rastro_dre',
        blank=True,
        editable=False)
    rastro_lote = models.ForeignKey(
        'escola.Lote',
        on_delete=models.DO_NOTHING,
        null=True,
        blank=True,
        related_name='%(app_label)s_%(class)s_rastro_lote',
        editable=False)
    rastro_terceirizada = models.ForeignKey(
        'terceirizada.Terceirizada',
        on_delete=models.DO_NOTHING,
        null=True,
        blank=True,
        related_name='%(app_label)s_%(class)s_rastro_terceirizada',
        editable=False)

    def _salva_rastro_solicitacao(self):
        escolas = [i.escola for i in self.escolas_quantidades.all()]
        self.rastro_escolas.set(escolas)
        self.rastro_dre = self.diretoria_regional
        self.rastro_lote = self.lote
        self.rastro_terceirizada = self.lote.terceirizada
        self.save()

    def cancelar_pedido(self, user, justificativa=''):
        """O objeto que herdar de FluxoAprovacaoPartindoDaDiretoriaRegional, deve ter um property data.

        Atualmente o único pedido da DRE é o Solicitação kit lanche unificada
        Dado dias de antecedencia de prazo, verifica se pode e altera o estado
        """
        dia_antecedencia = datetime.date.today() + datetime.timedelta(
            days=self.DIAS_PARA_CANCELAR)
        data_do_evento = self.data
        if isinstance(data_do_evento, datetime.datetime):
            # TODO: verificar por que os models estao retornando datetime em vez de date
            data_do_evento = data_do_evento.date()

        if (data_do_evento > dia_antecedencia) and (
                self.status != self.workflow_class.DRE_CANCELOU):
            self.status = self.workflow_class.DRE_CANCELOU
            self.salvar_log_transicao(
                status_evento=LogSolicitacoesUsuario.DRE_CANCELOU,
                usuario=user,
                justificativa=justificativa)
            self.save()
        elif self.status == self.workflow_class.DRE_CANCELOU:
            raise xworkflows.InvalidTransitionError('Já está cancelada')
        else:
            raise xworkflows.InvalidTransitionError(
                f'Só pode cancelar com no mínimo {self.DIAS_PARA_CANCELAR} dia(s) de antecedência'
            )

    @property
    def pode_excluir(self):
        return self.status == self.workflow_class.RASCUNHO

    @property
    def ta_na_dre(self):
        return self.status in [
            self.workflow_class.CODAE_PEDIU_DRE_REVISAR,
            self.workflow_class.RASCUNHO
        ]

    @property
    def ta_na_codae(self):
        return self.status == self.workflow_class.CODAE_A_AUTORIZAR

    @property
    def ta_na_terceirizada(self):
        return self.status == self.workflow_class.CODAE_AUTORIZADO

    @property
    def partes_interessadas_codae_autoriza(self):
        # TODO: definir partes interessadas
        return []

    @property
    def partes_interessadas_codae_nega(self):
        # TODO: definir partes interessadas
        return []

    @property
    def partes_interessadas_inicio_fluxo(self):
        # TODO: definir partes interessadas
        return []

    @property
    def partes_interessadas_terceirizadas_tomou_ciencia(self):
        # TODO: definir partes interessadas
        return []

    @property
    def template_mensagem(self):
        raise NotImplementedError(
            'Deve criar um property que recupera o assunto e corpo mensagem desse objeto'
        )

    def salvar_log_transicao(self, status_evento, usuario, **kwargs):
        raise NotImplementedError('Deve criar um método salvar_log_transicao')

    @xworkflows.after_transition('inicia_fluxo')
    def _inicia_fluxo_hook(self, *args, **kwargs):
        user = kwargs['user']
        assunto, corpo = self.template_mensagem

        self.salvar_log_transicao(
            status_evento=LogSolicitacoesUsuario.INICIO_FLUXO, usuario=user)
        self._salva_rastro_solicitacao()

    @xworkflows.after_transition('codae_autoriza')
    def _codae_autoriza_hook(self, *args, **kwargs):
        user = kwargs['user']
        if user:
            assunto, corpo = self.template_mensagem
            self.salvar_log_transicao(
                status_evento=LogSolicitacoesUsuario.CODAE_AUTORIZOU,
                usuario=user)

    @xworkflows.after_transition('terceirizada_toma_ciencia')
    def _terceirizada_toma_ciencia_hook(self, *args, **kwargs):
        user = kwargs['user']
        if user:
            assunto, corpo = self.template_mensagem
            self.salvar_log_transicao(status_evento=LogSolicitacoesUsuario.
                                      TERCEIRIZADA_TOMOU_CIENCIA,
                                      usuario=user)

    @xworkflows.after_transition('codae_nega')
    def _codae_recusou_hook(self, *args, **kwargs):
        user = kwargs['user']
        justificativa = kwargs.get('justificativa', '')
        if user:
            assunto, corpo = self.template_mensagem
            self.salvar_log_transicao(
                status_evento=LogSolicitacoesUsuario.CODAE_NEGOU,
                usuario=user,
                justificativa=justificativa)

    class Meta:
        abstract = True
Esempio n. 14
0
class JobApplication(xwf_models.WorkflowEnabled, models.Model):
    """
    An "unsolicited" job application.

    It inherits from `xwf_models.WorkflowEnabled` to add a workflow to its `state` field:
        - https://github.com/rbarrois/django_xworkflows
        - https://github.com/rbarrois/xworkflows
    """

    SENDER_KIND_JOB_SEEKER = KIND_JOB_SEEKER
    SENDER_KIND_PRESCRIBER = KIND_PRESCRIBER
    SENDER_KIND_SIAE_STAFF = KIND_SIAE_STAFF

    SENDER_KIND_CHOICES = (
        (SENDER_KIND_JOB_SEEKER, _("Demandeur d'emploi")),
        (SENDER_KIND_PRESCRIBER, _("Prescripteur")),
        (SENDER_KIND_SIAE_STAFF, _("Employeur (SIAE)")),
    )

    REFUSAL_REASON_DID_NOT_COME = "did_not_come"
    REFUSAL_REASON_UNAVAILABLE = "unavailable"
    REFUSAL_REASON_NON_ELIGIBLE = "non_eligible"
    REFUSAL_REASON_ELIGIBILITY_DOUBT = "eligibility_doubt"
    REFUSAL_REASON_INCOMPATIBLE = "incompatible"
    REFUSAL_REASON_PREVENT_OBJECTIVES = "prevent_objectives"
    REFUSAL_REASON_NO_POSITION = "no_position"
    REFUSAL_REASON_APPROVAL_EXPIRATION_TOO_CLOSE = "approval_expiration_too_close"
    REFUSAL_REASON_OTHER = "other"
    REFUSAL_REASON_CHOICES = (
        (REFUSAL_REASON_DID_NOT_COME, _("Candidat non venu ou non joignable")),
        (
            REFUSAL_REASON_UNAVAILABLE,
            _("Candidat indisponible ou non intéressé par le poste"),
        ),
        (REFUSAL_REASON_NON_ELIGIBLE, _("Candidat non éligible")),
        (
            REFUSAL_REASON_ELIGIBILITY_DOUBT,
            _("Doute sur l'éligibilité du candidat (penser à renvoyer la personne vers un prescripteur)"
              ),
        ),
        (
            REFUSAL_REASON_INCOMPATIBLE,
            _("Un des freins à l'emploi du candidat est incompatible avec le poste proposé"
              ),
        ),
        (
            REFUSAL_REASON_PREVENT_OBJECTIVES,
            _("L'embauche du candidat empêche la réalisation des objectifs du dialogue de gestion"
              ),
        ),
        (REFUSAL_REASON_NO_POSITION, _("Pas de poste ouvert en ce moment")),
        (
            REFUSAL_REASON_APPROVAL_EXPIRATION_TOO_CLOSE,
            _("La date de fin du PASS IAE / agrément est trop proche"),
        ),
        (REFUSAL_REASON_OTHER, _("Autre")),
    )

    ERROR_START_IN_PAST = _(
        f"La date de début du contrat ne doit pas être dans le passé.")
    ERROR_END_IS_BEFORE_START = _(
        f"La date de fin du contrat doit être postérieure à la date de début.")
    ERROR_DURATION_TOO_LONG = _(
        f"La durée du contrat ne peut dépasser {Approval.DEFAULT_APPROVAL_YEARS} ans."
    )

    APPROVAL_DELIVERY_MODE_AUTOMATIC = "automatic"
    APPROVAL_DELIVERY_MODE_MANUAL = "manual"

    APPROVAL_DELIVERY_MODE_CHOICES = (
        (APPROVAL_DELIVERY_MODE_AUTOMATIC, _("Automatique")),
        (APPROVAL_DELIVERY_MODE_MANUAL, _("Manuel")),
    )

    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

    job_seeker = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name=_("Demandeur d'emploi"),
        on_delete=models.CASCADE,
        related_name="job_applications",
    )

    # Who send the job application. It can be the same user as `job_seeker`
    sender = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name=_("Émetteur"),
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="job_applications_sent",
    )

    sender_kind = models.CharField(
        verbose_name=_("Type de l'émetteur"),
        max_length=10,
        choices=SENDER_KIND_CHOICES,
        default=SENDER_KIND_PRESCRIBER,
    )

    # When the sender is an SIAE staff member, keep a track of his current SIAE.
    sender_siae = models.ForeignKey(
        "siaes.Siae",
        verbose_name=_("SIAE émettrice"),
        null=True,
        blank=True,
        on_delete=models.CASCADE,
    )

    # When the sender is a prescriber, keep a track of his current organization (if any).
    sender_prescriber_organization = models.ForeignKey(
        "prescribers.PrescriberOrganization",
        verbose_name=_("Organisation du prescripteur émettrice"),
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
    )

    to_siae = models.ForeignKey(
        "siaes.Siae",
        verbose_name=_("SIAE destinataire"),
        on_delete=models.CASCADE,
        related_name="job_applications_received",
    )

    state = xwf_models.StateField(JobApplicationWorkflow,
                                  verbose_name=_("État"),
                                  db_index=True)

    # Jobs in which the job seeker is interested (optional).
    selected_jobs = models.ManyToManyField(
        "siaes.SiaeJobDescription",
        verbose_name=_("Métiers recherchés"),
        blank=True)

    message = models.TextField(verbose_name=_("Message de candidature"),
                               blank=True)
    answer = models.TextField(verbose_name=_("Message de réponse"), blank=True)
    refusal_reason = models.CharField(
        verbose_name=_("Motifs de refus"),
        max_length=30,
        choices=REFUSAL_REASON_CHOICES,
        blank=True,
    )

    hiring_start_at = models.DateField(
        verbose_name=_("Date de début du contrat"), blank=True, null=True)
    hiring_end_at = models.DateField(verbose_name=_("Date de fin du contrat"),
                                     blank=True,
                                     null=True)

    # Job applications sent to SIAEs subject to eligibility rules will
    # obtain an Approval after being accepted.
    approval = models.ForeignKey(
        "approvals.Approval",
        verbose_name=_("PASS IAE"),
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
    )
    approval_number_sent_by_email = models.BooleanField(
        verbose_name=_("PASS IAE envoyé par email"), default=False)
    approval_number_sent_at = models.DateTimeField(
        verbose_name=_("Date d'envoi du PASS IAE"),
        blank=True,
        null=True,
        db_index=True)
    approval_delivery_mode = models.CharField(
        verbose_name=_("Mode d'attribution du PASS IAE"),
        max_length=30,
        choices=APPROVAL_DELIVERY_MODE_CHOICES,
        blank=True,
    )
    approval_number_delivered_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name=_("PASS IAE envoyé par"),
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="approval_numbers_sent",
    )

    created_at = models.DateTimeField(verbose_name=_("Date de création"),
                                      default=timezone.now,
                                      db_index=True)
    updated_at = models.DateTimeField(verbose_name=_("Date de modification"),
                                      blank=True,
                                      null=True,
                                      db_index=True)

    objects = models.Manager.from_queryset(JobApplicationQuerySet)()

    class Meta:
        verbose_name = _("Candidature")
        verbose_name_plural = _("Candidatures")
        ordering = ["-created_at"]

    def __str__(self):
        return str(self.id)

    def save(self, *args, **kwargs):
        self.updated_at = timezone.now()
        return super().save(*args, **kwargs)

    @property
    def is_sent_by_proxy(self):
        return self.sender != self.job_seeker

    @property
    def is_sent_by_authorized_prescriber(self):
        return bool(self.sender_kind == self.SENDER_KIND_PRESCRIBER
                    and self.sender_prescriber_organization
                    and self.sender_prescriber_organization.is_authorized)

    @property
    def eligibility_diagnosis_by_siae_required(self):
        """
        Returns True if an eligibility diagnosis must be made by an SIAE
        when processing an application, False otherwise.
        """
        return (self.state.is_processing
                and self.to_siae.is_subject_to_eligibility_rules
                and not self.job_seeker.has_eligibility_diagnosis)

    @property
    def accepted_by(self):
        if not self.state.is_accepted:
            return None
        return (self.logs.select_related("user").get(
            to_state=JobApplicationWorkflow.STATE_ACCEPTED).user)

    @property
    def can_download_approval_as_pdf(self):
        return (self.state.is_accepted
                and self.to_siae.is_subject_to_eligibility_rules
                and self.approval and self.approval.is_valid)

    # Workflow transitions.

    @xwf_models.transition()
    def process(self, *args, **kwargs):
        pass

    @xwf_models.transition()
    def accept(self, *args, **kwargs):

        accepted_by = kwargs.get("user")

        # Mark other related job applications as obsolete.
        for job_application in self.job_seeker.job_applications.exclude(
                pk=self.pk).pending():
            job_application.render_obsolete(*args, **kwargs)

        # Notification email.
        emails = [self.email_accept]

        # Approval issuance logic.
        if self.to_siae.is_subject_to_eligibility_rules:

            approvals_wrapper = self.job_seeker.approvals_wrapper

            if (approvals_wrapper.has_in_waiting_period
                    and not self.is_sent_by_authorized_prescriber):
                # Security check: it's supposed to be blocked upstream.
                raise xwf_models.AbortTransition(
                    "Job seeker has an approval in waiting period.")

            if approvals_wrapper.has_valid:
                # Automatically reuse an existing valid Itou or PE approval.
                self.approval = Approval.get_or_create_from_valid(
                    approvals_wrapper)
                emails.append(self.email_approval_number(accepted_by))
            elif (self.job_seeker.pole_emploi_id
                  or self.job_seeker.lack_of_pole_emploi_id_reason
                  == self.job_seeker.REASON_NOT_REGISTERED):
                # Automatically create a new approval.
                new_approval = Approval(
                    start_at=self.hiring_start_at,
                    end_at=Approval.get_default_end_date(self.hiring_start_at),
                    number=Approval.get_next_number(self.hiring_start_at),
                    user=self.job_seeker,
                )
                new_approval.save()
                self.approval = new_approval
                emails.append(self.email_approval_number(accepted_by))
            elif (self.job_seeker.lack_of_pole_emploi_id_reason ==
                  self.job_seeker.REASON_FORGOTTEN):
                # Trigger a manual approval creation.
                emails.append(
                    self.email_accept_trigger_manual_approval(accepted_by))
            else:
                raise xwf_models.AbortTransition(
                    "Job seeker has an invalid PE status, cannot issue approval."
                )

        # Send emails in batch.
        connection = mail.get_connection()
        connection.send_messages(emails)

        if self.approval:
            self.approval_number_sent_by_email = True
            self.approval_number_sent_at = timezone.now()
            self.approval_delivery_mode = self.APPROVAL_DELIVERY_MODE_AUTOMATIC

    @xwf_models.transition()
    def refuse(self, *args, **kwargs):
        # Send notification.
        connection = mail.get_connection()
        emails = [self.email_refuse]
        connection.send_messages(emails)

    @xwf_models.transition()
    def render_obsolete(self, *args, **kwargs):
        pass

    # Emails.

    def get_siae_recipents_email_list(self):
        return list(
            self.to_siae.members.filter(is_active=True).values_list("email",
                                                                    flat=True))

    def get_email_message(
        self,
        to,
        context,
        subject,
        body,
        from_email=settings.DEFAULT_FROM_EMAIL,
        bcc=None,
    ):
        return mail.EmailMessage(
            from_email=from_email,
            to=to,
            bcc=bcc,
            subject=get_email_text_template(subject, context),
            body=get_email_text_template(body, context),
        )

    @property
    def email_new_for_siae(self):
        to = self.get_siae_recipents_email_list()
        context = {"job_application": self}
        subject = "apply/email/new_for_siae_subject.txt"
        body = "apply/email/new_for_siae_body.txt"
        return self.get_email_message(to, context, subject, body)

    @property
    def email_accept(self):
        to = [self.job_seeker.email]
        bcc = []
        if self.is_sent_by_proxy:
            bcc.append(self.sender.email)
        context = {"job_application": self}
        subject = "apply/email/accept_subject.txt"
        body = "apply/email/accept_body.txt"
        return self.get_email_message(to, context, subject, body, bcc=bcc)

    @property
    def email_refuse(self):
        to = [self.job_seeker.email]
        bcc = []
        if self.is_sent_by_proxy:
            bcc.append(self.sender.email)
        context = {"job_application": self}
        subject = "apply/email/refuse_subject.txt"
        body = "apply/email/refuse_body.txt"
        return self.get_email_message(to, context, subject, body, bcc=bcc)

    def email_accept_trigger_manual_approval(self, accepted_by):
        to = [settings.ITOU_EMAIL_CONTACT]
        context = {
            "job_application":
            self,
            "admin_manually_add_approval_url":
            reverse("admin:approvals_approval_manually_add_approval",
                    args=[self.pk]),
        }
        if accepted_by:
            context["accepted_by"] = accepted_by
        subject = "apply/email/accept_trigger_approval_subject.txt"
        body = "apply/email/accept_trigger_approval_body.txt"
        return self.get_email_message(to, context, subject, body)

    def email_approval_number(self, accepted_by):
        if not accepted_by:
            raise RuntimeError(
                _("Unable to determine the recipient email address."))
        if not self.approval:
            raise RuntimeError(
                _("No approval found for this job application."))
        to = [accepted_by.email]
        context = {"job_application": self}
        subject = "apply/email/approval_number_subject.txt"
        body = "apply/email/approval_number_body.txt"
        return self.get_email_message(to, context, subject, body)

    def send_approval_number_by_email_manually(self, deliverer):
        """
        Manual delivery mode: used when an Itou member has created an approval.
        """
        email = self.email_approval_number(self.accepted_by)
        email.send()
        self.approval_number_sent_by_email = True
        self.approval_number_sent_at = timezone.now()
        self.approval_delivery_mode = self.APPROVAL_DELIVERY_MODE_MANUAL
        self.approval_number_delivered_by = deliverer
        self.save()
Esempio n. 15
0
class Project(xwf_models.WorkflowEnabled, models.Model):
    name = models.CharField(max_length=100)
    state = xwf_models.StateField(ProjectWorkflow)
    author = models.OneToOneField(  # step:2,3,4,5
        Author, null=True, on_delete=models.CASCADE)  # step:2,3,4,5
Esempio n. 16
0
class JobApplication(xwf_models.WorkflowEnabled, models.Model):
    """
    An "unsolicited" job application.

    It inherits from `xwf_models.WorkflowEnabled` to add a workflow to its `state` field:
        - https://github.com/rbarrois/django_xworkflows
        - https://github.com/rbarrois/xworkflows
    """

    SENDER_KIND_JOB_SEEKER = KIND_JOB_SEEKER
    SENDER_KIND_PRESCRIBER = KIND_PRESCRIBER
    SENDER_KIND_SIAE_STAFF = KIND_SIAE_STAFF

    SENDER_KIND_CHOICES = (
        (SENDER_KIND_JOB_SEEKER, _("Demandeur d'emploi")),
        (SENDER_KIND_PRESCRIBER, _("Prescripteur")),
        (SENDER_KIND_SIAE_STAFF, _("Employeur (SIAE)")),
    )

    REFUSAL_REASON_DID_NOT_COME = "did_not_come"
    REFUSAL_REASON_UNAVAILABLE = "unavailable"
    REFUSAL_REASON_NON_ELIGIBLE = "non_eligible"
    REFUSAL_REASON_ELIGIBILITY_DOUBT = "eligibility_doubt"
    REFUSAL_REASON_INCOMPATIBLE = "incompatible"
    REFUSAL_REASON_PREVENT_OBJECTIVES = "prevent_objectives"
    REFUSAL_REASON_NO_POSITION = "no_position"
    REFUSAL_REASON_OTHER = "other"

    REFUSAL_REASON_CHOICES = (
        (REFUSAL_REASON_DID_NOT_COME, _("Candidat non venu ou non joignable")),
        (
            REFUSAL_REASON_UNAVAILABLE,
            _("Candidat indisponible ou non intéressé par le poste"),
        ),
        (REFUSAL_REASON_NON_ELIGIBLE, _("Candidat non éligible")),
        (
            REFUSAL_REASON_ELIGIBILITY_DOUBT,
            _("Doute sur l'éligibilité du candidat (penser à renvoyer la personne vers un prescripteur)"
              ),
        ),
        (
            REFUSAL_REASON_INCOMPATIBLE,
            _("Un des freins à l'emploi du candidat est incompatible avec le poste proposé"
              ),
        ),
        (
            REFUSAL_REASON_PREVENT_OBJECTIVES,
            _("L'embauche du candidat empêche la réalisation des objectifs du dialogue de gestion"
              ),
        ),
        (REFUSAL_REASON_NO_POSITION, _("Pas de poste ouvert en ce moment")),
        (REFUSAL_REASON_OTHER, _("Autre")),
    )

    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

    job_seeker = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name=_("Demandeur d'emploi"),
        on_delete=models.CASCADE,
        related_name="job_applications",
    )

    # Who send the job application. It can be the same user as `job_seeker`
    sender = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name=_("Émetteur"),
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="job_applications_sent",
    )

    sender_kind = models.CharField(
        verbose_name=_("Type de l'émetteur"),
        max_length=10,
        choices=SENDER_KIND_CHOICES,
        default=SENDER_KIND_PRESCRIBER,
    )

    # When the sender is an SIAE staff member, keep a track of his current SIAE.
    sender_siae = models.ForeignKey(
        "siaes.Siae",
        verbose_name=_("SIAE émettrice"),
        null=True,
        blank=True,
        on_delete=models.CASCADE,
    )

    # When the sender is a prescriber, keep a track of his current organization (if any).
    sender_prescriber_organization = models.ForeignKey(
        "prescribers.PrescriberOrganization",
        verbose_name=_("Organisation du prescripteur émettrice"),
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
    )

    to_siae = models.ForeignKey(
        "siaes.Siae",
        verbose_name=_("SIAE destinataire"),
        on_delete=models.CASCADE,
        related_name="job_applications_received",
    )

    state = xwf_models.StateField(JobApplicationWorkflow,
                                  verbose_name=_("État"),
                                  db_index=True)

    # Jobs in which the job seeker is interested (optional).
    selected_jobs = models.ManyToManyField(
        "siaes.SiaeJobDescription",
        verbose_name=_("Métiers recherchés"),
        blank=True)

    message = models.TextField(verbose_name=_("Message de candidature"),
                               blank=True)
    answer = models.TextField(verbose_name=_("Message de réponse"), blank=True)
    refusal_reason = models.CharField(
        verbose_name=_("Motifs de refus"),
        max_length=30,
        choices=REFUSAL_REASON_CHOICES,
        blank=True,
    )

    date_of_hiring = models.DateField(verbose_name=_("Date de l'embauche"),
                                      blank=True,
                                      null=True)

    created_at = models.DateTimeField(verbose_name=_("Date de création"),
                                      default=timezone.now,
                                      db_index=True)
    updated_at = models.DateTimeField(verbose_name=_("Date de modification"),
                                      blank=True,
                                      null=True,
                                      db_index=True)

    objects = models.Manager.from_queryset(JobApplicationQuerySet)()

    class Meta:
        verbose_name = _("Candidature")
        verbose_name_plural = _("Candidatures")
        ordering = ["-created_at"]

    def __str__(self):
        return str(self.id)

    def save(self, *args, **kwargs):
        self.updated_at = timezone.now()
        return super().save(*args, **kwargs)

    @property
    def is_sent_by_proxy(self):
        return self.sender != self.job_seeker

    @property
    def eligibility_diagnosis_by_siae_required(self):
        """
        Returns True if an eligibility diagnosis must be made by an SIAE
        when processing an application, False otherwise.
        """
        return (self.state.is_processing
                and self.to_siae.is_subject_to_eligibility_rules
                and not self.job_seeker.has_eligibility_diagnosis)

    @property
    def accepted_by(self):
        if not self.state.is_accepted:
            return None
        return (self.logs.select_related("user").get(
            to_state=JobApplicationWorkflow.STATE_ACCEPTED).user)

    # Workflow transitions.

    @xwf_models.transition()
    def process(self, *args, **kwargs):
        pass

    @xwf_models.transition()
    def accept(self, *args, **kwargs):
        # Mark other related job applications as obsolete.
        for job_application in self.job_seeker.job_applications.exclude(
                pk=self.pk).pending():
            job_application.render_obsolete(*args, **kwargs)
        # Send notification.
        connection = mail.get_connection()
        accepted_by = kwargs.get("user")
        emails = [self.email_accept]
        if self.to_siae.is_subject_to_eligibility_rules:
            emails.append(
                self.email_accept_trigger_manual_approval(accepted_by))
        connection.send_messages(emails)

    @xwf_models.transition()
    def refuse(self, *args, **kwargs):
        # Send notification.
        connection = mail.get_connection()
        emails = [self.email_refuse]
        connection.send_messages(emails)

    @xwf_models.transition()
    def render_obsolete(self, *args, **kwargs):
        pass

    # Emails.

    def get_siae_recipents_email_list(self):
        return list(
            self.to_siae.members.filter(is_active=True).values_list("email",
                                                                    flat=True))

    def get_email_message(self,
                          to,
                          context,
                          subject,
                          body,
                          from_email=settings.DEFAULT_FROM_EMAIL):
        return mail.EmailMessage(
            from_email=from_email,
            to=to,
            subject=get_email_text_template(subject, context),
            body=get_email_text_template(body, context),
        )

    @property
    def email_new_for_siae(self):
        to = self.get_siae_recipents_email_list()
        context = {"job_application": self}
        subject = "apply/email/new_for_siae_subject.txt"
        body = "apply/email/new_for_siae_body.txt"
        return self.get_email_message(to, context, subject, body)

    @property
    def email_accept(self):
        to = [self.job_seeker.email]
        if self.is_sent_by_proxy:
            to.append(self.sender.email)
        context = {"job_application": self}
        subject = "apply/email/accept_subject.txt"
        body = "apply/email/accept_body.txt"
        return self.get_email_message(to, context, subject, body)

    @property
    def email_refuse(self):
        to = [self.job_seeker.email]
        if self.is_sent_by_proxy:
            to.append(self.sender.email)
        context = {"job_application": self}
        subject = "apply/email/refuse_subject.txt"
        body = "apply/email/refuse_body.txt"
        return self.get_email_message(to, context, subject, body)

    def email_accept_trigger_manual_approval(self, accepted_by):
        to = [settings.ITOU_EMAIL_CONTACT]
        context = {
            "job_application":
            self,
            # Used to prepopulate the admin form.
            "approvals_admin_query_string":
            urlencode({
                "user": self.job_seeker.pk,
                "start_at": self.date_of_hiring.strftime("%d/%m/%Y"),
                "job_application": self.pk,
            }),
        }
        if accepted_by:
            context["accepted_by"] = accepted_by
        subject = "apply/email/accept_trigger_approval_subject.txt"
        body = "apply/email/accept_trigger_approval_body.txt"
        return self.get_email_message(to, context, subject, body)
Esempio n. 17
0
class SubProject(xwf_models.WorkflowEnabled, models.Model):  # step:3,4,5
    name = models.CharField(max_length=100)  # step:3,4,5
    project = models.ForeignKey(Project,
                                on_delete=models.CASCADE)  # step:3,4,5
    state = xwf_models.StateField(ProjectWorkflow)  # step:4,5
Esempio n. 18
0
class GenericWorkflowEnabled(dxmodels.WorkflowEnabled, models.Model):
    state = dxmodels.StateField(GenericWorkflow)
Esempio n. 19
0
class MetaProject(xwf_models.WorkflowEnabled, models.Model):  # step:5
    name = models.CharField(max_length=100)  # step:5
    state = xwf_models.StateField(ProjectWorkflow)  # step:5
Esempio n. 20
0
class WithTwoWorkflows(dxmodels.WorkflowEnabled, models.Model):
    state1 = dxmodels.StateField(MyWorkflow())
    state2 = dxmodels.StateField(MyAltWorkflow())
Esempio n. 21
0
class CampaignRun(xwf_models.WorkflowEnabled, models.Model):
    runAt = models.DateTimeField()
    campaign = models.ForeignKey(Campaign)
    status = xwf_models.StateField(CampaignWorkflow)
Esempio n. 22
0
 class BaseWorkflowEnabled(xwf_models.WorkflowEnabled,
                           django_models.Model):
     state = xwf_models.StateField(models.MyWorkflow)
Esempio n. 23
0
             "Can update medical information about a case"),
        ]

    def __str__(self):
        return f'Case {self.id}: {self.title}'


class PartnerWorkflow(xwf_models.Workflow):
    log_model = ''
    states = (('matched', _(u"Matched")), ('accepted', _(u"Accepted")),
              ('assigned', _(u"Assigned")), ('rejected', _(u"Rejected")))
    transitions = (('downgrade', 'accepted', 'matched'),
                   ('accept', ('matched', 'assigned'),
                    'accepted'), ('assign', 'accepted', 'assigned'),
                   ('reject', 'matched', 'rejected'), ('unreject', 'rejected',
                                                       'matched'))
    initial_state = 'matched'


class Partnership(xwf_models.WorkflowEnabled, models.Model):
    case = models.ForeignKey(to=Case,
                             on_delete=models.CASCADE,
                             related_name='partnered_organisations')
    organisation = models.ForeignKey(to=Organisation,
                                     on_delete=models.CASCADE,
                                     related_name='partnered_cases')
    status = xwf_models.StateField(PartnerWorkflow)

    class Meta:
        unique_together = ('case', 'organisation')
Esempio n. 24
0
class Project(xwf_models.WorkflowEnabled, models.Model):
    name = models.CharField(max_length=100)
    state = xwf_models.StateField(ProjectWorkflow)
Esempio n. 25
0
class JobApplication(xwf_models.WorkflowEnabled, models.Model):
    """
    A job application.

    It inherits from `xwf_models.WorkflowEnabled` to add a workflow to its `state` field:
        - https://github.com/rbarrois/django_xworkflows
        - https://github.com/rbarrois/xworkflows
    """

    SENDER_KIND_JOB_SEEKER = KIND_JOB_SEEKER
    SENDER_KIND_PRESCRIBER = KIND_PRESCRIBER
    SENDER_KIND_SIAE_STAFF = KIND_SIAE_STAFF

    SENDER_KIND_CHOICES = (
        (SENDER_KIND_JOB_SEEKER, "Demandeur d'emploi"),
        (SENDER_KIND_PRESCRIBER, "Prescripteur"),
        (SENDER_KIND_SIAE_STAFF, "Employeur (SIAE)"),
    )

    REFUSAL_REASON_DID_NOT_COME = "did_not_come"
    REFUSAL_REASON_UNAVAILABLE = "unavailable"
    REFUSAL_REASON_NON_ELIGIBLE = "non_eligible"
    REFUSAL_REASON_ELIGIBILITY_DOUBT = "eligibility_doubt"
    REFUSAL_REASON_INCOMPATIBLE = "incompatible"
    REFUSAL_REASON_PREVENT_OBJECTIVES = "prevent_objectives"
    REFUSAL_REASON_NO_POSITION = "no_position"
    REFUSAL_REASON_APPROVAL_EXPIRATION_TOO_CLOSE = "approval_expiration_too_close"
    REFUSAL_REASON_DEACTIVATION = "deactivation"
    REFUSAL_REASON_NOT_MOBILE = "not_mobile"
    REFUSAL_REASON_POORLY_INFORMED = "poorly_informed"
    REFUSAL_REASON_OTHER = "other"
    REFUSAL_REASON_CHOICES = (
        (REFUSAL_REASON_DID_NOT_COME, "Candidat non venu ou non joignable"),
        (REFUSAL_REASON_UNAVAILABLE,
         "Candidat indisponible ou non intéressé par le poste"),
        (REFUSAL_REASON_NON_ELIGIBLE, "Candidat non éligible"),
        (REFUSAL_REASON_NOT_MOBILE, "Candidat non mobile"),
        (
            REFUSAL_REASON_ELIGIBILITY_DOUBT,
            "Doute sur l'éligibilité du candidat (penser à renvoyer la personne vers un prescripteur)",
        ),
        (
            REFUSAL_REASON_INCOMPATIBLE,
            "Un des freins à l'emploi du candidat est incompatible avec le poste proposé",
        ),
        (
            REFUSAL_REASON_PREVENT_OBJECTIVES,
            "L'embauche du candidat empêche la réalisation des objectifs du dialogue de gestion",
        ),
        (REFUSAL_REASON_NO_POSITION, "Pas de poste ouvert en ce moment"),
        (REFUSAL_REASON_APPROVAL_EXPIRATION_TOO_CLOSE,
         "La date de fin du PASS IAE / agrément est trop proche"),
        (REFUSAL_REASON_DEACTIVATION, "La structure n'est plus conventionnée"),
        (REFUSAL_REASON_POORLY_INFORMED, "Candidature pas assez renseignée"),
        (REFUSAL_REASON_OTHER, "Autre"),
    )

    # SIAE have the possibility to update the hiring date if:
    # - it is before the end date of an approval created for this job application
    # - it is in the future, max. MAX_CONTRACT_POSTPONE_IN_DAYS days from today.
    MAX_CONTRACT_POSTPONE_IN_DAYS = 30

    ERROR_START_IN_PAST = "Il n'est pas possible d'antidater un contrat. Indiquez une date dans le futur."
    ERROR_END_IS_BEFORE_START = "La date de fin du contrat doit être postérieure à la date de début."
    ERROR_START_AFTER_APPROVAL_END = (
        "Attention, le PASS IAE sera expiré lors du début du contrat. Veuillez modifier la date de début."
    )
    ERROR_POSTPONE_TOO_FAR = (
        f"La date de début du contrat ne peut être repoussée de plus de {MAX_CONTRACT_POSTPONE_IN_DAYS} jours."
    )

    APPROVAL_DELIVERY_MODE_AUTOMATIC = "automatic"
    APPROVAL_DELIVERY_MODE_MANUAL = "manual"

    APPROVAL_DELIVERY_MODE_CHOICES = (
        (APPROVAL_DELIVERY_MODE_AUTOMATIC, "Automatique"),
        (APPROVAL_DELIVERY_MODE_MANUAL, "Manuel"),
    )

    WEEKS_BEFORE_CONSIDERED_OLD = 3

    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

    job_seeker = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name="Demandeur d'emploi",
        on_delete=models.CASCADE,
        related_name="job_applications",
    )

    # The job seeker's eligibility diagnosis used for this job application
    # (required for SIAEs subject to eligibility rules).
    # It is already linked to the job seeker but this double link is added
    # to easily find out which one was used for a given job application.
    # Use `self.get_eligibility_diagnosis()` to handle business rules.
    eligibility_diagnosis = models.ForeignKey(
        "eligibility.EligibilityDiagnosis",
        verbose_name="Diagnostic d'éligibilité",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
    )

    # Exclude flagged approvals (batch creation or import of approvals).
    # See itou.users.management.commands.import_ai_employees.
    create_employee_record = models.BooleanField(
        default=True, verbose_name="Création d'une fiche salarié")

    # The job seeker's resume used for this job application.
    resume_link = models.URLField(max_length=500,
                                  verbose_name="Lien vers un CV",
                                  blank=True)

    # Who send the job application. It can be the same user as `job_seeker`
    sender = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name="Émetteur",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="job_applications_sent",
    )

    sender_kind = models.CharField(
        verbose_name="Type de l'émetteur",
        max_length=10,
        choices=SENDER_KIND_CHOICES,
        default=SENDER_KIND_PRESCRIBER,
    )

    # When the sender is an SIAE staff member, keep a track of his current SIAE.
    sender_siae = models.ForeignKey("siaes.Siae",
                                    verbose_name="SIAE émettrice",
                                    null=True,
                                    blank=True,
                                    on_delete=models.CASCADE)

    # When the sender is a prescriber, keep a track of his current organization (if any).
    sender_prescriber_organization = models.ForeignKey(
        "prescribers.PrescriberOrganization",
        verbose_name="Organisation du prescripteur émettrice",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
    )

    to_siae = models.ForeignKey(
        "siaes.Siae",
        verbose_name="SIAE destinataire",
        on_delete=models.CASCADE,
        related_name="job_applications_received",
    )

    state = xwf_models.StateField(JobApplicationWorkflow,
                                  verbose_name="État",
                                  db_index=True)

    # Jobs in which the job seeker is interested (optional).
    selected_jobs = models.ManyToManyField("siaes.SiaeJobDescription",
                                           verbose_name="Métiers recherchés",
                                           blank=True)

    message = models.TextField(verbose_name="Message de candidature",
                               blank=True)
    answer = models.TextField(verbose_name="Message de réponse", blank=True)
    answer_to_prescriber = models.TextField(
        verbose_name="Message de réponse au prescripeur", blank=True)
    refusal_reason = models.CharField(verbose_name="Motifs de refus",
                                      max_length=30,
                                      choices=REFUSAL_REASON_CHOICES,
                                      blank=True)

    hiring_start_at = models.DateField(verbose_name="Date de début du contrat",
                                       blank=True,
                                       null=True,
                                       db_index=True)
    hiring_end_at = models.DateField(
        verbose_name="Date prévisionnelle de fin du contrat",
        blank=True,
        null=True)

    hiring_without_approval = models.BooleanField(
        default=False,
        verbose_name=
        "L'entreprise choisit de ne pas obtenir un PASS IAE à l'embauche")

    # This flag is used in the `PoleEmploiApproval`'s conversion process.
    # This process is required following the end of the software allowing Pôle emploi to manage their approvals.
    # The process allows to convert a `PoleEmploiApproval` into an `Approval`.
    created_from_pe_approval = models.BooleanField(
        default=False,
        verbose_name=
        "Candidature créée lors de l'import d'un agrément Pole Emploi")

    # Job applications sent to SIAEs subject to eligibility rules can obtain an
    # Approval after being accepted.
    approval = models.ForeignKey("approvals.Approval",
                                 verbose_name="PASS IAE",
                                 null=True,
                                 blank=True,
                                 on_delete=models.SET_NULL)
    approval_delivery_mode = models.CharField(
        verbose_name="Mode d'attribution du PASS IAE",
        max_length=30,
        choices=APPROVAL_DELIVERY_MODE_CHOICES,
        blank=True,
    )
    # Fields used for approvals processed both manually or automatically.
    approval_number_sent_by_email = models.BooleanField(
        verbose_name="PASS IAE envoyé par email", default=False)
    approval_number_sent_at = models.DateTimeField(
        verbose_name="Date d'envoi du PASS IAE",
        blank=True,
        null=True,
        db_index=True)
    # Fields used only for manual processing.
    approval_manually_delivered_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name="PASS IAE délivré manuellement par",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="approval_manually_delivered",
    )
    approval_manually_refused_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name="PASS IAE refusé manuellement par",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="approval_manually_refused",
    )
    approval_manually_refused_at = models.DateTimeField(
        verbose_name="Date de refus manuel du PASS IAE", blank=True, null=True)

    hidden_for_siae = models.BooleanField(default=False,
                                          verbose_name="Masqué coté employeur")

    created_at = models.DateTimeField(verbose_name="Date de création",
                                      default=timezone.now,
                                      db_index=True)
    updated_at = models.DateTimeField(verbose_name="Date de modification",
                                      blank=True,
                                      null=True,
                                      db_index=True)

    objects = models.Manager.from_queryset(JobApplicationQuerySet)()

    class Meta:
        verbose_name = "Candidature"
        verbose_name_plural = "Candidatures"
        ordering = ["-created_at"]

    def __str__(self):
        return str(self.id)

    def save(self, *args, **kwargs):
        self.updated_at = timezone.now()
        return super().save(*args, **kwargs)

    @property
    def is_pending(self):
        return self.state in JobApplicationWorkflow.PENDING_STATES

    @property
    def is_sent_by_proxy(self):
        return self.sender != self.job_seeker

    @property
    def is_sent_by_authorized_prescriber(self):
        return bool(self.sender_kind == self.SENDER_KIND_PRESCRIBER
                    and self.sender_prescriber_organization
                    and self.sender_prescriber_organization.is_authorized)

    @property
    def is_spontaneous(self):
        return not self.selected_jobs.exists()

    @property
    def eligibility_diagnosis_by_siae_required(self):
        """
        Returns True if an eligibility diagnosis must be made by an SIAE
        when processing an application, False otherwise.
        """
        return ((self.state in JobApplicationWorkflow.CAN_BE_ACCEPTED_STATES)
                and self.to_siae.is_subject_to_eligibility_rules and
                not self.job_seeker.has_valid_diagnosis(for_siae=self.to_siae))

    @property
    def manual_approval_delivery_required(self):
        """
        Returns True if the current instance require a manual PASS IAE delivery, False otherwise.
        """
        return (self.state.is_accepted and self.approval_delivery_mode
                == self.APPROVAL_DELIVERY_MODE_MANUAL
                and not self.approval_number_sent_by_email
                and self.approval_manually_refused_at is None)

    @property
    def accepted_by(self):
        if not self.state.is_accepted:
            return None
        return self.logs.select_related("user").filter(
            to_state=JobApplicationWorkflow.STATE_ACCEPTED).last().user

    @property
    def can_download_approval_as_pdf(self):
        return self.state.is_accepted and self.to_siae.is_subject_to_eligibility_rules and self.approval

    @property
    def can_be_cancelled(self):
        if self.is_from_ai_stock:
            return False
        if self.hiring_start_at:
            # A job application can be canceled provided that
            # there is no employee record linked with a status:
            # - SENT
            # - ACCEPTED
            # (likely to be accepted or already accepted by ASP)
            employee_record = self.employee_record.first()
            blocked = employee_record and employee_record.is_blocking_job_application_cancellation
            return not blocked
        return False

    @property
    def can_be_archived(self):
        return self.state in [
            JobApplicationWorkflow.STATE_REFUSED,
            JobApplicationWorkflow.STATE_CANCELLED,
            JobApplicationWorkflow.STATE_OBSOLETE,
        ]

    @property
    def is_refused_due_to_deactivation(self):
        return (self.state == JobApplicationWorkflow.STATE_REFUSED
                and self.refusal_reason == self.REFUSAL_REASON_DEACTIVATION)

    @property
    def is_from_ai_stock(self):
        """On November 30th, 2021, we created job applications to deliver approvals.
        See itou.users.management.commands.import_ai_employees.
        """
        # Avoid a circular import.
        user_manager = self.job_seeker._meta.model.objects
        developer_qs = user_manager.filter(
            email=settings.AI_EMPLOYEES_STOCK_DEVELOPER_EMAIL)
        if not developer_qs:
            return False
        developer = developer_qs.first()
        return (self.approval_manually_delivered_by == developer
                and self.created_at.date()
                == settings.AI_EMPLOYEES_STOCK_IMPORT_DATE.date())

    @property
    def has_editable_job_seeker(self):
        return (self.state.is_new or self.state.is_processing or
                self.state.is_accepted) and self.job_seeker.is_handled_by_proxy

    @property
    def hiring_starts_in_future(self):
        if self.hiring_start_at:
            return datetime.date.today() < self.hiring_start_at
        return False

    @property
    def can_update_hiring_start(self):
        return self.hiring_starts_in_future and self.state in [
            JobApplicationWorkflow.STATE_ACCEPTED,
            JobApplicationWorkflow.STATE_POSTPONED,
        ]

    @property
    def display_sender_kind(self):
        """
        Converts itou internal prescriber kinds into something readable
        """
        kind = "Candidature spontanée"
        if self.sender_kind == JobApplication.SENDER_KIND_SIAE_STAFF:
            kind = "Auto-prescription"
        elif self.sender_kind == JobApplication.SENDER_KIND_PRESCRIBER:
            kind = "Orienteur"
            if self.is_sent_by_authorized_prescriber:
                kind = "Prescripteur habilité"
        return kind

    def get_eligibility_diagnosis(self):
        """
        Returns the eligibility diagnosis linked to this job application or None.
        """
        if not self.to_siae.is_subject_to_eligibility_rules:
            return None
        if self.eligibility_diagnosis:
            return self.eligibility_diagnosis
        # As long as the job application has not been accepted, diagnosis-related
        # business rules may still prioritize one diagnosis over another.
        return EligibilityDiagnosis.objects.last_considered_valid(
            self.job_seeker, for_siae=self.to_siae)

    def get_resume_link(self):
        if self.resume_link:
            return self.resume_link
        elif self.job_seeker.resume_link:
            return self.job_seeker.resume_link
        return None

    # Workflow transitions.

    @xwf_models.transition()
    def process(self, *args, **kwargs):
        pass

    @xwf_models.transition()
    def accept(self, *args, **kwargs):
        accepted_by = kwargs.get("user")

        # Mark other related job applications as obsolete.
        for job_application in self.job_seeker.job_applications.exclude(
                pk=self.pk).pending():
            job_application.render_obsolete(*args, **kwargs)

        # Notification emails.
        emails = [self.email_accept_for_job_seeker]
        if self.is_sent_by_proxy:
            emails.append(self.email_accept_for_proxy)

        # Approval issuance logic.
        if not self.hiring_without_approval and self.to_siae.is_subject_to_eligibility_rules:

            approvals_wrapper = self.job_seeker.approvals_wrapper

            if approvals_wrapper.has_in_waiting_period:
                if approvals_wrapper.cannot_bypass_waiting_period(
                        siae=self.to_siae,
                        sender_prescriber_organization=self.
                        sender_prescriber_organization):
                    # Security check: it's supposed to be blocked upstream.
                    raise xwf_models.AbortTransition(
                        "Job seeker has an approval in waiting period.")

            if approvals_wrapper.has_valid:
                # Automatically reuse an existing valid Itou or PE approval.
                self.approval = Approval.get_or_create_from_valid(
                    approvals_wrapper)
                if self.approval.start_at > self.hiring_start_at:
                    # As a job seeker can have multiple contracts at the same time,
                    # the approval should start at the same time as most recent contract.
                    self.approval.update_start_date(
                        new_start_date=self.hiring_start_at)
                emails.append(self.email_deliver_approval(accepted_by))
            elif (self.job_seeker.pole_emploi_id
                  or self.job_seeker.lack_of_pole_emploi_id_reason
                  == self.job_seeker.REASON_NOT_REGISTERED):
                # Automatically create a new approval.
                new_approval = Approval(
                    start_at=self.hiring_start_at,
                    end_at=Approval.get_default_end_date(self.hiring_start_at),
                    user=self.job_seeker,
                )
                new_approval.save()
                self.approval = new_approval
                emails.append(self.email_deliver_approval(accepted_by))
            elif self.job_seeker.lack_of_pole_emploi_id_reason == self.job_seeker.REASON_FORGOTTEN:
                # Trigger a manual approval creation.
                self.approval_delivery_mode = self.APPROVAL_DELIVERY_MODE_MANUAL
                emails.append(
                    self.email_manual_approval_delivery_required_notification(
                        accepted_by))
            else:
                raise xwf_models.AbortTransition(
                    "Job seeker has an invalid PE status, cannot issue approval."
                )

        # Link to the job seeker's eligibility diagnosis.
        if self.to_siae.is_subject_to_eligibility_rules:
            self.eligibility_diagnosis = EligibilityDiagnosis.objects.last_considered_valid(
                self.job_seeker, for_siae=self.to_siae)
            if not self.eligibility_diagnosis and self.approval and self.approval.originates_from_itou:
                logger.error(
                    "An eligibility diagnosis should've been found for job application %s",
                    self.pk)

        # Send emails in batch.
        connection = mail.get_connection()
        connection.send_messages(emails)

        if self.approval:
            self.approval_number_sent_by_email = True
            self.approval_number_sent_at = timezone.now()
            self.approval_delivery_mode = self.APPROVAL_DELIVERY_MODE_AUTOMATIC
            self.approval.unsuspend(self.hiring_start_at)
            self.notify_pole_emploi_accepted()

    @xwf_models.transition()
    def refuse(self, *args, **kwargs):
        # Send notification.
        connection = mail.get_connection()
        emails = [self.email_refuse_for_job_seeker]
        if self.is_sent_by_proxy:
            emails.append(self.email_refuse_for_proxy)
        connection.send_messages(emails)

    @xwf_models.transition()
    def cancel(self, *args, **kwargs):
        if not self.can_be_cancelled:
            raise xwf_models.AbortTransition(
                "Cette candidature n'a pu être annulée.")

        if self.approval and self.approval.can_be_deleted:
            self.approval.delete()
            self.approval = None

            # Remove flags on the job application about approval
            self.approval_number_sent_by_email = False
            self.approval_number_sent_at = None
            self.approval_delivery_mode = ""
            self.approval_manually_delivered_by = None

        # Delete matching employee record, if any
        employee_record = self.employee_record.first()
        if employee_record:
            employee_record.delete()

        # Send notification.
        user = kwargs.get("user")
        connection = mail.get_connection()
        emails = [self.email_cancel(cancelled_by=user)]
        connection.send_messages(emails)

    @xwf_models.transition()
    def render_obsolete(self, *args, **kwargs):
        pass

    # Emails.
    @property
    def email_new_for_prescriber(self):
        to = [self.sender.email]
        context = {"job_application": self}
        subject = "apply/email/new_for_prescriber_subject.txt"
        body = "apply/email/new_for_prescriber_body.txt"
        return get_email_message(to, context, subject, body)

    def email_new_for_job_seeker(self, base_url):
        to = [self.job_seeker.email]
        context = {"job_application": self, "base_url": base_url}
        subject = "apply/email/new_for_job_seeker_subject.txt"
        body = "apply/email/new_for_job_seeker_body.txt"
        return get_email_message(to, context, subject, body)

    @property
    def email_accept_for_job_seeker(self):
        to = [self.job_seeker.email]
        context = {"job_application": self}
        subject = "apply/email/accept_for_job_seeker_subject.txt"
        body = "apply/email/accept_for_job_seeker_body.txt"
        return get_email_message(to, context, subject, body)

    @property
    def email_accept_for_proxy(self):
        if not self.is_sent_by_proxy:
            raise RuntimeError("The job application was not sent by a proxy.")
        to = [self.sender.email]
        context = {"job_application": self}
        if self.sender_prescriber_organization:
            # Include the survey link for all prescribers's organizations.
            context[
                "prescriber_survey_link"] = self.sender_prescriber_organization.accept_survey_url
        subject = "apply/email/accept_for_proxy_subject.txt"
        body = "apply/email/accept_for_proxy_body.txt"
        return get_email_message(to, context, subject, body)

    @property
    def email_refuse_for_proxy(self):
        to = [self.sender.email]
        context = {"job_application": self}
        subject = "apply/email/refuse_subject.txt"
        body = "apply/email/refuse_body_for_proxy.txt"
        return get_email_message(to, context, subject, body)

    @property
    def email_refuse_for_job_seeker(self):
        to = [self.job_seeker.email]
        context = {"job_application": self}
        subject = "apply/email/refuse_subject.txt"
        body = "apply/email/refuse_body_for_job_seeker.txt"
        return get_email_message(to, context, subject, body)

    def email_cancel(self, cancelled_by):
        to = [cancelled_by.email]
        bcc = []
        if self.is_sent_by_proxy:
            bcc.append(self.sender.email)
        context = {"job_application": self}
        subject = "apply/email/cancel_subject.txt"
        body = "apply/email/cancel_body.txt"
        return get_email_message(to, context, subject, body, bcc=bcc)

    def email_deliver_approval(self, accepted_by):
        if not accepted_by:
            raise RuntimeError(
                "Unable to determine the recipient email address.")
        if not self.approval:
            raise RuntimeError("No approval found for this job application.")
        to = [accepted_by.email]
        context = {
            "job_application": self,
            "siae_survey_link": self.to_siae.accept_survey_url
        }
        subject = "approvals/email/deliver_subject.txt"
        body = "approvals/email/deliver_body.txt"
        return get_email_message(to, context, subject, body)

    def email_manual_approval_delivery_required_notification(
            self, accepted_by):
        to = [settings.ITOU_EMAIL_CONTACT]
        context = {
            "job_application":
            self,
            "admin_manually_add_approval_url":
            reverse("admin:approvals_approval_manually_add_approval",
                    args=[self.pk]),
        }
        if accepted_by:
            context["accepted_by"] = accepted_by
        subject = "approvals/email/manual_delivery_required_notification_subject.txt"
        body = "approvals/email/manual_delivery_required_notification_body.txt"
        return get_email_message(to, context, subject, body)

    @property
    def email_manually_refuse_approval(self):
        if not self.accepted_by:
            raise RuntimeError(
                "Unable to determine the recipient email address.")
        to = [self.accepted_by.email]
        context = {"job_application": self}
        subject = "approvals/email/refuse_manually_subject.txt"
        body = "approvals/email/refuse_manually_body.txt"
        return get_email_message(to, context, subject, body)

    def manually_deliver_approval(self, delivered_by):
        """
        Manually deliver an approval.
        """
        self.approval_number_sent_by_email = True
        self.approval_number_sent_at = timezone.now()
        self.approval_manually_delivered_by = delivered_by
        self.save()
        # Send email at the end because we can't rollback this operation
        email = self.email_deliver_approval(self.accepted_by)
        email.send()

    def manually_refuse_approval(self, refused_by):
        """
        Manually refuse an approval.
        """
        self.approval_manually_refused_by = refused_by
        self.approval_manually_refused_at = timezone.now()
        self.save()
        # Send email at the end because we can't rollback this operation
        email = self.email_manually_refuse_approval
        email.send()

    def notify_pole_emploi_accepted(self) -> bool:
        if settings.API_ESD_SHOULD_PERFORM_MISE_A_JOUR_PASS:
            return huey_notify_pole_employ(self, POLE_EMPLOI_PASS_APPROVED)
        return False

    def _notify_pole_employ(self, mode: str) -> bool:
        """
        The entire logic for notifying Pole Emploi when a job_application is accepted:
            - first, we authenticate to pole-emploi.io with the proper credentials, scopes, environment and
            dry-run/wet run settings
            - then, we search for the job_seeker on their backend. They reply with an encrypted NIR.
            - finally, we use the encrypted NIR to notify them that a job application was accepted or refused.
            We provide what we have about this job application.

        This is VERY error prone and can break in a lot of places. PE’s servers can be down, we may not find
        the job_seeker, the update may fail for various reasons. The rate limiting is low, hence…
        those terrible `sleep` for lack of a better idea for now.

        In order to ensure the rest of the application process will behave properly no matter what happens here:
         - there is a lot of broad exception catching
         - we keep logs of the successful/failed attempts
         - when anything break, we quit early
        """
        # We do not send approvals that start in the future to PE, because the information system in front
        # can’t handle them. I’ll keep my opinion about this for talks that involve an unreasonnable amount of beer.
        # Another mechanism will be in charge of sending them on their start date
        if self.approval.start_at > timezone.now().date():
            return False
        individual = PoleEmploiIndividu.from_job_seeker(self.job_seeker)
        if individual is None or not individual.is_valid():
            # We may not have a valid user (missing NIR, for instance),
            # in which case we can bypass this process entirely
            return False
        log = JobApplicationPoleEmploiNotificationLog(
            job_application=self,
            status=JobApplicationPoleEmploiNotificationLog.STATUS_OK)
        # Step 1: we get the API token
        try:
            token = JobApplicationPoleEmploiNotificationLog.get_token()
            sleep(1)
        except Exception as e:
            log.status = JobApplicationPoleEmploiNotificationLog.STATUS_FAIL_AUTHENTICATION
            log.details = str(e)
            log.save()
            return False
        # Step 2 : we fetch the encrypted NIR
        try:
            encrypted_nir = JobApplicationPoleEmploiNotificationLog.get_encrypted_nir_from_individual(
                individual, token)
            # 3 requests/second max. I had timeout issues so 1 second takes some margins
            sleep(1)
        except PoleEmploiMiseAJourPassIAEException as e:
            log = JobApplicationPoleEmploiNotificationLog(
                job_application=self,
                status=JobApplicationPoleEmploiNotificationLog.
                STATUS_FAIL_SEARCH_INDIVIDUAL,
                details=f"{e.http_code} {e.response_code}",
            )
            log.save()
            return False
        # Step 3: we finally notify Pole Emploi that something happened for this user
        try:
            mise_a_jour_pass_iae(self, mode, encrypted_nir, token)
            sleep(1)
        except PoleEmploiMiseAJourPassIAEException as e:
            log.status = JobApplicationPoleEmploiNotificationLog.STATUS_FAIL_NOTIFY_POLE_EMPLOI
            log.details = f"{e.http_code} {e.response_code}"
            log.save()
            return False

        log.save()
        return True