Ejemplo n.º 1
0
class InforequestEmail(FormatMixin, models.Model):
    # May NOT be NULL; m2m ends; Indexes are prefixes of [inforequest, email] and
    # [email, inforequest] indexes, respectively
    inforequest = models.ForeignKey(u'Inforequest', db_index=False)
    email = models.ForeignKey(u'mail.Message', db_index=False)

    # May NOT be NULL
    TYPES = FieldChoices(
            # For outbound messages
            (u'APPLICANT_ACTION', 1, _(u'inforequests:InforequestEmail:type:APPLICANT_ACTION')),
            # For inbound messages
            (u'OBLIGEE_ACTION',   2, _(u'inforequests:InforequestEmail:type:OBLIGEE_ACTION')),
            (u'UNDECIDED',        3, _(u'inforequests:InforequestEmail:type:UNDECIDED')),
            (u'UNRELATED',        4, _(u'inforequests:InforequestEmail:type:UNRELATED')),
            (u'UNKNOWN',          5, _(u'inforequests:InforequestEmail:type:UNKNOWN')),
            )
    type = models.SmallIntegerField(choices=TYPES._choices,
            help_text=squeeze(u"""
                "Applicant Action": the email represents an applicant action;
                "Obligee Action": the email represents an obligee action;
                "Undecided": The email is waiting for applicant decision;
                "Unrelated": Marked as an unrelated email;
                "Unknown": Marked as an email the applicant didn't know how to decide.
                It must be "Applicant Action" for outbound mesages or one of the remaining values
                for inbound messages.
                """
                ))

    # Backward relations added to other models:
    #
    #  -- Inforequest.inforequestemail_set
    #     May be empty
    #
    #  -- Message.inforequestemail_set
    #     May be empty

    # Indexes:
    #  -- email, inforequest: index_together
    #  -- inforequest, email: index_together
    #  -- type, inforequest:  index_together

    objects = InforequestEmailQuerySet.as_manager()

    class Meta:
        index_together = [
                [u'email', u'inforequest'],
                [u'inforequest', u'email'],
                [u'type', u'inforequest'],
                ]

    def __unicode__(self):
        return format(self.pk)
Ejemplo n.º 2
0
class Message(FormatMixin, models.Model):
    # May NOT be NULL
    TYPES = FieldChoices(
        (u'INBOUND', 1, _(u'mail:Message:type:INBOUND')),
        (u'OUTBOUND', 2, _(u'mail:Message:type:OUTBOUND')),
    )
    type = models.SmallIntegerField(choices=TYPES._choices)

    # May NOT be NULL
    created = models.DateTimeField(auto_now_add=True,
                                   help_text=squeeze(u"""
                Date and time the message object was created,
                """))

    # NOT NULL for processed messages; NULL for queued messages
    processed = models.DateTimeField(blank=True,
                                     null=True,
                                     help_text=squeeze(u"""
                Date and time the message was sent or received and processed. Leave blank if you
                want the application to process it.
                """))

    # May be empty
    from_name = models.CharField(blank=True,
                                 max_length=255,
                                 help_text=escape(
                                     squeeze(u"""
                Sender full name. For instance setting name to "John Smith" and e-mail to
                "*****@*****.**" will set the sender address to "John Smith <*****@*****.**>".
                """)))

    # Should NOT be empty
    from_mail = models.EmailField(max_length=255,
                                  help_text=squeeze(u"""
                Sender e-mail address, e.g. "*****@*****.**".
                """))

    # May be empty for inbound messages; Empty for outbound messages
    received_for = models.EmailField(blank=True,
                                     max_length=255,
                                     help_text=squeeze(u"""
                The address we received the massage for. It may, but does not have to be among the
                message recipients, as the address may have heen bcc-ed to. The address is empty
                for all outbound messages. It may also be empty for inbound messages if we don't
                know it, or the used mail transport does not support it.
                """))

    # May be empty
    subject = models.CharField(blank=True, max_length=255)

    # May be empty
    text = models.TextField(blank=True,
                            help_text=squeeze(u"""
                "text/plain" message body alternative.
                """))
    html = models.TextField(blank=True,
                            help_text=squeeze(u"""
                "text/html" message body alternative.
                """))

    # Dict: String->(String|[String]); May be empty
    headers = JSONField(blank=True,
                        default={},
                        help_text=squeeze(u"""
                Dictionary mapping header names to their values, or lists of their values. For
                outbound messages it contains only extra headers added by the sender. For inbound
                messages it contains all message headers.
                """))

    # May be empty; Backward generic relation
    attachment_set = generic.GenericRelation(
        u'attachments.Attachment',
        content_type_field=u'generic_type',
        object_id_field=u'generic_id')

    # Backward relations:
    #
    #  -- recipient_set: by Recipient.message
    #     Should NOT be empty

    # Indexes:
    #  -- processed, id: index_together
    #  -- created, id:   index_together

    objects = MessageQuerySet.as_manager()

    class Meta:
        index_together = [
            [u'processed', u'id'],
            [u'created', u'id'],
        ]

    @property
    def from_formatted(self):
        return formataddr((self.from_name, self.from_mail))

    @from_formatted.setter
    def from_formatted(self, value):
        self.from_name, self.from_mail = parseaddr(value)

    @staticmethod
    def prefetch_attachments(path=None, queryset=None):
        u"""
        Use to prefetch ``Message.attachments``.
        """
        if queryset is None:
            queryset = Attachment.objects.get_queryset()
        queryset = queryset.order_by_pk()
        return Prefetch(join_lookup(path, u'attachment_set'),
                        queryset,
                        to_attr=u'attachments')

    @cached_property
    def attachments(self):
        u"""
        Cached list of all message attachments ordered by ``pk``. May be prefetched with
        ``prefetch_related(Message.prefetch_attachments())`` queryset method.
        """
        return list(self.attachment_set.order_by_pk())

    @staticmethod
    def prefetch_recipients(path=None, queryset=None):
        u"""
        Use to prefetch ``Message.recipients``.
        """
        if queryset is None:
            queryset = Recipient.objects.get_queryset()
        queryset = queryset.order_by_pk()
        return Prefetch(join_lookup(path, u'recipient_set'),
                        queryset,
                        to_attr=u'recipients')

    @cached_property
    def recipients(self):
        u"""
        Cached list of all message recipients ordered by ``pk``. May be prefetched with
        ``prefetch_related(Message.prefetch_recipients())`` queryset method.
        """
        return list(self.recipient_set.order_by_pk())

    @cached_property
    def recipients_to(self):
        u"""
        Cached list of all message "to" recipients ordered by ``pk``. Takes advantage of
        ``Message.recipients`` if it is already fetched.
        """
        if u'recipients' in self.__dict__:
            return list(r for r in self.recipients
                        if r.type == Recipient.TYPES.TO)
        else:
            return list(self.recipient_set.to().order_by_pk())

    @cached_property
    def recipients_cc(self):
        u"""
        Cached list of all message "cc" recipients ordered by ``pk``. Takes advantage of
        ``Message.recipients`` if it is already fetched.
        """
        if u'recipients' in self.__dict__:
            return list(r for r in self.recipients
                        if r.type == Recipient.TYPES.CC)
        else:
            return list(self.recipient_set.cc().order_by_pk())

    @cached_property
    def recipients_bcc(self):
        u"""
        Cached list of all message "bcc" recipients ordered by ``pk``. Takes advantage of
        ``Message.recipients`` if it is already fetched.
        """
        if u'recipients' in self.__dict__:
            return list(r for r in self.recipients
                        if r.type == Recipient.TYPES.BCC)
        else:
            return list(self.recipient_set.bcc().order_by_pk())

    @cached_property
    def to_formatted(self):
        return u', '.join(r.formatted for r in self.recipients_to)

    @cached_property
    def cc_formatted(self):
        return u', '.join(r.formatted for r in self.recipients_cc)

    @cached_property
    def bcc_formatted(self):
        return u', '.join(r.formatted for r in self.recipients_bcc)

    def __unicode__(self):
        return format(self.pk)
