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'
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()
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
class AbstractWorkflowEnabled(xwf_models.WorkflowEnabled, django_models.Model): state = xwf_models.StateField(models.MyWorkflow) class Meta: abstract = True
class SomeWorkflowEnabled(dxmodels.WorkflowEnabled, models.Model): state = dxmodels.StateField(SomeWorkflow)
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()
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
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)
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
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()
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
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)
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
class GenericWorkflowEnabled(dxmodels.WorkflowEnabled, models.Model): state = dxmodels.StateField(GenericWorkflow)
class MetaProject(xwf_models.WorkflowEnabled, models.Model): # step:5 name = models.CharField(max_length=100) # step:5 state = xwf_models.StateField(ProjectWorkflow) # step:5
class WithTwoWorkflows(dxmodels.WorkflowEnabled, models.Model): state1 = dxmodels.StateField(MyWorkflow()) state2 = dxmodels.StateField(MyAltWorkflow())
class CampaignRun(xwf_models.WorkflowEnabled, models.Model): runAt = models.DateTimeField() campaign = models.ForeignKey(Campaign) status = xwf_models.StateField(CampaignWorkflow)
class BaseWorkflowEnabled(xwf_models.WorkflowEnabled, django_models.Model): state = xwf_models.StateField(models.MyWorkflow)
"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')
class Project(xwf_models.WorkflowEnabled, models.Model): name = models.CharField(max_length=100) state = xwf_models.StateField(ProjectWorkflow)
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