class MinutesOfMeetingRevision(MetadataRevision): metadata = models.ForeignKey('MinutesOfMeeting') status = ConfigurableChoiceField(_('Status'), max_length=20, list_index='STATUS_COR_MOM') class Meta: app_label = 'default_documents'
class TransmittalRevision(MetadataRevision): metadata = models.ForeignKey('Transmittal') trs_status = ConfigurableChoiceField(_('Status'), max_length=20, default='opened', list_index='STATUS_TRANSMITTALS') class Meta: app_label = 'transmittals'
class GtgMetadataRevision(TransmittableMixin, MetadataRevision): metadata = models.ForeignKey('GtgMetadata') status = ConfigurableChoiceField(_('Status'), max_length=3, list_index='GTG_STATUSES', null=True, blank=True) final_revision = models.NullBooleanField(_('Is final revision?'), choices=BOOLEANS, null=True, blank=True) def get_first_revision_number(self): """See `MetadataRevision.get_first_revision_number`""" return 1
class ContractorDeliverableRevision(TransmittableMixin, MetadataRevision): # Revision metadata = models.ForeignKey('ContractorDeliverable') status = ConfigurableChoiceField(verbose_name="Status", default="STD", max_length=3, list_index='STATUSES', null=True, blank=True) final_revision = models.NullBooleanField(_('Is final revision?'), choices=BOOLEANS, null=True, blank=True) class Meta: app_label = 'default_documents'
class CorrespondenceRevision(MetadataRevision): metadata = models.ForeignKey('Correspondence') status = ConfigurableChoiceField(_('Status'), max_length=20, list_index='STATUS_COR_MOM') under_review = models.NullBooleanField(_('Under Review'), choices=BOOLEANS, null=True, blank=True) overdue = models.NullBooleanField(_('Overdue'), choices=BOOLEANS, null=True, blank=True) leader = models.ForeignKey(User, verbose_name=_('Leader'), related_name='leading_correspondance', null=True, blank=True) class Meta: app_label = 'default_documents'
class GtgMetadata(Metadata): latest_revision = models.ForeignKey('GtgMetadataRevision', null=True, verbose_name=_('Latest revision')) title = models.TextField(_('title')) originator = models.ForeignKey('accounts.Entity', verbose_name=_('Originator'), limit_choices_to={'type': 'originator'}) unit = ConfigurableChoiceField(verbose_name="Unit", default="000", max_length=3, list_index='GTG_UNITS') discipline = ConfigurableChoiceField(_('Discipline'), max_length=6, list_index='GTG_DISCIPLINES', blank=True, null=True) document_type = ConfigurableChoiceField(_('Document Type'), max_length=3, list_index='GTG_DOCUMENT_TYPES') # Related docs related_documents = models.ManyToManyField( 'documents.Document', related_name='gtg_related_documents', blank=True) # Schedule status_ifr_planned_date = models.DateField(_('Status IFR Planned Date'), null=True, blank=True) status_ifr_forecast_date = models.DateField(_('Status IFR Forecast Date'), null=True, blank=True) status_ifr_actual_date = models.DateField(_('Status IFR Actual Date'), null=True, blank=True) status_ifa_planned_date = models.DateField(_('Status IFA Planned Date'), null=True, blank=True) status_ifa_forecast_date = models.DateField(_('Status IFA Forecast Date'), null=True, blank=True) status_ifa_actual_date = models.DateField(_('Status IFA Actual Date'), null=True, blank=True) status_ifi_planned_date = models.DateField(_('Status IFI Planned Date'), null=True, blank=True) status_ifi_forecast_date = models.DateField(_('Status IFI Forecast Date'), null=True, blank=True) status_ifi_actual_date = models.DateField(_('Status IFI Actual Date'), null=True, blank=True) status_ife_planned_date = models.DateField(_('Status IFE Planned Date'), null=True, blank=True) status_ife_forecast_date = models.DateField(_('Status IFE Forecast Date'), null=True, blank=True) status_ife_actual_date = models.DateField(_('Status IFE Actual Date'), null=True, blank=True) status_ifp_planned_date = models.DateField(_('Status IFP Planned Date'), null=True, blank=True) status_ifp_forecast_date = models.DateField(_('Status IFP Forecast Date'), null=True, blank=True) status_ifp_actual_date = models.DateField(_('Status IFP Actual Date'), null=True, blank=True) status_fin_planned_date = models.DateField(_('Status FIN Planned Date'), null=True, blank=True) status_fin_forecast_date = models.DateField(_('Status FIN Forecast Date'), null=True, blank=True) status_fin_actual_date = models.DateField(_('Status FIN Actual Date'), null=True, blank=True) status_asb_planned_date = models.DateField(_('Status ASB Planned Date'), null=True, blank=True) status_asb_forecast_date = models.DateField(_('Status ASB Forecast Date'), null=True, blank=True) status_asb_actual_date = models.DateField(_('Status ASB Actual Date'), null=True, blank=True) class Meta: verbose_name = _('Gtg deliverable') verbose_name_plural = _('Gtg deliverables') ordering = ('document_number', ) class PhaseConfig: filter_fields = [ 'originator', 'discipline', 'document_type', 'status', 'unit', 'leader', 'approver', 'under_review', 'overdue' ] filter_fields_order_ = [ 'search_terms', 'originator', 'discipline', 'document_type', 'status', 'unit', 'leader', 'approver', 'under_review', 'overdue' ] column_fields = ( ('Document Number', 'document_number'), ('Title', 'title'), ('Status', 'status'), ('Rev.', 'current_revision'), ('Document type', 'document_type'), ('Originator', 'originator'), ('Review start date', 'review_start_date'), ('Review due date', 'review_due_date'), ('Under review', 'under_review'), ('Leader', 'leader'), ('Approver', 'approver'), ) def natural_key(self): return self.document_key def generate_document_key(self): # If document key is not suppplied by user, we generate a uuid return '{}'.format(uuid.uuid4()) @property def status(self): return self.latest_revision.status @property def final_revision(self): return self.latest_revision.final_revision @property def review_start_date(self): return self.latest_revision.review_start_date @property def review_due_date(self): return self.latest_revision.review_due_date @property def under_review(self): return self.latest_revision.is_under_review() @property def overdue(self): return self.latest_revision.is_overdue() @property def leader(self): return self.latest_revision.leader @property def approver(self): return self.latest_revision.approver @property def received_date(self): return self.latest_revision.received_date @property def leader_step_closed(self): return self.latest_revision.leader_step_closed @property def review_end_date(self): return self.latest_revision.review_end_date @property def return_code(self): return self.latest_revision.return_code @classmethod def get_batch_actions(cls, category, user): actions = super(GtgMetadata, cls).get_batch_actions(category, user) actions['start_review'] = MenuItem( 'start-review', _('Start review'), reverse('batch_start_reviews', args=[category.organisation.slug, category.slug]), ajax=True, modal='batch-review-modal', progression_modal=True, icon='eye-open', ) actions['cancel_review'] = MenuItem( 'cancel-review', 'Cancel review', reverse('batch_cancel_reviews', args=[category.organisation.slug, category.slug]), ajax=True, modal='cancel-review-modal', progression_modal=True, icon='eye-close', ) if user.has_perm('transmittals.add_outgoingtransmittal'): actions['create_transmittal'] = MenuItem( 'create-transmittal', 'Create transmittal', reverse('transmittal_create', args=[category.organisation.slug, category.slug]), ajax=True, modal='create-transmittal-modal', progression_modal=True, icon='transfer', ) return actions @classmethod def get_batch_actions_modals(cls): templates = super(GtgMetadata, cls).get_batch_actions_modals() return templates + [ 'reviews/document_list_cancel_review_modal.html', 'reviews/document_list_batch_review_modal.html', 'transmittals/document_list_create_transmittal_modal.html' ]
class MinutesOfMeeting(Metadata): latest_revision = models.ForeignKey('MinutesOfMeetingRevision', null=True, verbose_name=_('Latest revision')) # General information subject = models.TextField(_('Subject')) meeting_date = models.DateField(_('Meeting date')) received_sent_date = models.DateField(_('Received / sent date')) # We keep it for a while contract_number_old = ConfigurableChoiceField(_('Contract Number'), max_length=8, list_index='CONTRACT_NBS', blank=True, null=True) contract_number = models.CharField(verbose_name='Contract Number', max_length=50) originator = ConfigurableChoiceField(_('Originator'), default='FWF', max_length=3, list_index='ORIGINATORS') recipient = ConfigurableChoiceField(_('Recipient'), max_length=50, list_index='RECIPIENTS') document_type = ConfigurableChoiceField(_('Document Type'), default="PID", max_length=3, list_index='DOCUMENT_TYPES') sequential_number = models.CharField( verbose_name="sequential Number", help_text=_('Type in a four digit number'), default="0001", max_length=4, validators=[StringNumberValidator(4)]) prepared_by = ConfigurableChoiceField(_('Prepared by'), null=True, blank=True, max_length=250, list_index='AUTHORS') signed = models.NullBooleanField(_('Signed'), null=True, blank=True, choices=BOOLEANS) # Response reference # TODO Check the queryset response_reference = models.ManyToManyField( 'Correspondence', related_name='mom_correspondence_related_set', blank=True) class Meta: ordering = ('document_number', ) app_label = 'default_documents' unique_together = (( "contract_number", "originator", "recipient", "document_type", "sequential_number", ), ) class PhaseConfig: filter_fields = ( 'originator', 'recipient', 'status', 'signed', 'prepared_by', ) column_fields = ( ('Reference', 'document_number'), ('Subject', 'subject'), ('Meeting date', 'meeting_date'), ('Rec./sent date', 'received_sent_date'), ('Originator', 'originator'), ('Recipient', 'recipient'), ('Document type', 'document_type'), ('Prepared by', 'prepared_by'), ('Signed', 'signed'), ('Status', 'status'), ) def generate_document_key(self): return slugify("{contract_number}-{originator}-{recipient}" "{document_type}-{sequential_number}".format( contract_number=self.contract_number, originator=self.originator, recipient=self.recipient, document_type=self.document_type, sequential_number=self.sequential_number)).upper() def natural_key(self): return (self.document_key, ) @property def status(self): return self.latest_revision.status @property def title(self): return self.subject
class Correspondence(Metadata): latest_revision = models.ForeignKey('CorrespondenceRevision', null=True, verbose_name=_('Latest revision')) # General information subject = models.TextField(_('Subject')) correspondence_date = models.DateField(_('Correspondence date')) received_sent_date = models.DateField(_('Received / sent date')) contract_number_old = ConfigurableChoiceField(_('Contract Number'), max_length=8, list_index='CONTRACT_NBS', null=True, blank=True) contract_number = models.CharField(verbose_name='Contract Number', max_length=50) originator = ConfigurableChoiceField(_('Originator'), default='FWF', max_length=3, list_index='ORIGINATORS') recipient = ConfigurableChoiceField(_('Recipient'), max_length=50, list_index='RECIPIENTS') document_type = ConfigurableChoiceField(_('Document Type'), default="PID", max_length=3, list_index='DOCUMENT_TYPES') sequential_number = models.CharField( verbose_name="sequential Number", help_text=_('Type in a four digit number'), default="0001", max_length=4, validators=[StringNumberValidator(4)]) author = ConfigurableChoiceField(_('Author'), null=True, blank=True, max_length=250, list_index='AUTHORS') addresses = ConfigurableChoiceField(_('Addresses'), null=True, blank=True, list_index='ADDRESSES') response_required = models.NullBooleanField(_('Response required'), null=True, blank=True) due_date = models.DateField(_('Due date'), null=True, blank=True) external_reference = models.TextField(_('External reference'), null=True, blank=True) # Related documents related_documents = models.ManyToManyField( 'documents.Document', related_name='correspondence_related_set', blank=True) class Meta: ordering = ('id', ) app_label = 'default_documents' unique_together = (( "contract_number", "originator", "recipient", "document_type", "sequential_number", ), ) class PhaseConfig: filter_fields = ('originator', 'recipient', 'status', 'overdue', 'leader') column_fields = ( ('Reference', 'document_number'), ('Subject', 'subject'), ('Rec./Sent date', 'received_sent_date'), ('Resp. required', 'response_required'), ('Due date', 'due_date'), ('Status', 'status'), ('Under review', 'status'), ('Overdue', 'overdue'), ('Leader', 'leader'), ('Originator', 'originator'), ('Recipient', 'recipient'), ('Document type', 'document_type'), ) def generate_document_key(self): return slugify("{contract_number}-{originator}-{recipient}-" "{document_type}-{sequential_number}".format( contract_number=self.contract_number, originator=self.originator, recipient=self.recipient, document_type=self.document_type, sequential_number=self.sequential_number)).upper() def natural_key(self): return (self.document_key, ) @property def status(self): return self.latest_revision.status @property def overdue(self): return self.latest_revision.overdue @property def leader(self): return self.latest_revision.leader @property def title(self): return self.subject def get_initial_empty(self): empty_fields = ('final_revision', ) return super(ContractorDeliverableRevision, self).get_initial_empty() + empty_fields
class ContractorDeliverable(ScheduleMixin, Metadata): latest_revision = models.ForeignKey('ContractorDeliverableRevision', null=True, verbose_name=_('Latest revision')) # General information title = models.TextField(verbose_name="Title") # Let's keep this field for a while contract_number_old = ConfigurableChoiceField( verbose_name="Contract Number", max_length=15, list_index='CONTRACT_NBS', null=True, blank=True) contract_number = models.CharField(verbose_name='Contract Number', max_length=50) originator = models.ForeignKey('accounts.Entity', verbose_name=_('Originator')) unit = ConfigurableChoiceField(verbose_name="Unit", default="000", max_length=3, list_index='UNITS') discipline = ConfigurableChoiceField(verbose_name="Discipline", default="PCS", max_length=3, list_index='DISCIPLINES') document_type = ConfigurableChoiceField(verbose_name="Document Type", default="PID", max_length=3, list_index='DOCUMENT_TYPES') sequential_number = models.CharField( verbose_name="sequential Number", help_text=_('Select a four digit number'), default="0001", max_length=4, validators=[StringNumberValidator(4)]) system = ConfigurableChoiceField(verbose_name="System", list_index='SYSTEMS', null=True, blank=True) wbs = ConfigurableChoiceField(verbose_name="WBS", max_length=20, list_index='WBS', null=True, blank=True) weight = models.IntegerField(verbose_name="Weight", null=True, blank=True) # Related documents related_documents = models.ManyToManyField( 'documents.Document', related_name='cd_related_documents', blank=True) # Schedule status_std_planned_date = models.DateField( verbose_name="Status STD Planned Date", null=True, blank=True) status_std_forecast_date = models.DateField( verbose_name="Status STD Forecast Date", null=True, blank=True) status_std_actual_date = models.DateField( verbose_name="Status STD Actual Date", null=True, blank=True) status_idc_planned_date = models.DateField( verbose_name="Status IDC Planned Date", null=True, blank=True) status_idc_forecast_date = models.DateField( verbose_name="Status IDC Forecast Date", null=True, blank=True) status_idc_actual_date = models.DateField( verbose_name="Status IDC Actual Date", null=True, blank=True) status_ifr_planned_date = models.DateField( verbose_name="Status IFR Planned Date", null=True, blank=True) status_ifr_forecast_date = models.DateField( verbose_name="Status IFR Forecast Date", null=True, blank=True) status_ifr_actual_date = models.DateField( verbose_name="Status IFR Actual Date", null=True, blank=True) status_ifa_planned_date = models.DateField( verbose_name="Status IFA Planned Date", null=True, blank=True) status_ifa_forecast_date = models.DateField( verbose_name="Status IFA Forecast Date", null=True, blank=True) status_ifa_actual_date = models.DateField( verbose_name="Status IFA Actual Date", null=True, blank=True) status_ifd_planned_date = models.DateField( verbose_name="Status IFD Planned Date", null=True, blank=True) status_ifd_forecast_date = models.DateField( verbose_name="Status IFD Forecast Date", null=True, blank=True) status_ifd_actual_date = models.DateField( verbose_name="Status IFD Actual Date", null=True, blank=True) status_ifc_planned_date = models.DateField( verbose_name="Status IFC Planned Date", null=True, blank=True) status_ifc_forecast_date = models.DateField( verbose_name="Status IFC Forecast Date", null=True, blank=True) status_ifc_actual_date = models.DateField( verbose_name="Status IFC Actual Date", null=True, blank=True) status_ifi_planned_date = models.DateField( verbose_name="Status IFI Planned Date", null=True, blank=True) status_ifi_forecast_date = models.DateField( verbose_name="Status IFI Forecast Date", null=True, blank=True) status_ifi_actual_date = models.DateField( verbose_name="Status IFI Actual Date", null=True, blank=True) status_asb_planned_date = models.DateField( verbose_name="Status ASB Planned Date", null=True, blank=True) status_asb_forecast_date = models.DateField( verbose_name="Status ASB Forecast Date", null=True, blank=True) status_asb_actual_date = models.DateField( verbose_name="Status ASB Actual Date", null=True, blank=True) class PhaseConfig: filter_fields = ('docclass', 'status', 'unit', 'discipline', 'document_type', 'under_review', 'overdue', 'leader', 'approver') indexable_fields = ['is_existing', 'can_be_transmitted'] es_field_types = { 'overdue': 'boolean', } column_fields = ( ('', 'under_preparation_by'), ('Document Number', 'document_number'), ('Title', 'title'), ('Rev.', 'current_revision'), ('Status', 'status'), ('Class', 'docclass'), ('Unit', 'unit'), ('Discipline', 'discipline'), ('Document type', 'document_type'), ('Review start date', 'review_start_date'), ('Review due date', 'review_due_date'), ('Under review', 'under_review'), ('Overdue', 'overdue'), ('Leader', 'leader'), ('Approver', 'approver'), ('Final revision', 'final_revision'), ) transmittal_columns = { 'Document Number': 'document_key', 'Title': 'title', 'Contract Number': 'contract_number', 'Originator': 'originator', 'Unit': 'unit', 'Discipline': 'discipline', 'Document Type': 'document_type', 'Sequential Number': 'sequential_number', 'Class': 'docclass', 'Revision': 'revision', 'Status': 'status', 'Received Date': 'received_date', 'Created': 'created_on', } export_fields = OrderedDict(( ('Document number', 'document_number'), ('Title', 'title'), ('Revision', 'revision_name'), ('Revision date', 'revision_date'), ('Status', 'status'), ('Doc category', 'category'), ('Class', 'docclass'), ('Contract Number', 'contract_number'), ('Originator', 'originator'), ('Unit', 'unit'), ('Discipline', 'discipline'), ('Document type', 'document_type'), ('Sequential number', 'sequential_number'), ('System', 'system'), ('WBS', 'wbs'), ('Weight', 'weight'), ('Is final revision', 'final_revision'), ('Received date', 'received_date'), ('Created on', 'created_on'), ('Review start date', 'review_start_date'), ('Review due date', 'review_due_date'), ('Leader', 'leader'), ('Approver', 'approver'), ('Return code', 'return_code'), ('STD Planned', 'status_std_planned_date'), ('IDC Planned', 'status_idc_planned_date'), ('IFR Planned', 'status_ifr_planned_date'), ('IFA Planned', 'status_ifa_planned_date'), ('IFD Planned', 'status_ifd_planned_date'), ('IFC Planned', 'status_ifc_planned_date'), ('IFI Planned', 'status_ifi_planned_date'), ('ASB Planned', 'status_asb_planned_date'), ('STD Forecast', 'status_std_forecast_date'), ('IDC Forecast', 'status_idc_forecast_date'), ('IFR Forecast', 'status_ifr_forecast_date'), ('IFA Forecast', 'status_ifa_forecast_date'), ('IFD Forecast', 'status_ifd_forecast_date'), ('IFC Forecast', 'status_ifc_forecast_date'), ('IFI Forecast', 'status_ifi_forecast_date'), ('ASB Forecast', 'status_asb_forecast_date'), ('STD Actual', 'status_std_actual_date'), ('IFR Actual', 'status_ifr_actual_date'), ('IDC Actual', 'status_idc_actual_date'), ('IFA Actual', 'status_ifa_actual_date'), ('IFD Actual', 'status_ifd_actual_date'), ('IFC Actual', 'status_ifc_actual_date'), ('IFI Actual', 'status_ifi_actual_date'), ('ASB Actual', 'status_asb_actual_date'), )) custom_filters = OrderedDict((('show_cld_spd', { 'field': forms.BooleanField, 'label': _('Show CLD/SPD docs'), 'filters': { True: None, False: Q('term', is_existing=True), None: Q('term', is_existing=True) } }), ('outgoing_trs', { 'field': forms.BooleanField, 'label': _('Ready for outgoing TRS'), 'filters': { True: Q('term', can_be_transmitted=True), False: None, None: None, } }))) class Meta: ordering = ('document_number', ) app_label = 'default_documents' unique_together = (( "contract_number", "originator", "unit", "discipline", "document_type", "sequential_number", ), ) def natural_key(self): return self.document_key def generate_document_key(self): return slugify("{contract_number}-{originator}-{unit}-{discipline}-" "{document_type}-{sequential_number}".format( contract_number=self.contract_number, originator=self.originator.trigram, unit=self.unit, discipline=self.discipline, document_type=self.document_type, sequential_number=self.sequential_number)).upper() def get_all_revisions(self): """Return all revisions data of this document.""" revisions = super(ContractorDeliverable, self) \ .get_all_revisions() \ .select_related( 'metadata', 'metadata__document', 'metadata__document__category__organisation', 'leader', 'approver', 'approver') \ .prefetch_related('reviewers', 'transmittals__document') return revisions @property def status(self): return self.latest_revision.status @property def is_existing(self): return self.status not in ('CLD', 'SPD') @property def final_revision(self): return self.latest_revision.final_revision @property def review_start_date(self): return self.latest_revision.review_start_date @property def review_due_date(self): return self.latest_revision.review_due_date @property def under_review(self): return self.latest_revision.is_under_review() @property def overdue(self): return self.latest_revision.is_overdue() @property def leader(self): return self.latest_revision.leader @property def approver(self): return self.latest_revision.approver @property def docclass(self): return self.latest_revision.docclass @classmethod def get_batch_actions(cls, category, user): actions = super(ContractorDeliverable, cls).get_batch_actions(category, user) if user.has_perm('documents.can_control_document') and user.has_perm( 'reviews.can_add_review'): actions['start_review'] = MenuItem( 'start-review', _('Start review'), reverse('batch_start_reviews', args=[category.organisation.slug, category.slug]), ajax=True, modal='batch-review-modal', progression_modal=True, icon='eye-open', ) actions['cancel_review'] = MenuItem( 'cancel-review', _('Cancel review'), reverse('batch_cancel_reviews', args=[category.organisation.slug, category.slug]), ajax=True, modal='cancel-review-modal', progression_modal=True, icon='eye-close', ) if user.has_perm('transmittals.add_outgoingtransmittal'): actions['prepare_transmittal'] = MenuItem( 'prepare-transmittal', _('Prepare outgoing transmittal'), reverse('transmittal_prepare', args=[category.organisation.slug, category.slug]), ajax=False, progression_modal=False, icon='hand-up') actions['create_transmittal'] = MenuItem( 'create-transmittal', 'Create transmittal', reverse('transmittal_create', args=[category.organisation.slug, category.slug]), ajax=True, modal='create-transmittal-modal', progression_modal=True, icon='transfer', ) return actions @classmethod def get_batch_actions_modals(cls): templates = super(ContractorDeliverable, cls).get_batch_actions_modals() return templates + [ 'reviews/document_list_cancel_review_modal.html', 'reviews/document_list_batch_review_modal.html', 'transmittals/document_list_create_transmittal_modal.html' ]
class TransmittableMixin(ReviewMixin): """Define behavior of revisions that can be embedded in transmittals. Only reviewable documents can be transmitted, hence the mixin inheritance. """ PURPOSE_OF_ISSUE_CHOICES = Choices( ('FR', _('For review')), ('FI', _('For information'))) transmittal = models.ForeignKey( 'transmittals.OutgoingTransmittal', verbose_name='transmittal', null=True, blank=True, on_delete=models.SET_NULL) transmittals = models.ManyToManyField( 'transmittals.OutgoingTransmittal', verbose_name='transmittals', related_name="%(app_label)s_%(class)s_related" ) transmittal_sent_date = models.DateField( _('Transmittal sent date'), null=True, blank=True) trs_return_code = ConfigurableChoiceField( _('Final return code'), max_length=3, null=True, blank=True, list_index='REVIEW_RETURN_CODES') file_transmitted = PrivateFileField( _('File Transmitted'), null=True, blank=True, upload_to=file_transmitted_file_path) under_preparation_by = models.ForeignKey( 'accounts.User', verbose_name=_('Under preparation by'), related_name='+', null=True, blank=True) internal_review = models.BooleanField( _('Internal review only?'), choices=Choices( (False, 'No'), (True, 'Yes') ), default=False) purpose_of_issue = models.CharField( _('Purpose of issue'), max_length=2, blank=True, choices=PURPOSE_OF_ISSUE_CHOICES, default=PURPOSE_OF_ISSUE_CHOICES.FR) external_review_due_date = models.DateField( _('External due date'), null=True, blank=True) class Meta: abstract = True def get_final_return_code(self): """Returns the latest available return code.""" if self.trs_return_code: rc = self.trs_return_code elif hasattr(self, 'return_code'): rc = self.return_code else: rc = '' return rc @property def can_be_reviewed(self): """Can this revision be reviewed. A revision that was already embedded in a transmittal cannot be reviewed anymore. """ # Turns out, simply calling "self.can_be_transmitted" leads to # an infinite recursion error. return all(( super(TransmittableMixin, self).can_be_reviewed, not self.transmittals.count())) @property def can_be_transmitted(self): """Is this rev ready to be embedded in an outgoing trs?""" return all(( not self.internal_review, not self.transmittals.count(), self.document.current_revision == self.revision)) @property def last_review_closed(self): return all(( not self.internal_review, self.document.current_revision == self.revision, self.review_end_date)) def can_be_transmitted_to_recipient(self, recipient): """Is this rev ready to be embedded in an outgoing trs?""" return all(( not self.internal_review, recipient not in [trs.recipient for trs in self.transmittals.all()], self.document.current_revision == self.revision)) def get_initial_empty(self): """New revision initial data that must be empty.""" empty_fields = super(TransmittableMixin, self).get_initial_empty() return empty_fields + ( 'trs_return_code', 'file_transmitted', 'external_review_due_date', )
class OutgoingTransmittal(Metadata): """Represents an outgoing transmittal. In the end, Transmittal and OutgoingTransmittal should be refactored into a single class. However, the incoming trs class contains too much specific code and is kept isolated for now. """ EXTERNAL_REVIEW_DURATION = 13 latest_revision = models.ForeignKey( 'OutgoingTransmittalRevision', null=True, verbose_name=_('Latest revision')) revisions_category = models.ForeignKey( 'categories.Category', verbose_name=_('From category'), on_delete=models.PROTECT) # We'll have to delete it, but let's keep this one for a while contract_number_old = ConfigurableChoiceField( verbose_name='Contract Number', max_length=8, list_index='CONTRACT_NBS', null=True, blank=True ) contract_number = models.CharField( verbose_name='Contract Number', max_length=50) originator = models.CharField( _('Originator'), max_length=3) recipient = models.ForeignKey( 'accounts.Entity', verbose_name=_('Recipient')) sequential_number = models.PositiveIntegerField( _('sequential number'), null=True, blank=True) ack_of_receipt_date = models.DateField( _('Acknowledgment of receipt date'), null=True, blank=True) ack_of_receipt_author = models.ForeignKey( 'accounts.User', verbose_name=_('Acknowledgment of receipt author'), null=True, blank=True, on_delete=models.PROTECT) archived_pdf = OgtFileField( verbose_name=_("Archived PDF"), null=True, blank=True) class Meta: app_label = 'transmittals' ordering = ('document_number',) verbose_name = _('Outgoing transmittal') verbose_name_plural = _('Outgoing transmittals') class PhaseConfig: filter_fields = ('recipient', 'ack_of_receipt') custom_filters = OrderedDict(( ('has_errors', { 'field': forms.ChoiceField, 'field_kwargs': { 'choices': ( ('', '----------'), ('true', 'Yes'), ('false', 'No'), ) }, 'label': _('Has errors?'), 'filters': { '': None, 'true': Q('term', has_error=True), 'false': Q('term', has_error=False) } }),) ) column_fields = ( ('Reference', 'document_number'), ('Created', 'created_on'), ('Originator', 'originator'), ('Recipient', 'recipient'), ('Acknowledgment of receipt', 'ack_of_receipt'), ('Has error', 'has_error'), ) export_fields = OrderedDict(( ('Document number', 'document_number'), ('Contract Number', 'contract_number'), ('Originator', 'originator'), ('Recipient', 'recipient'), ('Ack. of receipt', 'ack_of_receipt'), ('Ack. of receipt date', 'ack_of_receipt_date'), ('Ack. of receipt author', 'ack_of_receipt_author'), ('Revision', 'revision_name'), ('Has error', 'has_error'), )) def __str__(self): return self.document_key def get_revisions(self): _class = self.get_revisions_class() revisions = _class.objects.filter(transmittals=self).select_related() return revisions def get_last_revisions(self): """This is ugly. Should find a better way. Return last revision of each linked document """ revisions = self.get_revisions() docs = [rev.metadata for rev in revisions] last_revs = [doc.get_all_revisions().first() for doc in docs] return list(set(last_revs)) def get_revisions_class(self): return self.revisions_category.revision_class() @property def ack_of_receipt(self): return bool(self.ack_of_receipt_date) def get_ack_of_receipt_display(self): return 'Yes' if self.ack_of_receipt else 'No' get_ack_of_receipt_display.short_description = 'Acknowledgment of receipt' def generate_document_key(self): key = '{}-{}-{}-TRS-{:0>5d}'.format( self.contract_number, self.originator, self.recipient.trigram, self.sequential_number) return key @property def title(self): return self.document_key @classmethod def get_document_download_form(cls, data, queryset): from transmittals.forms import TransmittalDownloadForm return TransmittalDownloadForm(data, queryset=queryset) @classmethod def compress_documents(cls, documents, **kwargs): """See `documents.models.Metadata.compress_documents`""" content = kwargs.get('content', 'transmittal') revisions = kwargs.get('revisions', 'latest') temp_file = tempfile.TemporaryFile() with zipfile.ZipFile(temp_file, mode='w') as zip_file: for document in documents: dirname = document.document_key revision = document.get_latest_revision() # Should we embed the transmittal pdf? if content in ('transmittal', 'both'): # All revisions or just the latest? if revisions == 'latest': revs = [revision] elif revisions == 'all': revs = document.get_all_revisions() # Embed the file in the zip archive for rev in revs: pdf_file = rev.pdf_file pdf_basename = os.path.basename(pdf_file.name) # Avoiding to break export process if not pdf_file: continue # If file is gone, don't break export process try: zip_file.write( pdf_file.path, '{}/{}'.format(dirname, pdf_basename), compress_type=zipfile.ZIP_DEFLATED) except OSError: logger.warning( 'File: {} missing in export process'.format(pdf_file)) # Should we embed review comments? if content in ('revisions', 'both'): meta = document.get_metadata() exported_revs = meta.get_revisions() for rev in exported_revs: if rev.file_transmitted: comments_file = rev.file_transmitted comments_basename = os.path.basename(comments_file.path) zip_file.write( comments_file.path, '{}/{}/{}'.format( dirname, rev.document.document_key, comments_basename), compress_type=zipfile.ZIP_DEFLATED) return temp_file def link_to_revisions(self, revisions): """Set the given revisions as related documents. The revisions MUST be valid: - belong to the same category - be transmittable objects """ ids = [] index_data = [] for revision in revisions: ids.append(revision.id) # Update ES index to make sure the "can_be_transmitted" # filter is up to date index_datum = build_index_data(revision) index_datum['_source']['can_be_transmitted'] = False index_datum['_source']['last_review_closed'] = False index_data.append(index_datum) with transaction.atomic(): today = timezone.now() later = today + datetime.timedelta(days=self.EXTERNAL_REVIEW_DURATION) # Mark revisions as transmitted Revision = type(revisions[0]) Revision.objects \ .filter(id__in=ids) \ .update( transmittal=self, transmittal_sent_date=timezone.now(), external_review_due_date=Case( When(purpose_of_issue='FR', then=Value(later)), When(purpose_of_issue='FI', then=Value(None)), )) for rev in Revision.objects.filter(id__in=ids): rev.transmittals.add(self) bulk_actions(index_data) @classmethod def get_batch_actions(cls, category, user): actions = super(OutgoingTransmittal, cls).get_batch_actions( category, user) if user.is_external: actions['ack_transmittals'] = MenuItem( 'ack-transmittals', _('Ack receipt'), reverse('transmittal_batch_ack_of_receipt', args=[ category.organisation.slug, category.slug]), ajax=False, icon='eye-open', ) return actions @classmethod def get_batch_actions_modals(cls): """Returns a list of templates used in batch actions.""" return ['transmittals/document_list_download_modal.html', 'documents/document_list_export_modal.html'] def ack_receipt(self, user, save=True): """Acknowledge receipt of this transmittal.""" self.ack_of_receipt_date = timezone.now().date() self.ack_of_receipt_author = user if save: self.save()
class Transmittal(Metadata): """Represents and incoming transmittal. Transmittals are created when a contractor upload documents. """ STATUSES = Choices( ('new', _('New')), ('invalid', _('Invalid')), ('tobechecked', _('To be checked')), ('rejected', _('Rejected')), ('processing', _('Processing')), ('accepted', _('Accepted')), ) latest_revision = models.ForeignKey( 'TransmittalRevision', null=True, verbose_name=_('Latest revision')) transmittal_key = models.CharField( _('Transmittal key'), max_length=250) # General informations transmittal_date = models.DateField( _('Transmittal date'), null=True, blank=True) ack_of_receipt_date = models.DateField( _('Acknowledgment of receipt date'), null=True, blank=True) # We'll keep it for a while contract_number_old = ConfigurableChoiceField( verbose_name='Contract Number', max_length=8, list_index='CONTRACT_NBS', null=True, blank=True) contract_number = models.CharField( verbose_name='Contract Number', max_length=50) originator = ConfigurableChoiceField( _('Originator'), default='CTR', max_length=3, list_index='ORIGINATORS') recipient = ConfigurableChoiceField( _('Recipient'), max_length=50, list_index='RECIPIENTS') sequential_number = models.PositiveIntegerField( _('sequential number'), null=True, blank=True) document_type = ConfigurableChoiceField( _('Document Type'), default="PID", max_length=3, list_index='DOCUMENT_TYPES') status = models.CharField( max_length=20, choices=STATUSES, default=STATUSES.tobechecked) # Related documents related_documents = models.ManyToManyField( 'documents.Document', related_name='transmittals_related_set', blank=True) contractor = models.CharField(max_length=255, null=True, blank=True) tobechecked_dir = models.CharField(max_length=255, null=True, blank=True) accepted_dir = models.CharField(max_length=255, null=True, blank=True) rejected_dir = models.CharField(max_length=255, null=True, blank=True) class Meta: app_label = 'transmittals' ordering = ('document_number',) verbose_name = _('Transmittal') verbose_name_plural = _('Transmittals') index_together = ( ('contract_number', 'originator', 'recipient', 'sequential_number', 'status'), ) class PhaseConfig: filter_fields = ( 'originator', 'recipient', 'status', ) column_fields = ( ('Reference', 'document_number'), ('Transmittal date', 'transmittal_date'), ('Acknowledgment of receipt', 'ack_of_receipt_date'), ('Originator', 'originator'), ('Recipient', 'recipient'), ('Document type', 'document_type'), ('Status', 'status'), ) def __str__(self): return self.document_key def save(self, *args, **kwargs): if not self.transmittal_key: if not self.document_key: self.document_key = self.generate_document_key() self.transmittal_key = self.document_key super(Transmittal, self).save(*args, **kwargs) @property def full_tobechecked_name(self): return os.path.join(self.tobechecked_dir, self.transmittal_key) @property def full_accepted_name(self): return os.path.join(self.accepted_dir, self.transmittal_key) @property def full_rejected_name(self): return os.path.join(self.rejected_dir, self.transmittal_key) def generate_document_key(self): key = '{}-{}-{}-TRS-{:0>5d}'.format( self.contract_number, self.originator, self.recipient, self.sequential_number) return key @property def title(self): return self.document_key @transaction.atomic def reject(self): """Mark the transmittal as rejected. Upon rejecting the transmittal, we must perform the following operations: - update the transmittal status in db - move the corresponding files in the correct "refused" directory - send the notifications to the email list. """ # Only transmittals with a pending validation can be refused if self.status != 'tobechecked': error_msg = 'The transmittal {} cannot be rejected ' \ 'it it\'s current status ({})'.format( self.document_key, self.status) raise RuntimeError(error_msg) # If an existing version already exists in rejected, we delete it before if os.path.exists(self.full_rejected_name): # Let's hope we got correct data and the process does not run # as root. Who would do something that stupid anyway? logger.info('Deleteting directory {}'.format(self.full_rejected_name)) shutil.rmtree(self.full_rejected_name) # Move to rejected directory if os.path.exists(self.full_tobechecked_name): try: os.rename(self.full_tobechecked_name, self.full_rejected_name) except OSError as e: logger.error('Cannot reject transmittal {} ({})'.format( self, e)) raise e else: # If the directory cannot be found in tobechecked, that's weird but we # won't trigger an error logger.warning('Transmittal {} files are gone'.format(self)) # Since the document_key "unique" constraint is enforced in the parent # class (Metadata), we need to update this object's key to allow a # new transmittal submission with the same transmittal key. new_key = '{}-{}'.format( self.document_key, uuid.uuid4()) self.document_key = new_key self.status = 'rejected' self.save() self.document.document_key = new_key self.document.save() def accept(self): """Starts the transmittal import process. Since the import can be quite long, we delegate the work to a celery task. """ from transmittals.tasks import process_transmittal if self.status != 'tobechecked': error_msg = 'The transmittal {} cannot be accepted ' \ 'in it\'s current state ({})'.format( self.document_key, self.get_status_display()) raise RuntimeError(error_msg) self.status = 'processing' self.save() process_transmittal.delay(self.pk)
class TrsRevision(models.Model): """Stores data imported from a single line in the csv.""" transmittal = models.ForeignKey( Transmittal, verbose_name=_('Transmittal')) document = models.ForeignKey( Document, null=True, blank=True, verbose_name=_('Document')) document_key = models.SlugField( _('Document number'), max_length=250) category = models.ForeignKey('categories.Category') title = models.TextField( verbose_name=_('Title')) revision = models.PositiveIntegerField( verbose_name=_('Revision'), default=0) revision_date = models.DateField( _('Revision date'), null=True, blank=True) received_date = models.DateField( _('Received date'), null=True, blank=True) created_on = models.DateField( _('Created on'), null=True, blank=True) accepted = models.NullBooleanField( verbose_name=_('Accepted?')) comment = models.TextField( verbose_name=_('Comment'), null=True, blank=True) is_new_revision = models.BooleanField( _('Is new revision?')) # We'll keep it for a while. # Those are fields that will one day be configurable # but are static for now. contract_number_old = ConfigurableChoiceField( verbose_name='Contract Number', max_length=8, list_index='CONTRACT_NBS', null=True, blank=True) contract_number = models.CharField( verbose_name='Contract Number', max_length=50) originator = models.ForeignKey( 'accounts.Entity', verbose_name=_('Originator')) unit = ConfigurableChoiceField( verbose_name=_('Unit'), default='000', max_length=3, list_index='UNITS') discipline = ConfigurableChoiceField( verbose_name=_('Discipline'), default='PCS', max_length=3, list_index='DISCIPLINES') document_type = ConfigurableChoiceField( verbose_name=_('Document Type'), default='PID', max_length=3, list_index='DOCUMENT_TYPES') sequential_number = models.CharField( verbose_name="sequential Number", help_text=_('Select a four digit number'), default="0001", max_length=4, validators=[StringNumberValidator(4)], null=True, blank=True) system = ConfigurableChoiceField( _('System'), list_index='SYSTEMS', null=True, blank=True) wbs = ConfigurableChoiceField( _('Wbs'), max_length=20, list_index='WBS', null=True, blank=True) weight = models.IntegerField( _('Weight'), null=True, blank=True) docclass = models.IntegerField( verbose_name=_('Class'), default=1, choices=CLASSES) status = ConfigurableChoiceField( verbose_name=_('Status'), default='STD', max_length=3, list_index='STATUSES', null=True, blank=True) return_code = models.PositiveIntegerField( _('Return code'), null=True, blank=True) review_start_date = models.DateField( _('Review start date'), null=True, blank=True) review_due_date = models.DateField( _('Review due date'), null=True, blank=True) review_leader = models.CharField( _('Review leader'), max_length=150, null=True, blank=True) leader_comment_date = models.DateField( _('Leader comment date'), null=True, blank=True) review_approver = models.CharField( _('Review approver'), max_length=150, null=True, blank=True) approver_comment_date = models.DateField( _('Approver comment date'), null=True, blank=True) review_trs = models.CharField( verbose_name=_('Review transmittal name'), max_length=255, null=True, blank=True) review_trs_status = models.CharField( verbose_name=_('Review transmittal status'), max_length=50, null=True, blank=True) outgoing_trs = models.CharField( verbose_name=_('Outgoing transmittal name'), max_length=255, null=True, blank=True) outgoing_trs_status = models.CharField( verbose_name=_('Outgoing transmittal status'), max_length=50, null=True, blank=True) outgoing_trs_sent_date = models.DateField( verbose_name=_('Outgoing transmittal sent date'), null=True, blank=True) doc_category = models.CharField( max_length=50, verbose_name=_('Doc category')) pdf_file = TransmittalFileField( verbose_name=_('Pdf file')) native_file = TransmittalFileField( verbose_name=_('Native file'), null=True, blank=True, max_length=255) class Meta: app_label = 'transmittals' verbose_name = _('Trs Revision') verbose_name_plural = _('Trs Revisions') unique_together = ('transmittal', 'document_key', 'revision') def __str__(self): return '{} ({:02d})'.format(self.document_key, self.revision) def get_absolute_url(self): return reverse('transmittal_revision_diff', args=[ self.transmittal.pk, self.transmittal.document_key, self.document_key, self.revision]) def get_document_fields(self): """Return a dict of fields that will be passed to the document form.""" columns = self.category.get_transmittal_columns() fields = list(columns.values()) fields_dict = dict([(field, getattr(self, field)) for field in fields]) # XXX # This is a HACK and should be refactored somehow fields_dict.update({ 'originator': self.originator.id, 'sequential_number': '{:04}'.format(int(self.sequential_number)) }) files_dict = { 'native_file': self.native_file, 'pdf_file': self.pdf_file} return fields_dict, files_dict def save_to_document(self): """Use self data to create / update the corresponding revision.""" fields, files = self.get_document_fields() kwargs = { 'category': self.category, 'data': fields, 'files': files } # The document was created earlier during # the batch import if self.document is None and self.revision > 0: self.document = Document.objects.get(document_key=self.document_key) metadata = getattr(self.document, 'metadata', None) kwargs.update({'instance': metadata}) Form = self.category.get_metadata_form_class() metadata_form = Form(**kwargs) # If there is no such revision, the method will return None # which is fine. revision = metadata.get_revision(self.revision) if metadata else None kwargs.update({'instance': revision}) RevisionForm = self.category.get_revision_form_class() revision_form = RevisionForm(**kwargs) doc, meta, rev = save_document_forms( metadata_form, revision_form, self.category) # Performs custom import action rev.post_trs_import(self)
class Review(models.Model): # Yes, two statuses with the same label. # See Trello#173 STATUSES = Choices( ('void', ''), # Only for dummy review in document form ('pending', _('Pending')), ('progress', _('In progress')), ('reviewed', _('Reviewed')), ('commented', _('Reviewed')), ('not_reviewed', _('Not reviewed')), ) STEPS = Choices( ('pending', ''), ('reviewer', _('Reviewer')), ('leader', _('Leader')), ('approver', _('Approver')), ('closed', _('Closed')), ) ROLES = Choices( ('reviewer', _('Reviewer')), ('leader', _('Leader')), ('approver', _('Approver')), ) reviewer = models.ForeignKey( User, verbose_name=_('User'), ) role = models.CharField(_('Role'), max_length=8, choices=ROLES, default=ROLES.reviewer) document = models.ForeignKey(Document, verbose_name=_('Document')) revision = models.PositiveIntegerField(_('Revision')) received_date = models.DateField(_('Review received date'), null=True, blank=True) start_date = models.DateField(_('Review start date'), null=True, blank=True) due_date = models.DateField(_('Review due date'), null=True, blank=True) docclass = models.IntegerField(verbose_name=u"Class", default=1, choices=CLASSES) status = models.CharField(_('Status'), max_length=30, choices=STATUSES, default=STATUSES.pending) revision_status = models.CharField(_('Revision status'), max_length=30, null=True, blank=True) closed_on = models.DateTimeField(_('Closed on'), null=True, blank=True) amended_on = models.DateTimeField(_('Amended on'), null=True, blank=True) comments = PrivateFileField(_('Comments'), null=True, blank=True, upload_to=review_comments_file_path) return_code = ConfigurableChoiceField(_('Return code'), max_length=3, null=True, blank=True, list_index='REVIEW_RETURN_CODES') created_on = models.DateTimeField(_('Created on'), default=timezone.now) class Meta: verbose_name = _('Review') verbose_name_plural = _('Reviews') index_together = (('reviewer', 'document', 'revision', 'role'), ) unique_together = ('reviewer', 'document', 'revision') app_label = 'reviews' def save(self, *args, **kwargs): cache_key = 'all_reviews_{}'.format(self.document_id) cache.delete(cache_key) super(Review, self).save(*args, **kwargs) @property def revision_name(self): return '%02d' % self.revision def post_review(self, comments, return_code=None, save=True): self.comments = comments self.return_code = return_code if self.closed_on is None: self.closed_on = timezone.now() else: self.amended_on = timezone.now() if comments: self.status = self.STATUSES.commented else: self.status = self.STATUSES.reviewed if save: self.save() def is_overdue(self): """Tells if the review is overdue. A review is overdue only if it's ongoing (ended reviews cannot be overdue) and the due date is past. """ today = timezone.now().date() return self.due_date < today and self.closed_on is None def days_of_delay(self): """Gets the number of days between the due date and the review end. If the review has ended, returns delay between the due date and the completion date. If the review is ongoing, returns delay between the due date and the present day. """ if self.closed_on: checked_date = self.closed_on else: checked_date = timezone.now().date() delta = checked_date - self.due_date return delta.days def get_comments_url(self): return reverse( 'download_review_comments', args=[self.document.document_key, self.revision, self.id])
class ReviewMixin(models.Model): """A Mixin to use to define reviewable document types. The review duration is configurable via a tuple matching the CLASSES tuple. The duration is extracted. REVIEW_DURATIONS = ( (1, 4), (2, 8), (3, 13), (4, 13), ) e.g: if docclass field value is 2 then duration is 8, etc. """ review_start_date = models.DateField(_('Review start date'), null=True, blank=True) review_due_date = models.DateField(_('Review due date'), null=True, blank=True) reviewers_step_closed = models.DateField(_('Reviewers step closed'), null=True, blank=True) leader_step_closed = models.DateField(_('Leader step closed'), null=True, blank=True) review_end_date = models.DateField(_('Review end date'), null=True, blank=True) reviewers = models.ManyToManyField(User, verbose_name=_('Reviewers'), blank=True) leader = models.ForeignKey( User, verbose_name=_('Leader'), related_name='%(app_label)s_%(class)s_related_leader', null=True, blank=True) approver = models.ForeignKey( User, verbose_name=_('Approver'), related_name='%(app_label)s_%(class)s_related_approver', null=True, blank=True) docclass = models.IntegerField(verbose_name=u"Class", default=1, choices=CLASSES) return_code = ConfigurableChoiceField(_('Return code'), max_length=3, null=True, blank=True, list_index='REVIEW_RETURN_CODES') class Meta: abstract = True def save(self, *args, **kwargs): cache_key = 'all_reviews_{}'.format(self.metadata.document_id) cache.delete(cache_key) super(ReviewMixin, self).save(*args, **kwargs) @cached_property def can_be_reviewed(self): """Is this revision ready to be reviewed. A revision can only be reviewed if at least a leader was defined. Also, a revision can only be reviewed once. """ return all( (self.received_date, self.leader_id, not self.review_start_date)) @transaction.atomic def start_review(self, at_date=None, due_date=None): """Starts the review process. This methods initiates the review process. We don't check whether the document can be reviewed or not, or if the process was already initiated. It's up to the developer to perform those checks before calling this method. """ start_date = at_date or timezone.now() self.review_start_date = start_date duration = self.get_review_duration() self.review_due_date = due_date or \ self.received_date + \ datetime.timedelta(days=duration) reviewers = self.reviewers.all() for reviewer in reviewers: Review.objects.create(reviewer=reviewer, document=self.document, revision=self.revision, received_date=self.received_date, start_date=start_date, due_date=self.review_due_date, docclass=self.docclass, status='progress', revision_status=self.status) # If no reviewers, close reviewers step immediatly if len(reviewers) == 0: self.reviewers_step_closed = start_date leader_review_status = 'progress' else: leader_review_status = 'pending' # Leader is mandatory, no need to test it Review.objects.create(reviewer_id=self.leader_id, role=Review.ROLES.leader, document=self.document, revision=self.revision, received_date=self.received_date, start_date=start_date, due_date=self.review_due_date, status=leader_review_status, docclass=self.docclass, revision_status=self.status) # Approver is not mandatory if self.approver_id: Review.objects.create(reviewer_id=self.approver_id, role=Review.ROLES.approver, document=self.document, revision=self.revision, received_date=self.received_date, start_date=start_date, due_date=self.review_due_date, status=leader_review_status, docclass=self.docclass, revision_status=self.status) self.reload_reviews() self.save(update_document=True) @transaction.atomic def cancel_review(self): """Stops the review process. This methods reverts the "start_review" process. It simply deletes all data related to the current review, and leaves the document in the state it was before starting the review. This method can cause data loss. """ Review.objects \ .filter(document=self.document) \ .filter(revision=self.revision) \ .delete() self.review_start_date = None self.review_due_date = None self.review_end_date = None self.reviewers_step_closed = None self.leader_step_closed = None self.save(update_document=True) from reviews.signals import review_canceled review_canceled.send(sender=self.__class__, instance=self) @transaction.atomic def end_reviewers_step(self, at_date=None, save=True): """Ends the first step of the review.""" end_date = at_date or timezone.now() self.reviewers_step_closed = end_date Review.objects \ .filter(document=self.document) \ .filter(revision=self.revision) \ .filter(role=Review.ROLES.reviewer) \ .filter(closed_on=None) \ .update(closed_on=end_date, status='not_reviewed') Review.objects \ .filter(document=self.document) \ .filter(revision=self.revision) \ .filter(role=Review.ROLES.leader) \ .update(status='progress') self.reload_reviews() if save: self.save(update_document=True) @transaction.atomic def end_leader_step(self, at_date=None, save=True): """Ends the second step of the review. Also ends the first step if it wasn't already done. """ if self.reviewers_step_closed is None: self.end_reviewers_step(save=False, at_date=at_date) end_date = at_date or timezone.now() self.leader_step_closed = end_date Review.objects \ .filter(document=self.document) \ .filter(revision=self.revision) \ .filter(role=Review.ROLES.leader) \ .filter(closed_on=None) \ .update(closed_on=end_date, status='not_reviewed') Review.objects \ .filter(document=self.document) \ .filter(revision=self.revision) \ .filter(role=Review.ROLES.approver) \ .update(status='progress') if not self.approver_id: self.review_end_date = end_date self.reload_reviews() if save: self.save(update_document=True) @transaction.atomic def send_back_to_leader_step(self, save=True): """Send the review back to the leader step.""" self.leader_step_closed = None Review.objects \ .filter(document=self.document) \ .filter(revision=self.revision) \ .filter(role=Review.ROLES.leader) \ .update(closed_on=None, status='progress') self.reload_reviews() if save: self.save(update_document=True) @transaction.atomic def end_review(self, at_date=None, save=True): """Ends the review. Also ends the steps before. """ if self.leader_step_closed is None: self.end_leader_step(save=False, at_date=at_date) end_date = at_date or timezone.now() self.review_end_date = end_date Review.objects \ .filter(document=self.document) \ .filter(revision=self.revision) \ .filter(role=Review.ROLES.approver) \ .filter(closed_on=None) \ .update(closed_on=end_date, status='not_reviewed') self.reload_reviews() if save: self.save(update_document=True) @transaction.atomic def sync_reviews(self): """Update Review objects so it's coherent with current object state. If the distribution list (reviewers, leader, approver) was modified in the document form, the corresponding Review objects must be created / deleted to stay in sync. """ # Sync leader leader_review = self.get_leader_review() if leader_review.reviewer_id != self.leader_id: leader_review.reviewer = self.leader leader_review.save() # Sync approver # Several cases here # * an existing approver review was deleted # * an existing approver review was modified # * an approver review was created approver_review = self.get_approver_review() if approver_review: if approver_review.reviewer_id != self.approver_id: # A new approver was submitted if self.approver: approver_review.reviewer = self.approver approver_review.save() # The approver was deleted else: approver_review.delete() # If we were at the last review step, end the review completely if self.is_at_review_step(Review.STEPS.approver): self.end_review() else: if self.approver: Review.objects.create(reviewer=self.approver, role=Review.ROLES.approver, document=self.document, revision=self.revision, received_date=self.received_date, start_date=self.review_start_date, due_date=self.review_due_date, docclass=self.docclass, status='pending', revision_status=self.status) # Sync reviewers old_reviews = self.get_reviewers_reviews() old_reviewers = set(review.reviewer for review in old_reviews) current_reviewers = set(self.reviewers.all()) # Create Review objects for new reviewers new_reviewers = current_reviewers - old_reviewers for reviewer in new_reviewers: Review.objects.create(reviewer=reviewer, document=self.document, revision=self.revision, received_date=self.received_date, start_date=self.review_start_date, due_date=self.review_due_date, docclass=self.docclass, status='progress', revision_status=self.status) # Remove Review objects for deleted reviewers deleted_reviewers = old_reviewers - current_reviewers if len(deleted_reviewers) > 0: for reviewer in deleted_reviewers: review = self.get_review(reviewer) # Check that we only delete review with no comments # This condition is enforced in the ReviewMixinForm anyway if review.status != 'progress': raise RuntimeError('Cannot delete a review with comments') review.delete() # Should we end the reviewers step? waiting_reviews = self.get_filtered_reviews( lambda rev: rev.id and rev.role == 'reviewer' and rev.status == 'progress') if len(waiting_reviews) == 0: self.end_reviewers_step() self.reload_reviews() def is_under_review(self): """It's under review only if review has started but not ended.""" return bool(self.review_start_date) != bool(self.review_end_date) is_under_review.short_description = _('Under review') def is_overdue(self): """Tells if the review is overdue. A review is overdue only if it's ongoing (ended reviews cannot be overdue) and the due date is past. """ today = timezone.now().date() return self.is_under_review() and self.review_due_date < today is_overdue.short_description = _('Overdue') def current_review_step(self): """Return a string representing the current step.""" if self.review_start_date is None: return Review.STEPS.pending if self.reviewers_step_closed is None: return Review.STEPS.reviewer if self.leader_step_closed is None: return Review.STEPS.leader if self.review_end_date is None: return Review.STEPS.approver return Review.STEPS.closed current_review_step.short_description = _('Current review step') def get_current_review_step_display(self): step = self.current_review_step() return dict(Review.STEPS)[step] get_current_review_step_display.short_description = _( 'Current review step') def is_at_review_step(self, step): return step == self.current_review_step() def get_reviews(self): """Get all reviews associated with this revision.""" if not hasattr(self, '_reviews'): qs = Review.objects \ .filter(document=self.document) \ .filter(revision=self.revision) \ .order_by('id') \ .select_related('reviewer') self._reviews = qs return self._reviews def reload_reviews(self): """Reload the review cache.""" if hasattr(self, '_reviews'): del self._reviews def get_review_duration(self): """We can define `REVIEW_DURATIONS` tuple in class attributes. Then we return the duration matched by `docclass` field. If `REVIEW_DURATIONS` is not present, the global value `REVIEW_DURATION` is in settings.""" # If REVIEW_DURATIONS is not defined we get the settings value if not hasattr(self, 'REVIEW_DURATIONS'): return settings.REVIEW_DURATION review_durations = dict(self.REVIEW_DURATIONS) # We try to get the duration from the tuple review_duration = review_durations.get(self.docclass, None) if review_duration is None: raise ImproperlyConfigured( 'Define {0}.REVIEW_DURATIONS to match reviews.models.CLASSES' 'or define settings.LOGIN_URL or ' 'override {0}.get_review_duration().'.format( self.__class__.__name__)) return review_duration def get_review(self, user): """Get the review from this specific user.""" reviews = self.get_reviews() rev = next((rev for rev in reviews if rev.reviewer == user), None) return rev def get_filtered_reviews(self, filter): reviews = self.get_reviews() filtered = [rev for rev in reviews if filter(rev)] return filtered def get_reviewers_reviews(self): reviews = self.get_reviews() return [rev for rev in reviews if rev.role == 'reviewer'] def get_leader_review(self): reviews = self.get_reviews() rev = next((rev for rev in reviews if rev.role == 'leader'), None) return rev def get_approver_review(self): reviews = self.get_reviews() rev = next((rev for rev in reviews if rev.role == 'approver'), None) return rev def is_reviewer(self, user): return user in self.reviewers.all() def get_initial_ignored_fields(self): ignored = ( 'review_start_date', 'review_due_date', ) return super(ReviewMixin, self).get_initial_ignored_fields() + ignored def get_new_revision_initial(self, form): initial = super(ReviewMixin, self).get_new_revision_initial(form) initial.update({ 'leader': self.leader_id, 'approver': self.approver_id, 'reviewers': self.reviewers.values_list('id', flat=True), }) return initial def post_trs_import(self, trs_revision): """See `documents.models.MetadataRevision.post_trs_import` If we are importing a revision with review data, we need to make sure Phase objects are left in a consistent state. We need to create `Review` objects if the leader and approver review data is set in the trs_revision object. """ category = self.document.category user_qs = User.objects.filter(categories=category) # Is there a defined leader? if trs_revision.review_leader: self.leader = user_qs.get(name=trs_revision.review_leader) # Is there a defined approver if trs_revision.review_approver: self.approver = user_qs.get(name=trs_revision.review_approver) # Was the review started? if trs_revision.review_start_date: self.start_review(at_date=trs_revision.review_start_date, due_date=trs_revision.review_due_date) # Did the leader already submit a comment? if trs_revision.leader_comment_date: self.end_leader_step(at_date=trs_revision.leader_comment_date, save=False) # Is there an approver and did he submit a comment? if self.approver_id and trs_revision.approver_comment_date: self.end_review(at_date=trs_revision.approver_comment_date, save=False) self.save() def detail_view_context(self, request): """@see `MetadataRevision.detail_view_context`""" context = super(ReviewMixin, self).detail_view_context(request) user_review = self.get_review(request.user) review_closed_on = user_review.closed_on if user_review else None context.update({ 'user_review': user_review, 'review_closed_on': review_closed_on }) return context def get_review_fields(self): """Return data to display on the review form.""" fields = [ (_('Category'), self.document.category), (_('Document number'), self.document.document_number), (_('Title'), self.document.title), (_('Revision'), self.name), (_('Status'), self.status), ] return fields def get_actions(self, metadata, user): actions = super(ReviewMixin, self).get_actions(metadata, user) category = self.document.category if self.is_under_review(): if user.has_perm('documents.can_control_document'): actions.insert( -3, MenuItem('cancel-review', _('Cancel review'), reverse('document_cancel_review', args=[self.document.document_key]), modal='cancel-review-modal')) user_review = self.get_review(user) review_closed_on = user_review.closed_on if user_review else None if review_closed_on: actions.insert( -3, MenuItem( 'update-comment', _('Modify your comment'), reverse('review_document', args=[self.document.document_key]), method='GET', )) else: # revision is not under review if self.can_be_reviewed and \ user.has_perm('documents.can_control_document'): actions.insert( -3, MenuItem( 'start-review', _('Start review'), reverse('document_start_review', args=[ category.organisation.slug, category.slug, self.document.document_key ]), )) actions.insert( -3, MenuItem('start-review-remark', _('Start review w/ remark'), reverse('document_start_review', args=[ category.organisation.slug, category.slug, self.document.document_key ]), modal='start-comment-review')) return actions