Ejemplo n.º 3
0
class Recipient(FormatMixin, models.Model):
    # May NOT be NULL
    message = models.ForeignKey(u'Message')

    # May be empty
    name = models.CharField(blank=True,
                            max_length=255,
                            help_text=escape(
                                squeeze(u"""
                Recipient full name. For instance setting name to "John Smith" and e-mail to
                "*****@*****.**" will send the message to "John Smith <*****@*****.**>".
                """)))

    # Should NOT be empty
    mail = models.EmailField(
        max_length=255,
        help_text=u'Recipient e-mail address, e.g. "*****@*****.**".')

    # May NOT be NULL
    TYPES = FieldChoices(
        (u'TO', 1, _(u'mail:Recipient:type:TO')),
        (u'CC', 2, _(u'mail:Recipient:type:CC')),
        (u'BCC', 3, _(u'mail:Recipient:type:BCC')),
    )
    type = models.SmallIntegerField(
        choices=TYPES._choices, help_text=u'Recipient type: To, Cc, or Bcc.')

    # May NOT be NULL
    STATUSES = FieldChoices(
        # For inbound messages
        (u'INBOUND', 8, _(u'mail:Recipient:status:INBOUND')),
        # For outbound messages
        (u'UNDEFINED', 1, _(u'mail:Recipient:status:UNDEFINED')),
        (u'QUEUED', 2, _(u'mail:Recipient:status:QUEUED')),
        (u'REJECTED', 3, _(u'mail:Recipient:status:REJECTED')),
        (u'INVALID', 4, _(u'mail:Recipient:status:INVALID')),
        (u'SENT', 5, _(u'mail:Recipient:status:SENT')),
        (u'DELIVERED', 6, _(u'mail:Recipient:status:DELIVERED')),
        (u'OPENED', 7, _(u'mail:Recipient:status:OPENED')),
    )
    INBOUND_STATUSES = (STATUSES.INBOUND, )
    OUTBOUND_STATUSES = (
        STATUSES.UNDEFINED,
        STATUSES.QUEUED,
        STATUSES.REJECTED,
        STATUSES.INVALID,
        STATUSES.SENT,
        STATUSES.DELIVERED,
        STATUSES.OPENED,
    )
    status = models.SmallIntegerField(choices=STATUSES._choices,
                                      help_text=squeeze(u"""
                Delivery status for the message recipient. It must be "Inbound" for inbound mesages
                or one of the remaining statuses for outbound messages.
                """))

    # May be empty
    status_details = models.CharField(blank=True,
                                      max_length=255,
                                      help_text=squeeze(u"""
                Unspecific delivery status details set by e-mail transport. Leave blank if not
                sure.
                """))

    # May be empty
    remote_id = models.CharField(blank=True,
                                 max_length=255,
                                 db_index=True,
                                 help_text=squeeze(u"""
                Recipient reference ID set by e-mail transport. Leave blank if not sure.
                """))

    # Backward relations added to other models:
    #
    #  -- Message.recipient_set
    #     Should NOT be empty

    # Indexes:
    #  -- message:   ForeignKey
    #  -- remote_id: on field

    objects = RecipientQuerySet.as_manager()

    @property
    def formatted(self):
        return formataddr((self.name, self.mail))

    @formatted.setter
    def formatted(self, value):
        self.name, self.mail = parseaddr(value)

    def __unicode__(self):
        return u'[{}] {}'.format(self.pk, self.mail)
Ejemplo n.º 4
0
class Obligee(models.Model):
    # Should NOT be empty; For index see index_together
    name = models.CharField(max_length=255)
    street = models.CharField(max_length=255)
    city = models.CharField(max_length=255)
    zip = models.CharField(max_length=10)

    # Should NOT be empty
    emails = models.CharField(max_length=1024,
                              validators=[validate_comma_separated_emails],
                              help_text=escape(
                                  squeeze(u"""
                Comma separated list of e-mails. E.g. 'John <*****@*****.**>,
                [email protected], "Smith, Jane" <*****@*****.**>'
                """)))

    # Should NOT be empty; Read-only; Automaticly computed in save() whenever creating a new object
    # or changing its name. Any user defined value is replaced.
    slug = models.SlugField(max_length=255,
                            db_index=False,
                            help_text=squeeze(u"""
                Slug for full-text search. Automaticly computed whenever creating a new object or
                changing its name. Any user defined value is replaced.
                """))

    # May NOT be NULL
    STATUSES = FieldChoices(
        (u'PENDING', 1, _(u'obligees:Obligee:status:PENDING')),
        (u'DISSOLVED', 2, _(u'obligees:Obligee:status:DISSOLVED')),
    )
    status = models.SmallIntegerField(choices=STATUSES._choices,
                                      help_text=squeeze(u"""
                "Pending" for obligees that exist and accept inforequests; "Dissolved" for obligees
                that do not exist any more and no further inforequests may be submitted to them.
                """))

    # Added by ``@register_history``:
    #  -- history: simple_history.manager.HistoryManager
    #     Returns instance historical snapshots as HistoricalObligee model.

    objects = ObligeeQuerySet.as_manager()

    class Meta:
        # FIXME: Ordinary indexes do not work for LIKE '%word%'. So we don't declare any indexes
        # for ``slug`` yet. Eventually, we need to define a fulltext index for "slug" or "name" and
        # use ``__search`` instead of ``__contains`` in autocomplete view. However, SQLite does not
        # support ``__contains`` and MySQL supports fulltext indexes for InnoDB tables only since
        # version 5.6.4, but our server has only MySQL 5.5.x so far. We need to upgrate our
        # production MySQL server and find a workaround for SQLite we use in development mode.
        # Alternatively, we can use some complex fulltext search engine like ElasticSearch.
        index_together = [
            [u'name', u'id'],
        ]

    @property
    def emails_parsed(self):
        return ((n, a) for n, a in getaddresses([self.emails]) if a)

    @property
    def emails_formatted(self):
        return (formataddr((n, a)) for n, a in getaddresses([self.emails])
                if a)

    @decorate(prevent_bulk_create=True)
    def save(self, *args, **kwargs):
        update_fields = kwargs.get(u'update_fields', None)

        # Generate and save slug if saving name
        if update_fields is None or u'name' in update_fields:
            name = unidecode(self.name).lower()
            words = (w for w in re.split(r'[^a-z0-9]+', name) if w)
            self.slug = u'-%s-' % u'-'.join(words)

            if update_fields is not None:
                update_fields.append(u'slug')

        super(Obligee, self).save(*args, **kwargs)

    def __unicode__(self):
        return u'%s' % self.pk
