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)
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)
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)
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
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)
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
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())