Ejemplo n.º 5
0
class Obligee(FormatMixin, models.Model):
    # FIXME: Ordinary indexes do not work for LIKE '%word%'. So we can't use the slug index for
    # searching. Eventually, we need to define a fulltext index for "slug" or "name" and use
    # ``__search`` instead of ``__contains`` in autocomplete view. However, SQLite does not support
    # ``__contains`` and MySQL supports fulltext indexes for InnoDB tables only since version
    # 5.6.4, but our server has only MySQL 5.5.x so far. We need to upgrate our production MySQL
    # server and find a workaround for SQLite we use in development mode. Alternatively, we can use
    # some complex fulltext search engine like ElasticSearch.

    # Should NOT be empty
    official_name = models.CharField(max_length=255,
                                     help_text=u'Official obligee name.')

    # Should NOT be empty
    name = models.CharField(max_length=255,
                            unique=True,
                            help_text=squeeze(u"""
                Unique human readable obligee name. If official obligee name is ambiguous, it
                should be made more specific.
                """))

    # Should NOT be empty; Read-only; Automaticly computed in save()
    slug = models.SlugField(max_length=255,
                            unique=True,
                            help_text=squeeze(u"""
                Unique slug to identify the obligee used in urls. Automaticly computed from the
                obligee name. May not be changed manually.
                """))

    # Should NOT be empty
    name_genitive = models.CharField(max_length=255,
                                     help_text=u'Genitive of obligee name.')
    name_dative = models.CharField(max_length=255,
                                   help_text=u'Dative of obligee name.')
    name_accusative = models.CharField(
        max_length=255, help_text=u'Accusative of obligee name.')
    name_locative = models.CharField(max_length=255,
                                     help_text=u'Locative of obligee name.')
    name_instrumental = models.CharField(
        max_length=255, help_text=u'Instrumental of obligee name.')

    # May NOT be NULL
    GENDERS = FieldChoices(
        (u'MASCULINE', 1, _(u'obligees:Obligee:gender:MASCULINE')),
        (u'FEMININE', 2, _(u'obligees:Obligee:gender:FEMININE')),
        (u'NEUTER', 3, _(u'obligees:Obligee:gender:NEUTER')),
        (u'PLURALE', 4, _(u'obligees:Obligee:gender:PLURALE')),  # Pomnožné
    )
    gender = models.SmallIntegerField(
        choices=GENDERS._choices, help_text=u'Obligee name grammar gender.')

    # May be empty
    ico = models.CharField(blank=True,
                           max_length=32,
                           help_text=u'Legal identification number if known.')

    # Should NOT be empty
    street = models.CharField(
        max_length=255, help_text=u'Street and number part of postal address.')
    city = models.CharField(max_length=255,
                            help_text=u'City part of postal address.')
    zip = models.CharField(max_length=10,
                           help_text=u'Zip part of postal address.')

    # May NOT be NULL
    iczsj = models.ForeignKey(
        Neighbourhood,
        help_text=u'City neighbourhood the obligee address is in.')

    # May be empty
    emails = models.CharField(blank=True,
                              max_length=1024,
                              validators=[validate_comma_separated_emails],
                              help_text=escape(
                                  squeeze(u"""
                Comma separated list of e-mails. E.g. 'John <*****@*****.**>,
                [email protected], "Smith, Jane" <*****@*****.**>'. Empty the email
                address is unknown.
                """)))

    # May be NULL
    latitude = models.FloatField(null=True,
                                 blank=True,
                                 help_text=u'Obligee GPS latitude')
    longitude = models.FloatField(null=True,
                                  blank=True,
                                  help_text=u'Obligee GPS longitude')

    # May be empty
    tags = models.ManyToManyField(ObligeeTag, blank=True)
    groups = models.ManyToManyField(ObligeeGroup, blank=True)

    # May NOT be NULL
    TYPES = FieldChoices(
        (u'SECTION_1', 1, _(u'obligees:Obligee:type:SECTION_1')),
        (u'SECTION_2', 2, _(u'obligees:Obligee:type:SECTION_2')),
        (u'SECTION_3', 3, _(u'obligees:Obligee:type:SECTION_3')),
        (u'SECTION_4', 4, _(u'obligees:Obligee:type:SECTION_4')),
    )
    type = models.SmallIntegerField(choices=TYPES._choices,
                                    help_text=squeeze(u"""
                Obligee type according to §2. Obligees defined in section 3 are obliged to disclose
                some information only.
                """))

    # May be empty
    official_description = models.TextField(
        blank=True, help_text=u'Official obligee description.')
    simple_description = models.TextField(
        blank=True, help_text=u'Human readable obligee description.')

    # May NOT be NULL
    STATUSES = FieldChoices(
        (u'PENDING', 1, _(u'obligees:Obligee:status:PENDING')),
        (u'DISSOLVED', 2, _(u'obligees:Obligee:status:DISSOLVED')),
    )
    status = models.SmallIntegerField(choices=STATUSES._choices,
                                      help_text=squeeze(u"""
                "Pending" for obligees that exist and accept inforequests; "Dissolved" for obligees
                that do not exist any more and no further inforequests may be submitted to them.
                """))

    # May be empty
    notes = models.TextField(
        blank=True,
        help_text=u'Internal freetext notes. Not shown to the user.')

    # Backward relations:
    #
    #  -- history: HistoryManager added by @register_history
    #     Returns instance historical snapshots as HistoricalObligee model.
    #
    #  -- obligeealias_set: by ObligeeAlias.obligee
    #     May be empty
    #
    #  -- branch_set: by Branch.obligee
    #     May be empty
    #
    #  -- inforequestdraft_set: by InforequestDraft.obligee
    #     May be empty

    # Backward relations added to other models:
    #
    #  -- ObligeeTag.obligee_set
    #     May be empty
    #
    #  -- ObligeeGroup.obligee_set
    #     May be empty

    # Indexes:
    #  -- name: unique
    #  -- slug: unique

    objects = ObligeeQuerySet.as_manager()

    @staticmethod
    def dummy_email(name, tpl):
        slug = slugify(name)[:30].strip(u'-')
        return tpl.format(name=slug)

    @cached_property
    def emails_parsed(self):
        return [(n, a) for n, a in getaddresses([self.emails]) if a]

    @cached_property
    def emails_formatted(self):
        return [
            formataddr((n, a)) for n, a in getaddresses([self.emails]) if a
        ]

    @decorate(prevent_bulk_create=True)
    def save(self, *args, **kwargs):
        update_fields = kwargs.get(u'update_fields', None)

        # Generate and save slug if saving name
        if update_fields is None or u'name' in update_fields:
            self.slug = slugify(self.name)
            if update_fields is not None:
                update_fields.append(u'slug')

        super(Obligee, self).save(*args, **kwargs)

    def __unicode__(self):
        return u'[{}] {}'.format(self.pk, self.name)
Ejemplo n.º 6
0
class Action(models.Model):
    # May NOT be NULL
    branch = models.ForeignKey(u'Branch')

    # NOT NULL for actions sent or received by email; NULL otherwise
    email = models.OneToOneField(u'mail.Message',
                                 blank=True,
                                 null=True,
                                 on_delete=models.SET_NULL)

    # May NOT be NULL
    TYPES = FieldChoices(
        # Applicant actions
        (u'REQUEST', 1, _(u'inforequests:Action:type:REQUEST')),
        (u'CLARIFICATION_RESPONSE', 12,
         _(u'inforequests:Action:type:CLARIFICATION_RESPONSE')),
        (u'APPEAL', 13, _(u'inforequests:Action:type:APPEAL')),
        # Obligee actions
        (u'CONFIRMATION', 2, _(u'inforequests:Action:type:CONFIRMATION')),
        (u'EXTENSION', 3, _(u'inforequests:Action:type:EXTENSION')),
        (u'ADVANCEMENT', 4, _(u'inforequests:Action:type:ADVANCEMENT')),
        (u'CLARIFICATION_REQUEST', 5,
         _(u'inforequests:Action:type:CLARIFICATION_REQUEST')),
        (u'DISCLOSURE', 6, _(u'inforequests:Action:type:DISCLOSURE')),
        (u'REFUSAL', 7, _(u'inforequests:Action:type:REFUSAL')),
        (u'AFFIRMATION', 8, _(u'inforequests:Action:type:AFFIRMATION')),
        (u'REVERSION', 9, _(u'inforequests:Action:type:REVERSION')),
        (u'REMANDMENT', 10, _(u'inforequests:Action:type:REMANDMENT')),
        # Implicit actions
        (u'ADVANCED_REQUEST', 11,
         _(u'inforequests:Action:type:ADVANCED_REQUEST')),
        (u'EXPIRATION', 14, _(u'inforequests:Action:type:EXPIRATION')),
        (u'APPEAL_EXPIRATION', 15,
         _(u'inforequests:Action:type:APPEAL_EXPIRATION')),
    )
    APPLICANT_ACTION_TYPES = (
        TYPES.REQUEST,
        TYPES.CLARIFICATION_RESPONSE,
        TYPES.APPEAL,
    )
    APPLICANT_EMAIL_ACTION_TYPES = (
        TYPES.REQUEST,
        TYPES.CLARIFICATION_RESPONSE,
    )
    OBLIGEE_ACTION_TYPES = (
        TYPES.CONFIRMATION,
        TYPES.EXTENSION,
        TYPES.ADVANCEMENT,
        TYPES.CLARIFICATION_REQUEST,
        TYPES.DISCLOSURE,
        TYPES.REFUSAL,
        TYPES.AFFIRMATION,
        TYPES.REVERSION,
        TYPES.REMANDMENT,
    )
    OBLIGEE_EMAIL_ACTION_TYPES = (
        TYPES.CONFIRMATION,
        TYPES.EXTENSION,
        TYPES.ADVANCEMENT,
        TYPES.CLARIFICATION_REQUEST,
        TYPES.DISCLOSURE,
        TYPES.REFUSAL,
    )
    IMPLICIT_ACTION_TYPES = (
        TYPES.ADVANCED_REQUEST,
        TYPES.EXPIRATION,
        TYPES.APPEAL_EXPIRATION,
    )
    type = models.SmallIntegerField(choices=TYPES._choices)

    # May be empty for implicit actions; Should NOT be empty for other actions
    subject = models.CharField(blank=True, max_length=255)
    content = models.TextField(blank=True)

    # NOT NULL
    CONTENT_TYPES = FieldChoices(
        (u'PLAIN_TEXT', 1, u'Plain Text'),
        (u'HTML', 2, u'HTML'),
    )
    content_type = models.SmallIntegerField(choices=CONTENT_TYPES._choices,
                                            default=CONTENT_TYPES.PLAIN_TEXT,
                                            help_text=squeeze(u"""
                Mandatory choice action content type. Supported formats are plain text and html
                code. The html code is assumed to be safe. It is passed to the client without
                sanitizing. It must be sanitized before saving it here.
                """))

    # May be empty
    attachment_set = generic.GenericRelation(
        u'attachments.Attachment',
        content_type_field=u'generic_type',
        object_id_field=u'generic_id')

    # May NOT be NULL
    effective_date = models.DateField(help_text=squeeze(u"""
                The date at which the action was sent or received. If the action was sent/received
                by e‑mail it's set automatically. If it was sent/received by s‑mail it's filled by
                the applicant.
                """))

    # May be empty for obligee actions; Should be empty for other actions
    file_number = models.CharField(blank=True,
                                   max_length=255,
                                   help_text=squeeze(u"""
                A file number assigned to the action by the obligee. Usually only obligee actions
                have it. However, if we know tha obligee assigned a file number to an applicant
                action, we should keep it here as well. The file number is not mandatory.
                """))

    # May NOT be NULL for actions that set deadline; Must be NULL otherwise. Default value is
    # determined and automaticly set in save() when creating a new object. All actions that set
    # deadlines except CLARIFICATION_REQUEST, DISCLOSURE and REFUSAL set the deadline for the
    # obligee. CLARIFICATION_REQUEST, DISCLOSURE and REFUSAL set the deadline for the applicant.
    # DISCLOSURE sets the deadline only if not FULL.
    DEFAULT_DEADLINES = Bunch(
        # Applicant actions
        REQUEST=8,
        CLARIFICATION_RESPONSE=8,
        APPEAL=30,
        # Obligee actions
        CONFIRMATION=8,
        EXTENSION=10,
        ADVANCEMENT=30,
        CLARIFICATION_REQUEST=7,  # Deadline for the applicant
        DISCLOSURE=(
            lambda a: 15  # Deadline for the applicant if not full disclosure
            if a.disclosure_level != a.DISCLOSURE_LEVELS.FULL else None),
        REFUSAL=15,  # Deadline for the applicant
        AFFIRMATION=None,
        REVERSION=None,
        REMANDMENT=13,
        # Implicit actions
        ADVANCED_REQUEST=13,
        EXPIRATION=30,
        APPEAL_EXPIRATION=None,
    )
    SETTING_APPLICANT_DEADLINE_TYPES = (
        # Applicant actions
        # Obligee actions
        TYPES.ADVANCEMENT,
        TYPES.CLARIFICATION_REQUEST,
        TYPES.DISCLOSURE,
        TYPES.REFUSAL,
        # Implicit actions
        TYPES.EXPIRATION,
    )
    SETTING_OBLIGEE_DEADLINE_TYPES = (
        # Applicant actions
        TYPES.REQUEST,
        TYPES.CLARIFICATION_RESPONSE,
        TYPES.APPEAL,
        # Obligee actions
        TYPES.CONFIRMATION,
        TYPES.EXTENSION,
        TYPES.REMANDMENT,
        # Implicit actions
        TYPES.ADVANCED_REQUEST,
    )
    deadline = models.IntegerField(blank=True,
                                   null=True,
                                   help_text=squeeze(u"""
                The deadline that apply after the action, if the action sets a deadline, NULL
                otherwise. The deadline is expressed in a number of working days (WD) counting
                since the effective date. It may apply to the applicant or to the obligee,
                depending on the action type.
                """))

    # May be NULL
    extension = models.IntegerField(blank=True,
                                    null=True,
                                    help_text=squeeze(u"""
                Applicant extension to the deadline, if the action sets an obligee deadline. The
                applicant may extend the deadline after it is missed in order to be patient and
                wait a little longer. He may extend it multiple times. Applicant deadlines may not
                be extended.
                """))

    # NOT NULL for ADVANCEMENT, DISCLOSURE, REVERSION and REMANDMENT; NULL otherwise
    DISCLOSURE_LEVELS = FieldChoices(
        (u'NONE', 1, _(u'inforequests:Action:disclosure_level:NONE')),
        (u'PARTIAL', 2, _(u'inforequests:Action:disclosure_level:PARTIAL')),
        (u'FULL', 3, _(u'inforequests:Action:disclosure_level:FULL')),
    )
    disclosure_level = models.SmallIntegerField(
        choices=DISCLOSURE_LEVELS._choices,
        blank=True,
        null=True,
        help_text=squeeze(u"""
                Mandatory choice for advancement, disclosure, reversion and remandment actions,
                NULL otherwise. Specifies if the obligee disclosed any requested information by
                this action.
                """))

    # NOT NULL for REFUSAL and AFFIRMATION; NULL otherwise
    REFUSAL_REASONS = FieldChoices(
        (u'DOES_NOT_HAVE', u'3',
         _(u'inforequests:Action:refusal_reason:DOES_NOT_HAVE')),
        (u'DOES_NOT_PROVIDE', u'4',
         _(u'inforequests:Action:refusal_reason:DOES_NOT_PROVIDE')),
        (u'DOES_NOT_CREATE', u'5',
         _(u'inforequests:Action:refusal_reason:DOES_NOT_CREATE')),
        (u'COPYRIGHT', u'6',
         _(u'inforequests:Action:refusal_reason:COPYRIGHT')),
        (u'BUSINESS_SECRET', u'7',
         _(u'inforequests:Action:refusal_reason:BUSINESS_SECRET')),
        (u'PERSONAL', u'8', _(u'inforequests:Action:refusal_reason:PERSONAL')),
        (u'CONFIDENTIAL', u'9',
         _(u'inforequests:Action:refusal_reason:CONFIDENTIAL')),
        (u'OTHER_REASON', u'-2',
         _(u'inforequests:Action:refusal_reason:OTHER_REASON')),
    )
    refusal_reason = MultiSelectField(choices=REFUSAL_REASONS._choices,
                                      blank=True,
                                      help_text=squeeze(u"""
                Optional multichoice for refusal and affirmation actions, NULL otherwise. Specifies
                the reason why the obligee refused to disclose the information. Empty value
                means that the obligee refused to disclose it with no reason.
                """))

    # May be NULL; Used by ``cron.obligee_deadline_reminder`` and ``cron.applicant_deadline_reminder``
    last_deadline_reminder = models.DateTimeField(blank=True, null=True)

    # Backward relations:
    #
    #  -- advanced_to_set: by Branch.advanced_by
    #     May NOT be empty for ADVANCEMENT; Must be empty otherwise

    # Backward relations added to other models:
    #
    #  -- Branch.action_set
    #     Should NOT be empty
    #
    #  -- Message.action
    #     May be undefined

    objects = ActionQuerySet.as_manager()

    class Meta:
        index_together = [
            [u'effective_date', u'id'],
            # [u'branch'] -- ForeignKey defines index by default
            # [u'email'] -- OneToOneField defines index by default
        ]

    @staticmethod
    def prefetch_attachments(path=None, queryset=None):
        u"""
        Use to prefetch ``Action.attachments``.
        """
        if queryset is None:
            queryset = Attachment.objects.get_queryset()
        queryset = queryset.order_by_pk()
        return Prefetch(join_lookup(path, u'attachment_set'),
                        queryset,
                        to_attr=u'attachments')

    @cached_property
    def attachments(self):
        u"""
        Cached list of all action attachments ordered by ``pk``. May be prefetched with
        ``prefetch_related(Action.prefetch_attachments())`` queryset method.
        """
        return list(self.attachment_set.order_by_pk())

    @cached_property
    def is_applicant_action(self):
        return self.type in self.APPLICANT_ACTION_TYPES

    @cached_property
    def is_obligee_action(self):
        return self.type in self.OBLIGEE_ACTION_TYPES

    @cached_property
    def is_implicit_action(self):
        return self.type in self.IMPLICIT_ACTION_TYPES

    @cached_property
    def is_by_email(self):
        return self.email_id is not None

    @cached_property
    def days_passed(self):
        return self.days_passed_at(local_today())

    @cached_property
    def deadline_remaining(self):
        return self.deadline_remaining_at(local_today())

    @cached_property
    def deadline_missed(self):
        return self.deadline_missed_at(local_today())

    @cached_property
    def deadline_date(self):
        if self.deadline is None:
            return None
        deadline = self.deadline + (self.extension or 0)
        return workdays.advance(self.effective_date, deadline)

    @cached_property
    def has_deadline(self):
        return self.deadline is not None

    @cached_property
    def has_applicant_deadline(self):
        return self.deadline is not None and self.type in self.SETTING_APPLICANT_DEADLINE_TYPES

    @cached_property
    def has_obligee_deadline(self):
        return self.deadline is not None and self.type in self.SETTING_OBLIGEE_DEADLINE_TYPES

    @decorate(prevent_bulk_create=True)
    def save(self, *args, **kwargs):
        if self.pk is None:  # Creating a new object

            assert self.type is not None, u'%s.type is mandatory' % self.__class__.__name__
            if self.deadline is None:
                type_name = self.TYPES._inverse[self.type]
                deadline = getattr(self.DEFAULT_DEADLINES, type_name)
                self.deadline = deadline(self) if callable(
                    deadline) else deadline

        super(Action, self).save(*args, **kwargs)

    def get_absolute_url(self):
        return self.branch.inforequest.get_absolute_url(u'#action-%d' %
                                                        self.pk)

    def days_passed_at(self, at):
        return workdays.between(self.effective_date, at)

    def deadline_remaining_at(self, at):
        if self.deadline is None:
            return None
        deadline = self.deadline + (self.extension or 0)
        return deadline - self.days_passed_at(at)

    def deadline_missed_at(self, at):
        # self.deadline_remaining is 0 on the last day of deadline
        remaining = self.deadline_remaining_at(at)
        return remaining is not None and remaining < 0

    def send_by_email(self):
        if not self.is_applicant_action:
            raise TypeError(u'%s is not applicant action' %
                            self.get_type_display())

        sender_name = self.branch.inforequest.applicant_name
        sender_address = self.branch.inforequest.unique_email
        sender_formatted = formataddr((squeeze(sender_name), sender_address))
        recipients = (formataddr(r)
                      for r in self.branch.collect_obligee_emails())

        # FIXME: Attachment name and content type are set by client and not to be trusted. The name
        # must be sanitized and the content type white listed for known content types. Any unknown
        # content type should be replaced with 'application/octet-stream'.

        msg = EmailMessage(self.subject, self.content, sender_formatted,
                           recipients)
        for attachment in self.attachments:
            msg.attach(attachment.name, attachment.content,
                       attachment.content_type)
        msg.send()

        inforequestemail = InforequestEmail(
            inforequest=self.branch.inforequest,
            email=msg.instance,
            type=InforequestEmail.TYPES.APPLICANT_ACTION,
        )
        inforequestemail.save()

        self.email = msg.instance
        self.save(update_fields=[u'email'])

    def __unicode__(self):
        return u'%s' % self.pk
Ejemplo n.º 7
0
class Action(FormatMixin, models.Model):
    # NOT NULL
    branch = models.ForeignKey(u'Branch')

    # NOT NULL for actions sent or received by email; NULL otherwise
    email = models.OneToOneField(u'mail.Message', blank=True, null=True, on_delete=models.SET_NULL)

    # NOT NULL
    TYPES = FieldChoices(
            # Applicant actions
            (u'REQUEST',                 1, _(u'inforequests:Action:type:REQUEST')),
            (u'CLARIFICATION_RESPONSE', 12, _(u'inforequests:Action:type:CLARIFICATION_RESPONSE')),
            (u'APPEAL',                 13, _(u'inforequests:Action:type:APPEAL')),
            # Obligee actions
            (u'CONFIRMATION',            2, _(u'inforequests:Action:type:CONFIRMATION')),
            (u'EXTENSION',               3, _(u'inforequests:Action:type:EXTENSION')),
            (u'ADVANCEMENT',             4, _(u'inforequests:Action:type:ADVANCEMENT')),
            (u'CLARIFICATION_REQUEST',   5, _(u'inforequests:Action:type:CLARIFICATION_REQUEST')),
            (u'DISCLOSURE',              6, _(u'inforequests:Action:type:DISCLOSURE')),
            (u'REFUSAL',                 7, _(u'inforequests:Action:type:REFUSAL')),
            (u'AFFIRMATION',             8, _(u'inforequests:Action:type:AFFIRMATION')),
            (u'REVERSION',               9, _(u'inforequests:Action:type:REVERSION')),
            (u'REMANDMENT',             10, _(u'inforequests:Action:type:REMANDMENT')),
            # Implicit actions
            (u'ADVANCED_REQUEST',       11, _(u'inforequests:Action:type:ADVANCED_REQUEST')),
            (u'EXPIRATION',             14, _(u'inforequests:Action:type:EXPIRATION')),
            (u'APPEAL_EXPIRATION',      15, _(u'inforequests:Action:type:APPEAL_EXPIRATION')),
            )
    APPLICANT_ACTION_TYPES = (
            TYPES.REQUEST,
            TYPES.CLARIFICATION_RESPONSE,
            TYPES.APPEAL,
            )
    APPLICANT_EMAIL_ACTION_TYPES = (
            TYPES.REQUEST,
            TYPES.CLARIFICATION_RESPONSE,
            )
    OBLIGEE_ACTION_TYPES = (
            TYPES.CONFIRMATION,
            TYPES.EXTENSION,
            TYPES.ADVANCEMENT,
            TYPES.CLARIFICATION_REQUEST,
            TYPES.DISCLOSURE,
            TYPES.REFUSAL,
            TYPES.AFFIRMATION,
            TYPES.REVERSION,
            TYPES.REMANDMENT,
            )
    OBLIGEE_EMAIL_ACTION_TYPES = (
            TYPES.CONFIRMATION,
            TYPES.EXTENSION,
            TYPES.ADVANCEMENT,
            TYPES.CLARIFICATION_REQUEST,
            TYPES.DISCLOSURE,
            TYPES.REFUSAL,
            )
    IMPLICIT_ACTION_TYPES = (
            TYPES.ADVANCED_REQUEST,
            TYPES.EXPIRATION,
            TYPES.APPEAL_EXPIRATION,
            )
    type = models.SmallIntegerField(choices=TYPES._choices)

    # May be empty for implicit actions; Should NOT be empty for other actions
    subject = models.CharField(blank=True, max_length=255)
    content = models.TextField(blank=True)

    # NOT NULL
    CONTENT_TYPES = FieldChoices(
            (u'PLAIN_TEXT', 1, u'Plain Text'),
            (u'HTML',       2, u'HTML'),
            )
    content_type = models.SmallIntegerField(choices=CONTENT_TYPES._choices,
            default=CONTENT_TYPES.PLAIN_TEXT,
            help_text=squeeze(u"""
                Mandatory choice for action content type. Supported formats are plain text and html
                code. The html code is assumed to be safe. It is passed to the client without
                sanitizing. It must be sanitized before saving it here.
                """))

    # May be empty
    attachment_set = generic.GenericRelation(u'attachments.Attachment',
            content_type_field=u'generic_type', object_id_field=u'generic_id')

    # May be empty
    file_number = models.CharField(blank=True, max_length=255,
            help_text=squeeze(u"""
                A file number assigned to the action by the obligee. Usually only obligee actions
                have it. However, if we know tha obligee assigned a file number to an applicant
                action, we should keep it here as well. The file number is optional.
                """))

    # NOT NULL
    created = models.DateTimeField(default=utc_now,
            help_text=squeeze(u"""
                Point in time used to order branch actions chronologically. By default it's the
                datetime the action was created. The admin may set the value manually to change
                actions order in the branch.
                """))

    # NOT NULL for applicant actions; May be NULL for obligee actions; NULL otherwise
    sent_date = models.DateField(blank=True, null=True,
            help_text=squeeze(u"""
                The date the action was sent by the applicant or the obligee. It is mandatory for
                applicant actions, optional for obligee actions and should be NULL for implicit
                actions.
                """))

    # May be NULL for applicant actions; NOT NULL for obligee actions; NULL otherwise
    delivered_date = models.DateField(blank=True, null=True,
            help_text=squeeze(u"""
                The date the action was delivered to the applicant or the obligee. It is optional
                for applicant actions, mandatory for obligee actions and should be NULL for
                implicit actions.
                """))

    # NOT NULL
    legal_date = models.DateField(
            help_text=squeeze(u"""
                The date the action legally occured. Mandatory for every action.
                """))

    # NOT NULL for EXTENSION; NULL otherwise
    extension = models.IntegerField(blank=True, null=True,
            help_text=squeeze(u"""
                Obligee extension to the original deadline. Applicable only to extension actions.
                Ignored for all other actions.
                """))

    # May be NULL for actions with obligee deadline; NULL otherwise
    snooze = models.DateField(blank=True, null=True,
            help_text=squeeze(u"""
                The applicant may snooze for few days after the obligee misses its deadline and
                wait a little longer. He may snooze multiple times. Ignored for applicant
                deadlines.
                """))

    # NOT NULL for obligee actions that may disclose the information; NULL otherwise
    DISCLOSURE_LEVELS = FieldChoices(
            (u'NONE',    1, _(u'inforequests:Action:disclosure_level:NONE')),
            (u'PARTIAL', 2, _(u'inforequests:Action:disclosure_level:PARTIAL')),
            (u'FULL',    3, _(u'inforequests:Action:disclosure_level:FULL')),
            )
    disclosure_level = models.SmallIntegerField(choices=DISCLOSURE_LEVELS._choices,
            blank=True, null=True,
            help_text=squeeze(u"""
                Mandatory choice for obligee actions that may disclose the information, NULL
                otherwise. Specifies if the obligee disclosed any requested information by this
                action.
                """))

    # May be empty for obligee actions that may provide a reason; Empty otherwise
    REFUSAL_REASONS = FieldChoices(
            (u'DOES_NOT_HAVE',    u'3', _(u'inforequests:Action:refusal_reason:DOES_NOT_HAVE')),
            (u'DOES_NOT_PROVIDE', u'4', _(u'inforequests:Action:refusal_reason:DOES_NOT_PROVIDE')),
            (u'DOES_NOT_CREATE',  u'5', _(u'inforequests:Action:refusal_reason:DOES_NOT_CREATE')),
            (u'COPYRIGHT',        u'6', _(u'inforequests:Action:refusal_reason:COPYRIGHT')),
            (u'BUSINESS_SECRET',  u'7', _(u'inforequests:Action:refusal_reason:BUSINESS_SECRET')),
            (u'PERSONAL',         u'8', _(u'inforequests:Action:refusal_reason:PERSONAL')),
            (u'CONFIDENTIAL',     u'9', _(u'inforequests:Action:refusal_reason:CONFIDENTIAL')),
            (u'OTHER_REASON',    u'-2', _(u'inforequests:Action:refusal_reason:OTHER_REASON')),
            )
    APPEAL_REFUSAL_REASONS = FieldChoices(
            (u'DOES_NOT_HAVE',    u'3', _(u'inforequests:Action:appeal_refusal_reason:DOES_NOT_HAVE')),
            (u'DOES_NOT_PROVIDE', u'4', _(u'inforequests:Action:appeal_refusal_reason:DOES_NOT_PROVIDE')),
            (u'DOES_NOT_CREATE',  u'5', _(u'inforequests:Action:appeal_refusal_reason:DOES_NOT_CREATE')),
            (u'COPYRIGHT',        u'6', _(u'inforequests:Action:appeal_refusal_reason:COPYRIGHT')),
            (u'BUSINESS_SECRET',  u'7', _(u'inforequests:Action:appeal_refusal_reason:BUSINESS_SECRET')),
            (u'PERSONAL',         u'8', _(u'inforequests:Action:appeal_refusal_reason:PERSONAL')),
            (u'CONFIDENTIAL',     u'9', _(u'inforequests:Action:appeal_refusal_reason:CONFIDENTIAL')),
            (u'OTHER_REASON',    u'-2', _(u'inforequests:Action:appeal_refusal_reason:OTHER_REASON')),
            )
    refusal_reason = MultiSelectField(choices=REFUSAL_REASONS._choices, blank=True,
            help_text=squeeze(u"""
                Optional multichoice for obligee actions that may provide a reason for not
                disclosing the information, Should be empty for all other actions. Specifies the
                reason why the obligee refused to disclose the information. An empty value means
                that the obligee did not provide any reason.
                """))

    # May be NULL; Used by ``cron.obligee_deadline_reminder`` and ``cron.applicant_deadline_reminder``
    last_deadline_reminder = models.DateTimeField(blank=True, null=True)

    # Backward relations:
    #
    #  -- advanced_to_set: by Branch.advanced_by
    #     May NOT be empty for ADVANCEMENT; Must be empty otherwise

    # Backward relations added to other models:
    #
    #  -- Branch.action_set
    #     May NOT be empty; The first action of every main branch must be REQUEST and the first
    #     action of every advanced branch ADVANCED_REQUEST.
    #
    #  -- Message.action
    #     May raise DoesNotExist

    # Indexes:
    #  -- branch:      ForeignKey
    #  -- email:       OneToOneField
    #  -- created, id: index_together

    objects = ActionQuerySet.as_manager()

    class Meta:
        index_together = [
                [u'created', u'id'],
                ]

    @staticmethod
    def prefetch_attachments(path=None, queryset=None):
        u"""
        Use to prefetch ``Action.attachments``.
        """
        if queryset is None:
            queryset = Attachment.objects.get_queryset()
        queryset = queryset.order_by_pk()
        return Prefetch(join_lookup(path, u'attachment_set'), queryset, to_attr=u'attachments')

    @cached_property
    def attachments(self):
        u"""
        Cached list of all action attachments ordered by ``pk``. May be prefetched with
        ``prefetch_related(Action.prefetch_attachments())`` queryset method.
        """
        return list(self.attachment_set.order_by_pk())

    @cached_property
    def previous_action(self):
        return self.branch.action_set.order_by_created().before(self).last()

    @cached_property
    def next_action(self):
        return self.branch.action_set.order_by_created().after(self).first()

    @cached_property
    def action_path(self):
        res = [] if self.branch.is_main else self.branch.advanced_by.action_path
        res += self.branch.actions[:self.branch.actions.index(self)+1]
        return res

    @cached_property
    def is_applicant_action(self):
        return self.type in self.APPLICANT_ACTION_TYPES

    @cached_property
    def is_obligee_action(self):
        return self.type in self.OBLIGEE_ACTION_TYPES

    @cached_property
    def is_implicit_action(self):
        return self.type in self.IMPLICIT_ACTION_TYPES

    @cached_property
    def is_by_email(self):
        return self.email_id is not None

    @cached_property
    def has_obligee_deadline(self):
        return self.deadline and self.deadline.is_obligee_deadline

    @cached_property
    def has_obligee_deadline_missed(self):
        return self.has_obligee_deadline and self.deadline.is_deadline_missed

    @cached_property
    def has_obligee_deadline_snooze_missed(self):
        return self.has_obligee_deadline and self.deadline.is_snooze_missed

    @cached_property
    def has_applicant_deadline(self):
        return self.deadline and self.deadline.is_applicant_deadline

    @cached_property
    def has_applicant_deadline_missed(self):
        return self.has_applicant_deadline and self.deadline.is_deadline_missed

    @cached_property
    def can_applicant_snooze(self):
        u"""
        Whether the applicant may snooze for 3 calendar days sinde today such that the total snooze
        since the deadline date will not be more than 8 calendar days.
        """
        return (self.has_obligee_deadline_snooze_missed
                and 8 - self.deadline.calendar_days_behind >= 3)

    @cached_property
    def deadline(self):

        # Applicant actions
        if self.type == self.TYPES.REQUEST:
            return Deadline(Deadline.TYPES.OBLIGEE_DEADLINE,
                    self.delivered_date or workdays.advance(self.sent_date, 1),
                    8, Deadline.UNITS.WORKDAYS, self.snooze)

        elif self.type == self.TYPES.CLARIFICATION_RESPONSE:
            return Deadline(Deadline.TYPES.OBLIGEE_DEADLINE,
                    self.delivered_date or workdays.advance(self.sent_date, 1),
                    8, Deadline.UNITS.WORKDAYS, self.snooze)

        elif self.type == self.TYPES.APPEAL:
            return Deadline(Deadline.TYPES.OBLIGEE_DEADLINE,
                    self.delivered_date or workdays.advance(self.sent_date, 6),
                    15, Deadline.UNITS.CALENDAR_DAYS, self.snooze)

        # Obligee actions
        elif self.type == self.TYPES.CONFIRMATION:
            previous = self.previous_action.deadline
            return Deadline(Deadline.TYPES.OBLIGEE_DEADLINE,
                    previous.base_date, previous.value, previous.unit,
                    self.snooze or previous.snooze_date)

        elif self.type == self.TYPES.EXTENSION:
            previous = self.previous_action.deadline
            extension = self.extension or 0
            return Deadline(Deadline.TYPES.OBLIGEE_DEADLINE,
                    previous.base_date, previous.value + extension, previous.unit,
                    self.snooze or previous.snooze_date)

        elif self.type == self.TYPES.ADVANCEMENT:
            # The user may send an appeal after advancement. But it is not very common, so we don't
            # show any deadline nor send deadline reminder.
            return None

        elif self.type == self.TYPES.CLARIFICATION_REQUEST:
            return Deadline(Deadline.TYPES.APPLICANT_DEADLINE,
                    self.delivered_date, 7, Deadline.UNITS.CALENDAR_DAYS, 0)

        elif self.type == self.TYPES.DISCLOSURE:
            if self.disclosure_level == self.DISCLOSURE_LEVELS.FULL:
                return None
            return Deadline(Deadline.TYPES.APPLICANT_DEADLINE,
                    self.delivered_date, 15, Deadline.UNITS.CALENDAR_DAYS, 0)

        elif self.type == self.TYPES.REFUSAL:
            return Deadline(Deadline.TYPES.APPLICANT_DEADLINE,
                    self.delivered_date, 15, Deadline.UNITS.CALENDAR_DAYS, 0)

        elif self.type == self.TYPES.AFFIRMATION:
            return None

        elif self.type == self.TYPES.REVERSION:
            return None

        elif self.type == self.TYPES.REMANDMENT:
            return Deadline(Deadline.TYPES.OBLIGEE_DEADLINE,
                    workdays.advance(self.legal_date, 4),
                    8, Deadline.UNITS.WORKDAYS, self.snooze)

        # Implicit actions
        elif self.type == self.TYPES.ADVANCED_REQUEST:
            return Deadline(Deadline.TYPES.OBLIGEE_DEADLINE,
                    workdays.advance(self.legal_date, 4),
                    8, Deadline.UNITS.WORKDAYS, self.snooze)

        elif self.type == self.TYPES.EXPIRATION:
            return Deadline(Deadline.TYPES.APPLICANT_DEADLINE,
                    self.legal_date, 15, Deadline.UNITS.CALENDAR_DAYS, 0)

        elif self.type == self.TYPES.APPEAL_EXPIRATION:
            return None

        raise ValueError(u'Invalid action type: {}'.format(self.type))

    @classmethod
    def create(cls, *args, **kwargs):
        advanced_to = kwargs.pop(u'advanced_to', None) or []
        attachments = kwargs.pop(u'attachments', None) or []
        action = Action(*args, **kwargs)

        @after_saved(action)
        def deferred(action):
            for obligee in advanced_to:
                if not obligee:
                    continue
                sub_branch = Branch.create(
                        obligee=obligee,
                        inforequest=action.branch.inforequest,
                        advanced_by=action,
                        action_kwargs=dict(
                            type=Action.TYPES.ADVANCED_REQUEST,
                            legal_date=action.legal_date,
                            ),
                        )
                sub_branch.save()

            for attch in attachments:
                attachment = attch.clone(action)
                attachment.save()

        return action

    def get_extended_type_display(self):
        u"""
        Return a bit more verbose action type description. It is not based only on the action type.
        For instance ``DISCLOSURE`` actions have different descriptions based on their disclosure
        levels.
        """
        if self.type == self.TYPES.DISCLOSURE:
            if self.disclosure_level == self.DISCLOSURE_LEVELS.NONE:
                return _(u'inforequests:Action:type:DISCLOSURE:NONE')
            if self.disclosure_level == self.DISCLOSURE_LEVELS.PARTIAL:
                return _(u'inforequests:Action:type:DISCLOSURE:PARTIAL')
            if self.disclosure_level == self.DISCLOSURE_LEVELS.FULL:
                return _(u'inforequests:Action:type:DISCLOSURE:FULL')
        return self.get_type_display()

    def get_absolute_url(self):
        return self.branch.inforequest.get_absolute_url(u'#a{}'.format(self.pk))

    def send_by_email(self):
        if not self.is_applicant_action:
            raise TypeError(u'{} is not applicant action'.format(self.get_type_display()))
        if not self.branch.collect_obligee_emails:
            # Django silently ignores messages with no recipients
            raise ValueError(u'Action has no recipients')

        sender_name = self.branch.inforequest.applicant_name
        sender_address = self.branch.inforequest.unique_email
        sender_formatted = formataddr((squeeze(sender_name), sender_address))
        recipients = [formataddr(r) for r in self.branch.collect_obligee_emails]

        # FIXME: Attachment name is set by client and may not to be trusted. It must be sanitized.

        msg = EmailMessage(self.subject, self.content, sender_formatted, recipients)
        for attachment in self.attachments:
            msg.attach(attachment.name, attachment.content, attachment.content_type)
        msg.send()

        inforequestemail = InforequestEmail(
                inforequest=self.branch.inforequest,
                email=msg.instance,
                type=InforequestEmail.TYPES.APPLICANT_ACTION,
                )
        inforequestemail.save()

        self.email = msg.instance
        self.save(update_fields=[u'email'])

    def __unicode__(self):
        return u'[{}] {}'.format(self.pk, self.get_type_display())