diff --git a/README.md b/README.md index 56cf5961..e5480a64 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,6 @@ DATABASE_URL="postgis://test:my_passwd@localhost:5432/boranga_dev" LEDGER_DATABASE_URL='postgis://test:my_passwd@localhost:5432/boranga_dev' LEDGER_API_URL="http://localhost:8000" LEDGER_API_KEY="ledger_api_key__from__ledger_api_admin" -SYSTEM_GROUPS=['Boranga Admin'] SITE_PREFIX='boranga-dev' SITE_DOMAIN='dbca.wa.gov.au' SECRET_KEY='SECRET_KEY_YO' @@ -94,7 +93,6 @@ DJANGO_HTTPS=True DEFAULT_FROM_EMAIL='no-reply@dbca.wa.gov.au' ALLOWED_HOSTS=['*'] DEV_APP_BUILD_URL="http://localhost:8080/app.js" -CONSOLE_EMAIL_BACKEND=True ``` diff --git a/boranga/components/conservation_status/email.py b/boranga/components/conservation_status/email.py index f9e61615..0f2986bd 100644 --- a/boranga/components/conservation_status/email.py +++ b/boranga/components/conservation_status/email.py @@ -470,12 +470,18 @@ def send_approver_approve_email_notification(request, conservation_status): return msg -def send_assessor_ready_for_agenda_email_notification(request, conservation_status): +def send_assessor_ready_for_agenda_email_notification( + request, conservation_status, assessor_comment +): """Recipient: Always internal users""" email = AssessorReadyForAgendaSendNotificationEmail() url = request.build_absolute_uri(reverse("internal-meeting-dashboard", kwargs={})) - context = {"cs_proposal": conservation_status, "url": url} + context = { + "cs_proposal": conservation_status, + "url": url, + "assessor_comment": assessor_comment, + } msg = email.send(conservation_status.assessor_recipients, context=context) diff --git a/boranga/components/conservation_status/models.py b/boranga/components/conservation_status/models.py index fa52f0f2..4237b531 100644 --- a/boranga/components/conservation_status/models.py +++ b/boranga/components/conservation_status/models.py @@ -49,6 +49,7 @@ is_external_contributor, is_internal_contributor, member_ids, + no_commas_validator, ) from boranga.ledger_api_utils import retrieve_email_user from boranga.settings import ( @@ -414,15 +415,9 @@ class ConservationStatus(SubmitterInformationModelMixin, RevisionedMixin): (PROCESSING_STATUS_DELISTED, "DeListed"), (PROCESSING_STATUS_CLOSED, "Closed"), ) - # This status is used as a front end filter but shouldn't be in most cases + # This status is used as a front end filter but shouldn't be used in most cases # So it is deliberately not included in PROCESSING_STATUS_CHOICES PROCESSING_STATUS_DISCARDED_BY_ME = "discarded_by_me" - REVIEW_STATUS_CHOICES = ( - ("not_reviewed", "Not Reviewed"), - ("awaiting_amendments", "Awaiting Amendments"), - ("amended", "Amended"), - ("accepted", "Accepted"), - ) customer_status = models.CharField( "Customer Status", max_length=40, @@ -430,11 +425,9 @@ class ConservationStatus(SubmitterInformationModelMixin, RevisionedMixin): default=CUSTOMER_STATUS_CHOICES[0][0], ) - RECURRENCE_PATTERNS = [(1, "Month"), (2, "Year")] change_code = models.ForeignKey( ConservationChangeCode, on_delete=models.SET_NULL, blank=True, null=True ) - change_date = models.DateField(null=True, blank=True) APPLICATION_TYPE_CHOICES = ( ("new_proposal", "New Application"), @@ -531,44 +524,6 @@ class ConservationStatus(SubmitterInformationModelMixin, RevisionedMixin): international_conservation = models.CharField(max_length=100, blank=True, null=True) conservation_criteria = models.CharField(max_length=100, blank=True, null=True) - # Conservation Lists and Categories (from a meeting) - recommended_wa_priority_list = models.ForeignKey( - WAPriorityList, - on_delete=models.PROTECT, - blank=True, - null=True, - ) - recommended_wa_priority_category = models.ForeignKey( - WAPriorityCategory, - on_delete=models.PROTECT, - blank=True, - null=True, - ) - recommended_wa_legislative_list = models.ForeignKey( - WALegislativeList, - on_delete=models.PROTECT, - blank=True, - null=True, - ) - recommended_wa_legislative_category = models.ForeignKey( - WALegislativeCategory, - on_delete=models.PROTECT, - blank=True, - null=True, - ) - recommended_commonwealth_conservation_list = models.ForeignKey( - CommonwealthConservationList, - on_delete=models.PROTECT, - blank=True, - null=True, - ) - recommended_international_conservation = models.CharField( - max_length=100, blank=True, null=True - ) - recommended_conservation_criteria = models.CharField( - max_length=100, blank=True, null=True - ) - APPROVAL_LEVEL_IMMEDIATE = "immediate" APPROVAL_LEVEL_MINISTER = "minister" APPROVAL_LEVEL_CHOICES = ( @@ -584,11 +539,6 @@ class ConservationStatus(SubmitterInformationModelMixin, RevisionedMixin): comment = models.CharField(max_length=512, blank=True, null=True) review_due_date = models.DateField(null=True, blank=True) - reviewed_by = models.IntegerField(null=True) # EmailUserRO - recurrence_pattern = models.SmallIntegerField( - choices=RECURRENCE_PATTERNS, default=1 - ) - recurrence_schedule = models.IntegerField(null=True, blank=True) effective_from = models.DateField(null=True, blank=True) effective_to = models.DateField(null=True, blank=True) submitter = models.IntegerField(null=True) # EmailUserRO @@ -613,15 +563,12 @@ class ConservationStatus(SubmitterInformationModelMixin, RevisionedMixin): # Currently prev_processing_status is only used to keep track of status prior to unlock # so that when locked the record returns to the correct status prev_processing_status = models.CharField(max_length=30, blank=True, null=True) - review_status = models.CharField( - "Review Status", - max_length=30, - choices=REVIEW_STATUS_CHOICES, - default=REVIEW_STATUS_CHOICES[0][0], - ) proposed_decline_status = models.BooleanField(default=False) deficiency_data = models.TextField(null=True, blank=True) # deficiency comment assessor_data = models.TextField(null=True, blank=True) # assessor comment + + # When the CS proposal is sent back to the assessor this comment is used + # in the email approver_comment = models.TextField(blank=True) internal_application = models.BooleanField(default=False) @@ -968,19 +915,20 @@ def has_assessor_mode(self, request): ConservationStatus.PROCESSING_STATUS_WITH_APPROVER, ConservationStatus.PROCESSING_STATUS_CLOSED, ConservationStatus.PROCESSING_STATUS_DECLINED, + ConservationStatus.PROCESSING_STATUS_DELISTED, ConservationStatus.PROCESSING_STATUS_DRAFT, ] if self.processing_status in status_without_assessor: - if self.processing_status in [ - ConservationStatus.PROCESSING_STATUS_UNLOCKED, - ]: - return is_conservation_status_approver(request) - return False - elif self.processing_status == ConservationStatus.PROCESSING_STATUS_APPROVED: - return is_conservation_status_assessor( - request - ) or is_conservation_status_approver(request) + + if self.processing_status == ConservationStatus.PROCESSING_STATUS_APPROVED: + # Edge case that allows assessors to propose to delist without being assigned + # to the conservation status. This is due to the fact we only show either + # the assigned to dropdown for the approver or assessor and not both. + return is_conservation_status_assessor(request) + + elif self.processing_status == ConservationStatus.PROCESSING_STATUS_UNLOCKED: + return is_conservation_status_approver(request) else: if not self.assigned_officer: return False @@ -990,21 +938,6 @@ def has_assessor_mode(self, request): return is_conservation_status_assessor(request) - def can_edit_recommended(self, request): - recommended_edit_status = [ - ConservationStatus.PROCESSING_STATUS_READY_FOR_AGENDA - ] - if self.processing_status not in recommended_edit_status: - return False - - if not self.assigned_officer: - return False - - if not self.assigned_officer == request.user.id: - return False - - return is_conservation_status_assessor(request) - @transaction.atomic def assign_officer(self, request, officer): if not is_conservation_status_assessor( @@ -1141,39 +1074,47 @@ def send_referral(self, request, referral_email, referral_text): ]: raise exceptions.ConservationStatusReferralCannotBeSent() - self.processing_status = ConservationStatus.PROCESSING_STATUS_WITH_REFERRAL - self.save() - referral = None - # Check if the user is in ledger try: - user = EmailUser.objects.get(email__icontains=referral_email) + referee = EmailUser.objects.get(email__iexact=referral_email.strip()) except EmailUser.DoesNotExist: raise ValidationError( f"There is no user with email {referral_email} in the ledger system. " "Please check the email and try again." ) + # Don't allow users to refer to themselves + if referee.id == request.user.id: + raise ValidationError("You cannot refer to yourself") + + # Don't allow users to refer to the submitter + if referee.id == self.submitter: + raise ValidationError("You cannot refer to the submitter") + + referral = None try: ConservationStatusReferral.objects.get( - referral=user.id, conservation_status=self + referral=referee.id, conservation_status=self ) raise ValidationError("A referral has already been sent to this user") except ConservationStatusReferral.DoesNotExist: referral = ConservationStatusReferral.objects.create( conservation_status=self, - referral=user.id, + referral=referee.id, sent_by=request.user.id, text=referral_text, assigned_officer=request.user.id, ) + self.processing_status = ConservationStatus.PROCESSING_STATUS_WITH_REFERRAL + self.save() + # Create a log entry for the proposal self.log_user_action( ConservationStatusUserAction.ACTION_SEND_REFERRAL_TO.format( referral.id, self.conservation_status_number, - f"{user.get_full_name()}({user.email})", + f"{referee.get_full_name()}({referee.email})", ), request, ) @@ -1183,7 +1124,7 @@ def send_referral(self, request, referral_email, referral_text): ConservationStatusUserAction.ACTION_SEND_REFERRAL_TO.format( referral.id, self.conservation_status_number, - f"{user.get_full_name()}({user.email})", + f"{referee.get_full_name()}({referee.email})", ), request, ) @@ -1216,12 +1157,16 @@ def move_to_status(self, request, status, approver_comment): if self.processing_status == status: return - if self.processing_status == ConservationStatus.PROCESSING_STATUS_WITH_APPROVER: + if ( + self.processing_status + == ConservationStatus.PROCESSING_STATUS_READY_FOR_AGENDA + ): self.approver_comment = "" if approver_comment: self.approver_comment = approver_comment self.save() send_proposal_approver_sendback_email_notification(request, self) + previous_status = self.processing_status self.processing_status = status self.save() @@ -1558,10 +1503,12 @@ def proposed_ready_for_agenda(self, request): self.customer_status = ConservationStatus.PROCESSING_STATUS_READY_FOR_AGENDA self.save() + assessor_comment = request.data.get("assessor_comment") + # Log proposal action self.log_user_action( ConservationStatusUserAction.ACTION_PROPOSED_READY_FOR_AGENDA.format( - self.conservation_status_number + self.conservation_status_number, assessor_comment ), request, ) @@ -1569,12 +1516,14 @@ def proposed_ready_for_agenda(self, request): # Create a log entry for the user request.user.log_user_action( ConservationStatusUserAction.ACTION_PROPOSED_READY_FOR_AGENDA.format( - self.conservation_status_number, + self.conservation_status_number, assessor_comment ), request, ) - send_assessor_ready_for_agenda_email_notification(request, self) + send_assessor_ready_for_agenda_email_notification( + request, self, assessor_comment + ) @transaction.atomic def discard(self, request): @@ -1971,6 +1920,10 @@ class ConservationStatusUserAction(UserAction): ACTION_PROPOSE_DELIST_PROPOSAL = ( "Propose discard conservation status proposal {}. Reason: {}" ) + ACTION_PROPOSED_READY_FOR_AGENDA = ( + "Propose conservation status proposal {} " + "is ready for agenda. Assessor Comment: {}" + ) ACTION_DELIST_PROPOSAL = "Delist conservation status proposal {}" ACTION_DISCARD_PROPOSAL_INTERNALLY = ( "Discard conservation status proposal internally {}" @@ -1986,9 +1939,6 @@ class ConservationStatusUserAction(UserAction): # Assessors ACTION_SAVE_ASSESSMENT_ = "Save assessment {}" ACTION_CONCLUDE_ASSESSMENT_ = "Conclude assessment {}" - ACTION_PROPOSED_READY_FOR_AGENDA = ( - "Conservation status proposal {} has been proposed for ready for agenda" - ) ACTION_PROPOSED_APPROVAL = ( "Conservation status proposal {} has been proposed for approval" ) @@ -2134,36 +2084,7 @@ class Meta: app_label = "boranga" -class ConservationStatusReferralDocument(Document): - referral = models.ForeignKey( - "ConservationStatusReferral", - related_name="referral_documents", - on_delete=models.CASCADE, - ) - _file = models.FileField( - upload_to=update_referral_doc_filename, max_length=512, storage=private_storage - ) - input_name = models.CharField(max_length=255, null=True, blank=True) - can_delete = models.BooleanField( - default=True - ) # after initial submit prevent document from being deleted - - def delete(self): - if self.can_delete: - return self.can_delete - logger.info( - "Cannot delete existing document object after proposal has been submitted " - "(including document submitted before proposal pushback to status Draft): {}".format( - self.name - ) - ) - - class Meta: - app_label = "boranga" - - class ConservationStatusReferral(models.Model): - SENT_CHOICES = ((1, "Sent From Assessor"), (2, "Sent From Referral")) PROCESSING_STATUS_WITH_REFERRAL = "with_referral" PROCESSING_STATUS_RECALLED = "recalled" PROCESSING_STATUS_COMPLETED = "completed" @@ -2179,9 +2100,6 @@ class ConservationStatusReferral(models.Model): sent_by = models.IntegerField() # EmailUserRO referral = models.IntegerField() # EmailUserRO linked = models.BooleanField(default=False) - sent_from = models.SmallIntegerField( - choices=SENT_CHOICES, default=SENT_CHOICES[0][0] - ) processing_status = models.CharField( "Processing Status", max_length=30, @@ -2189,17 +2107,7 @@ class ConservationStatusReferral(models.Model): default=PROCESSING_STATUS_CHOICES[0][0], ) text = models.TextField(blank=True) # Assessor text when send_referral - referral_text = models.TextField( - blank=True - ) # used in other projects for complete referral comment but not used in boranga referral_comment = models.TextField(blank=True, null=True) # Referral Comment - document = models.ForeignKey( - ConservationStatusReferralDocument, - blank=True, - null=True, - related_name="referral_document", - on_delete=models.SET_NULL, - ) assigned_officer = models.IntegerField(null=True) # EmailUserRO is_external = models.BooleanField(default=False) @@ -2306,7 +2214,6 @@ def resend(self, request): ) self.conservation_status.save() - self.sent_from = 1 self.save() # Create a log entry for the conservation status @@ -2341,10 +2248,14 @@ def send_referral(self, request, referral_email, referral_text): ): raise exceptions.ConservationStatusReferralCannotBeSent() - if request.user.id != self.referral: - raise exceptions.ReferralNotAuthorized() - if self.sent_from != 1: - raise exceptions.ReferralCanNotSend() + # Don't allow users to refer to themselves + if request.user.id == self.referral: + raise ValidationError("You cannot refer to yourself") + + # Don't allow users to refer to the submitter + if request.user.id == self.conservation_status.submitter: + raise ValidationError("You cannot refer to the submitter") + self.conservation_status.processing_status = ( ConservationStatus.PROCESSING_STATUS_WITH_REFERRAL ) @@ -2377,7 +2288,6 @@ def send_referral(self, request, referral_email, referral_text): conservation_status=self.conservation_status, referral=referee.id, sent_by=request.user.id, - sent_from=2, text=referral_text, ) @@ -2461,16 +2371,16 @@ class ConservationStatusProposalRequest(models.Model): conservation_status = models.ForeignKey( ConservationStatus, on_delete=models.CASCADE ) - subject = models.CharField(max_length=200, blank=True) text = models.TextField(blank=True) - officer = models.IntegerField(null=True) # EmailUserRO class Meta: app_label = "boranga" class ProposalAmendmentReason(ArchivableModel): - reason = models.CharField("Reason", max_length=125) + reason = models.CharField( + "Reason", max_length=125, validators=[no_commas_validator] + ) class Meta: app_label = "boranga" diff --git a/boranga/components/conservation_status/serializers.py b/boranga/components/conservation_status/serializers.py index 0fa1f03d..f0f65366 100644 --- a/boranga/components/conservation_status/serializers.py +++ b/boranga/components/conservation_status/serializers.py @@ -530,21 +530,11 @@ class BaseConservationStatusSerializer(serializers.ModelSerializer): group_type = serializers.SerializerMethodField(read_only=True) group_type_id = serializers.SerializerMethodField(read_only=True) is_submitter = serializers.SerializerMethodField(read_only=True) - wa_legislative_list = serializers.CharField( - source="wa_legislative_list.code", read_only=True, allow_null=True - ) - wa_legislative_category = serializers.CharField( - source="wa_legislative_category.code", read_only=True, allow_null=True - ) - wa_priority_list = serializers.CharField( - source="wa_priority_list.code", read_only=True, allow_null=True - ) - wa_priority_category = serializers.CharField( - source="wa_priority_category.code", read_only=True, allow_null=True - ) - commonwealth_conservation_list = serializers.CharField( - source="commonwealth_conservation_list.code", read_only=True, allow_null=True - ) + wa_legislative_list = serializers.SerializerMethodField(read_only=True) + wa_legislative_category = serializers.SerializerMethodField(read_only=True) + wa_priority_list = serializers.SerializerMethodField(read_only=True) + wa_priority_category = serializers.SerializerMethodField(read_only=True) + commonwealth_conservation_list = serializers.SerializerMethodField(read_only=True) class Meta: model = ConservationStatus @@ -568,13 +558,6 @@ class Meta: "commonwealth_conservation_list", "international_conservation", "conservation_criteria", - "recommended_wa_legislative_list_id", - "recommended_wa_legislative_category_id", - "recommended_wa_priority_list_id", - "recommended_wa_priority_category_id", - "recommended_commonwealth_conservation_list_id", - "recommended_international_conservation", - "recommended_conservation_criteria", "comment", "lodgement_date", "applicant_type", @@ -583,7 +566,6 @@ class Meta: "assigned_officer", "customer_status", "processing_status", - "review_status", "readonly", "can_user_edit", "can_user_view", @@ -598,6 +580,54 @@ class Meta: "is_submitter", ) + def get_wa_legislative_list(self, obj): + if not obj.wa_legislative_list: + return None + + if obj.wa_legislative_list.code and obj.wa_legislative_list.label: + return f"{obj.wa_legislative_list.code} - {obj.wa_legislative_list.label}" + + return obj.wa_legislative_list.code + + def get_wa_legislative_category(self, obj): + if not obj.wa_legislative_category: + return None + + if obj.wa_legislative_category.code and obj.wa_legislative_category.label: + return f"{obj.wa_legislative_category.code} - {obj.wa_legislative_category.label}" + + return obj.wa_legislative_category.code + + def get_wa_priority_list(self, obj): + if not obj.wa_priority_list: + return None + + if obj.wa_priority_list.code and obj.wa_priority_list.label: + return f"{obj.wa_priority_list.code} - {obj.wa_priority_list.label}" + + return obj.wa_priority_list.code + + def get_wa_priority_category(self, obj): + if not obj.wa_priority_category: + return None + + if obj.wa_priority_category.code and obj.wa_priority_category.label: + return f"{obj.wa_priority_category.code} - {obj.wa_priority_category.label}" + + return obj.wa_priority_category.code + + def get_commonwealth_conservation_list(self, obj): + if not obj.commonwealth_conservation_list: + return None + + if ( + obj.commonwealth_conservation_list.code + and obj.commonwealth_conservation_list.label + ): + return f"{obj.commonwealth_conservation_list.code} - {obj.commonwealth_conservation_list.label}" + + return obj.commonwealth_conservation_list.code + def get_readonly(self, obj): return False @@ -637,7 +667,6 @@ def get_is_submitter(self, obj): class ConservationStatusSerializer(BaseConservationStatusSerializer): submitter = serializers.SerializerMethodField(read_only=True) processing_status = serializers.SerializerMethodField(read_only=True) - review_status = serializers.SerializerMethodField(read_only=True) customer_status = serializers.SerializerMethodField(read_only=True) submitter_information = SubmitterInformationSerializer(read_only=True) @@ -761,7 +790,6 @@ class InternalConservationStatusSerializer(BaseConservationStatusSerializer): ) conservation_status_approval_document = serializers.SerializerMethodField() internal_user_edit = serializers.SerializerMethodField(read_only=True) - can_edit_recommended = serializers.SerializerMethodField(read_only=True) referrals = ConservationStatusProposalReferralSerializer(many=True) is_new_contributor = serializers.SerializerMethodField(read_only=True) internal_application = serializers.BooleanField(read_only=True) @@ -789,13 +817,17 @@ class Meta: "community_id", "conservation_status_number", "wa_legislative_list_id", + "wa_legislative_list", "wa_legislative_category_id", + "wa_legislative_category", "wa_priority_list_id", + "wa_priority_list", "wa_priority_category_id", + "wa_priority_category", "commonwealth_conservation_list_id", + "commonwealth_conservation_list", "international_conservation", "conservation_criteria", - "recommended_conservation_criteria", "comment", "processing_status", "customer_status", @@ -825,7 +857,6 @@ class Meta: "internal_user_edit", "conservation_status_approval_document", "approval_level", - "can_edit_recommended", "internal_application", "is_new_contributor", "change_code_id", @@ -917,10 +948,6 @@ def get_conservation_status_approval_document(self, obj): except ConservationStatusIssuanceApprovalDetails.DoesNotExist: return None - def get_can_edit_recommended(self, obj): - request = self.context["request"] - return obj.can_edit_recommended(request) - def get_is_new_contributor(self, obj): return is_new_external_contributor(obj.submitter) @@ -1028,22 +1055,6 @@ class SaveSpeciesConservationStatusSerializer(BaseConservationStatusSerializer): commonwealth_conservation_list_id = serializers.IntegerField( required=False, allow_null=True, write_only=True ) - - recommended_wa_legislative_list_id = serializers.IntegerField( - required=False, allow_null=True, write_only=True - ) - recommended_wa_legislative_category_id = serializers.IntegerField( - required=False, allow_null=True, write_only=True - ) - recommended_wa_priority_list_id = serializers.IntegerField( - required=False, allow_null=True, write_only=True - ) - recommended_wa_priority_category_id = serializers.IntegerField( - required=False, allow_null=True, write_only=True - ) - recommended_commonwealth_conservation_list_id = serializers.IntegerField( - required=False, allow_null=True, write_only=True - ) change_code_id = serializers.IntegerField( required=False, allow_null=True, write_only=True ) @@ -1061,13 +1072,6 @@ class Meta: "commonwealth_conservation_list_id", "international_conservation", "conservation_criteria", - "recommended_wa_legislative_list_id", - "recommended_wa_legislative_category_id", - "recommended_wa_priority_list_id", - "recommended_wa_priority_category_id", - "recommended_commonwealth_conservation_list_id", - "recommended_international_conservation", - "recommended_conservation_criteria", "comment", "lodgement_date", "listing_date", @@ -1197,22 +1201,6 @@ class SaveCommunityConservationStatusSerializer(BaseConservationStatusSerializer commonwealth_conservation_list_id = serializers.IntegerField( required=False, allow_null=True, write_only=True ) - - recommended_wa_legislative_list_id = serializers.IntegerField( - required=False, allow_null=True, write_only=True - ) - recommended_wa_legislative_category_id = serializers.IntegerField( - required=False, allow_null=True, write_only=True - ) - recommended_wa_priority_list_id = serializers.IntegerField( - required=False, allow_null=True, write_only=True - ) - recommended_wa_priority_category_id = serializers.IntegerField( - required=False, allow_null=True, write_only=True - ) - recommended_commonwealth_conservation_list_id = serializers.IntegerField( - required=False, allow_null=True, write_only=True - ) change_code_id = serializers.IntegerField( required=False, allow_null=True, write_only=True ) @@ -1230,13 +1218,6 @@ class Meta: "commonwealth_conservation_list_id", "international_conservation", "conservation_criteria", - "recommended_wa_legislative_list_id", - "recommended_wa_legislative_category_id", - "recommended_wa_priority_list_id", - "recommended_wa_priority_category_id", - "recommended_commonwealth_conservation_list_id", - "recommended_international_conservation", - "recommended_conservation_criteria", "comment", "lodgement_date", "applicant_type", @@ -1288,7 +1269,7 @@ def validate(self, data): request = self.context.get("request") if request.user.email == data["email"]: - non_field_errors.append("You cannot send referral to yourself.") + non_field_errors.append("You cannot refer to yourself.") elif not data["email"]: non_field_errors.append("Referral not found.") @@ -1315,7 +1296,6 @@ class DTConservationStatusReferralSerializer(serializers.ModelSerializer): referral = serializers.SerializerMethodField() referral_comment = serializers.SerializerMethodField() submitter = serializers.SerializerMethodField() - document = serializers.SerializerMethodField() can_user_process = serializers.SerializerMethodField() group_type = serializers.SerializerMethodField() @@ -1341,8 +1321,6 @@ class Meta: "lodged_on", "conservation_status", "can_be_processed", - "document", - "referral_text", "referral_comment", "group_type", "species_number", @@ -1373,9 +1351,6 @@ def get_submitter(self, obj): else: return "" - def get_document(self, obj): - return [obj.document.name, obj.document._file.url] if obj.document else None - def get_can_user_process(self, obj): request = self.context["request"] if not obj.can_be_completed: @@ -1514,9 +1489,7 @@ class Meta: "reason", "reason_text", "cs_amendment_request_documents", - "subject", "text", - "officer", "status", "conservation_status", ] diff --git a/boranga/components/conservation_status/templates/boranga/emails/cs_proposals/send_approver_sendback_notification.html b/boranga/components/conservation_status/templates/boranga/emails/cs_proposals/send_approver_sendback_notification.html index ff594f58..622db4fd 100644 --- a/boranga/components/conservation_status/templates/boranga/emails/cs_proposals/send_approver_sendback_notification.html +++ b/boranga/components/conservation_status/templates/boranga/emails/cs_proposals/send_approver_sendback_notification.html @@ -1,13 +1,13 @@ {% extends 'boranga/emails/base_email.html' %} {% block content %} - The Proposal {{ cs_proposal.conservation_status_number }} has been sent back by approver. + The Proposal {{ cs_proposal.conservation_status_number }} has been sent back by the approver. -

Approver comments: {{ approver_comment }}

+

Reason / Comments: {{ approver_comment }}

You can access this Proposal using the following link:

Access Proposal -{% endblock %} +{% endblock %} diff --git a/boranga/components/conservation_status/templates/boranga/emails/cs_proposals/send_approver_sendback_notification.txt b/boranga/components/conservation_status/templates/boranga/emails/cs_proposals/send_approver_sendback_notification.txt index 15e3d817..52ed3a92 100644 --- a/boranga/components/conservation_status/templates/boranga/emails/cs_proposals/send_approver_sendback_notification.txt +++ b/boranga/components/conservation_status/templates/boranga/emails/cs_proposals/send_approver_sendback_notification.txt @@ -1,11 +1,11 @@ {% extends 'boranga/emails/base_email.txt' %} {% block content %} - The Proposal {{ cs_proposal.conservation_status_number }} has been sent back by approver. + The Proposal {{ cs_proposal.conservation_status_number }} has been sent back by the approver. - Approver comments: {{ approver_comment }} + Reason / Comments: {{ approver_comment }} You can access this Proposal using the following link: {{url}} -{% endblock %} +{% endblock %} diff --git a/boranga/components/conservation_status/templates/boranga/emails/cs_proposals/send_assessor_ready_for_agenda_notification.html b/boranga/components/conservation_status/templates/boranga/emails/cs_proposals/send_assessor_ready_for_agenda_notification.html index 4e7661a5..cd108cfa 100644 --- a/boranga/components/conservation_status/templates/boranga/emails/cs_proposals/send_assessor_ready_for_agenda_notification.html +++ b/boranga/components/conservation_status/templates/boranga/emails/cs_proposals/send_assessor_ready_for_agenda_notification.html @@ -6,8 +6,16 @@

- You can schedule a meeting for this Proposal using the following link: + Assessor Recommended Action / Comments:

- Schedule Meeting +

+ {{ assessor_comment }} +

+ +

+ You can action this Proposal using the following link: +

+ + Conservation Status Proposal {{ cs_proposal.conservation_status_number }} {% endblock %} diff --git a/boranga/components/conservation_status/templates/boranga/emails/cs_proposals/send_assessor_ready_for_agenda_notification.txt b/boranga/components/conservation_status/templates/boranga/emails/cs_proposals/send_assessor_ready_for_agenda_notification.txt index 4505f715..e9198cda 100644 --- a/boranga/components/conservation_status/templates/boranga/emails/cs_proposals/send_assessor_ready_for_agenda_notification.txt +++ b/boranga/components/conservation_status/templates/boranga/emails/cs_proposals/send_assessor_ready_for_agenda_notification.txt @@ -3,7 +3,11 @@ {% block content %} The Conservation Status Proposal {{ cs_proposal.conservation_status_number }} has been assessed and is now 'Ready for Agenda'. - You can schedule a meeting for this Proposal using the following link: + Assessor Recommended Action / Comments: + + {{ assessor_comment }} + + You can action this Proposal using the following link: {{url}} {% endblock %} diff --git a/boranga/components/history/api.py b/boranga/components/history/api.py index 972bb283..1bf06bd6 100644 --- a/boranga/components/history/api.py +++ b/boranga/components/history/api.py @@ -154,7 +154,7 @@ def search_versions(self, request, queryset, view): ) queryset = queryset.filter(revision_id__in=remaining_revision_ids) except Exception as e: - logger.warn(f"Invalid search term: {e}") + logger.warning(f"Invalid search term: {e}") return queryset @@ -268,7 +268,7 @@ def getVersionModelLookUpFieldValues(self, versions, model): lookup_fields.append(i) self.lookup_values[i] = lookup_value except Exception as e: - logger.warn(e) + logger.warning(e) rejected_lookup_fields.append(i) self.lookup_fields = lookup_fields diff --git a/boranga/components/main/api.py b/boranga/components/main/api.py index 9914b171..4590b281 100755 --- a/boranga/components/main/api.py +++ b/boranga/components/main/api.py @@ -5,15 +5,17 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.cache import cache +from django.db.models import Q from django_filters import rest_framework as filters from rest_framework import filters as rest_framework_filters from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.response import Response from boranga import helpers -from boranga.components.main.models import GlobalSettings, HelpTextEntry +from boranga.components.main.models import HelpTextEntry from boranga.components.main.serializers import ( ContentTypeSerializer, - GlobalSettingsSerializer, HelpTextEntrySerializer, ) from boranga.components.occurrence.models import Datum @@ -22,16 +24,6 @@ logger = logging.getLogger(__name__) -class GlobalSettingsViewSet(viewsets.ReadOnlyModelViewSet): - queryset = GlobalSettings.objects.none() - serializer_class = GlobalSettingsSerializer - - def get_queryset(self): - if self.request.user.is_authenticated: - qs = GlobalSettings.objects.all().order_by("id") - return qs - - class HelpTextEntryViewSet(viewsets.ReadOnlyModelViewSet): queryset = HelpTextEntry.objects.active() serializer_class = HelpTextEntrySerializer @@ -54,6 +46,25 @@ class ContentTypeViewSet(viewsets.ReadOnlyModelViewSet): filterset_fields = ["app_label", "model"] search_fields = ["^model"] + @action( + methods=[ + "GET", + ], + detail=False, + ) + def ocr_bulk_import_content_types(self, request): + """Returns a list of content types that are allowed to be imported in the ocr bulk importer""" + content_types = ContentType.objects.filter( + app_label="boranga", + ).filter( + Q(model__startswith="occurrencereport") + | Q(model__startswith="ocr") + | Q(model__iexact="occurrence") + | Q(model__iexact="submitterinformation") + ) + serializer = self.get_serializer(content_types, many=True) + return Response(serializer.data) + class RetrieveActionLoggingViewsetMixin: """Mixin to automatically log user actions when a user retrieves an instance. diff --git a/boranga/components/main/models.py b/boranga/components/main/models.py index d5f61b0f..6eedf3d7 100644 --- a/boranga/components/main/models.py +++ b/boranga/components/main/models.py @@ -4,12 +4,11 @@ from django.apps import apps from django.conf import settings from django.core.cache import cache -from django.core.exceptions import ValidationError from django.core.files.storage import FileSystemStorage from django.db import models from reversion.models import Version -from boranga.helpers import compressed_content_valid, file_extension_valid +from boranga.helpers import check_file private_storage = FileSystemStorage( location=settings.BASE_DIR + "/private-media/", base_url="/private-media/" @@ -183,54 +182,7 @@ def __str__(self): return self.name or self.filename def check_file(self, file): - # check if extension in whitelist - cache_key = settings.CACHE_KEY_FILE_EXTENSION_WHITELIST - whitelist = cache.get(cache_key) - if whitelist is None: - whitelist = FileExtensionWhitelist.objects.all() - cache.set(cache_key, whitelist, settings.CACHE_TIMEOUT_2_HOURS) - - valid, compression = file_extension_valid( - str(file), whitelist, self._meta.model_name - ) - - if not valid: - raise ValidationError("File type/extension not supported") - - if compression: - # supported compression check - valid = compressed_content_valid(file, whitelist, self._meta.model_name) - if not valid: - raise ValidationError("Unsupported type/extension in compressed file") - - -class GlobalSettings(models.Model): - keys = ( - ("credit_facility_link", "Credit Facility Link"), - ("deed_poll", "Deed poll"), - ("deed_poll_filming", "Deed poll Filming"), - ("deed_poll_event", "Deed poll Event"), - ("online_training_document", "Online Training Document"), - ("park_finder_link", "Park Finder Link"), - ("fees_and_charges", "Fees and charges link"), - ("event_fees_and_charges", "Event Fees and charges link"), - ("commercial_filming_handbook", "Commercial Filming Handbook link"), - ("park_stay_link", "Park Stay Link"), - ("event_traffic_code_of_practice", "Event traffic code of practice"), - ("trail_section_map", "Trail section map"), - ("dwer_application_form", "DWER Application Form"), - ) - key = models.CharField( - max_length=255, - choices=keys, - blank=False, - null=False, - ) - value = models.CharField(max_length=255) - - class Meta: - app_label = "boranga" - verbose_name_plural = "Global Settings" + return check_file(file, self._meta.model_name) # @python_2_unicode_compatible diff --git a/boranga/components/main/related_item.py b/boranga/components/main/related_item.py index e9426d13..6ff7c630 100644 --- a/boranga/components/main/related_item.py +++ b/boranga/components/main/related_item.py @@ -11,6 +11,29 @@ def __init__( self.status = status self.action_url = action_url + def __hash__(self): + return hash( + ( + self.model_name, + self.identifier, + self.descriptor, + self.status, + self.action_url, + ) + ) + + def __eq__(self, other): + return ( + self.identifier == other.identifier + and self.model_name == other.model_name + and self.descriptor == other.descriptor + and self.status == other.status + and self.action_url == other.action_url + ) + + def __str__(self): + return f"{self.identifier}" + class RelatedItemsSerializer(serializers.Serializer): model_name = serializers.CharField() diff --git a/boranga/components/main/serializers.py b/boranga/components/main/serializers.py index 7f93ded9..7ff141af 100755 --- a/boranga/components/main/serializers.py +++ b/boranga/components/main/serializers.py @@ -1,17 +1,17 @@ import logging +from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -from django.db.models.fields.related import ForeignKey, OneToOneField +from django.db.models.fields.related import ForeignKey, ManyToManyField, OneToOneField from ledger_api_client.ledger_models import EmailUserRO from ledger_api_client.ledger_models import EmailUserRO as EmailUser from rest_framework import serializers -from boranga.components.main.models import ( - CommunicationsLogEntry, - GlobalSettings, - HelpTextEntry, -) +from boranga.components.main.models import CommunicationsLogEntry, HelpTextEntry from boranga.helpers import ( + get_choices_for_field, + get_filter_field_options_for_field, + get_lookup_field_options_for_field, get_openpyxl_data_validation_type_for_django_field, is_django_admin, ) @@ -45,12 +45,6 @@ def get_documents(self, obj): return [[d.name, d._file.url] for d in obj.documents.all()] -class GlobalSettingsSerializer(serializers.ModelSerializer): - class Meta: - model = GlobalSettings - fields = ("key", "value") - - class EmailUserROSerializerForReferral(serializers.ModelSerializer): name = serializers.SerializerMethodField() telephone = serializers.CharField(source="phone_number") @@ -136,6 +130,9 @@ def get_model_verbose_name(self, obj): def get_model_fields(self, obj): if not obj.model_class(): return [] + + content_type = ContentType.objects.get_for_model(obj.model_class()).id + fields = obj.model_class()._meta.get_fields() exclude_fields = [] if hasattr(obj.model_class(), "BULK_IMPORT_EXCLUDE_FIELDS"): @@ -144,6 +141,7 @@ def get_model_fields(self, obj): def filter_fields(field): return ( field.name not in exclude_fields + and field.name != "occurrence_report" and not field.auto_created and not ( field.is_relation @@ -151,6 +149,7 @@ def filter_fields(field): not in [ ForeignKey, OneToOneField, + ManyToManyField, ] ) ) @@ -164,33 +163,30 @@ def filter_fields(field): else field.name ) field_type = str(type(field)).split(".")[-1].replace("'>", "") - choices = field.choices if hasattr(field, "choices") else None allow_null = field.null if hasattr(field, "null") else None max_length = field.max_length if hasattr(field, "max_length") else None xlsx_validation_type = get_openpyxl_data_validation_type_for_django_field( field ) - lookup_field_options = None - if hasattr(field, "related_model") and field.related_model: - related_model = field.related_model - fields = related_model._meta.get_fields() - lookup_field_options = [ - field.verbose_name.lower() - for field in related_model._meta.get_fields() - if not field.related_model - and field.unique - and not field.name.endswith("_number") - ] + + if isinstance(field, GenericForeignKey): + continue + + choices = get_choices_for_field(obj.model_class(), field) + lookup_field_options = get_lookup_field_options_for_field(field) + filter_field_options = get_filter_field_options_for_field(field) model_fields.append( { "name": field.name, "display_name": display_name, + "content_type": content_type, "type": field_type, - "choices": choices, "allow_null": allow_null, "max_length": max_length, "xlsx_validation_type": xlsx_validation_type, + "choices": choices, "lookup_field_options": lookup_field_options, + "filter_field_options": filter_field_options, } ) return model_fields diff --git a/boranga/components/occurrence/api.py b/boranga/components/occurrence/api.py index bbda71b5..e03b0856 100644 --- a/boranga/components/occurrence/api.py +++ b/boranga/components/occurrence/api.py @@ -1,5 +1,6 @@ import json import logging +import traceback from datetime import datetime, time from io import BytesIO @@ -104,6 +105,7 @@ RockType, SampleDestination, SampleType, + SchemaColumnLookupFilter, SecondarySign, SiteType, SoilColour, @@ -141,6 +143,8 @@ OccurrenceLogEntrySerializer, OccurrenceReportAmendmentRequestSerializer, OccurrenceReportBulkImportSchemaColumnSerializer, + OccurrenceReportBulkImportSchemaListSerializer, + OccurrenceReportBulkImportSchemaOccurrenceApproverSerializer, OccurrenceReportBulkImportSchemaSerializer, OccurrenceReportBulkImportTaskSerializer, OccurrenceReportDocumentSerializer, @@ -208,6 +212,7 @@ from boranga.helpers import ( is_contributor, is_customer, + is_django_admin, is_internal, is_occurrence_approver, is_occurrence_assessor, @@ -6278,7 +6283,7 @@ class OccurrenceReportBulkImportTaskViewSet( permission_classes = [OccurrenceReportBulkImportPermission] serializer_class = OccurrenceReportBulkImportTaskSerializer filter_backends = [OrderingFilter, filters.DjangoFilterBackend] - filterset_fields = ["processing_status"] + filterset_fields = ["processing_status", "schema__group_type__name"] ordering_fields = ["datetime_queued", "datetime_started", "datetime_completed"] pagination_class = LimitOffsetPagination @@ -6292,20 +6297,27 @@ def perform_create(self, serializer): OccurrenceReportBulkImportTask.PROCESSING_STATUS_FAILED ) instance.datetime_error = timezone.now() + error_message = "" + for error in errors: + error_message += f"Row: {error['row_index'] + 1}. Error: {error['error_message']}\n" + instance.error_message = error_message else: instance.processing_status = ( OccurrenceReportBulkImportTask.PROCESSING_STATUS_COMPLETED ) instance.datetime_completed = timezone.now() - instance.save() + except Exception as e: logger.error( f"Error processing bulk import task {instance.id}: {str(e)}" ) + logger.error(traceback.format_exc()) instance.processing_status = ( OccurrenceReportBulkImportTask.PROCESSING_STATUS_FAILED ) instance.datetime_error = timezone.now() + instance.error_message = str(e) + instance.save() @detail_route(methods=["patch"], detail=True) def retry(self, request, *args, **kwargs): @@ -6333,6 +6345,13 @@ class OccurrenceReportBulkImportSchemaViewSet( filter_backends = [filters.DjangoFilterBackend] filterset_fields = ["group_type"] + def get_serializer_class(self): + if self.action == "list": + return OccurrenceReportBulkImportSchemaListSerializer + if not is_django_admin(self.request): + return OccurrenceReportBulkImportSchemaOccurrenceApproverSerializer + return super().get_serializer_class() + def get_queryset(self): qs = self.queryset if not (is_internal(self.request) or self.request.user.is_superuser): @@ -6367,7 +6386,9 @@ def get_schema_list_by_group_type(self, request, *args, **kwargs): group_type = GroupType.objects.get(name=group_type) schema = OccurrenceReportBulkImportSchema.objects.filter(group_type=group_type) - serializer = OccurrenceReportBulkImportSchemaSerializer(schema, many=True) + serializer = OccurrenceReportBulkImportSchemaListSerializer( + schema, many=True, context={"request": request} + ) return Response(serializer.data) @detail_route(methods=["get"], detail=True) @@ -6383,26 +6404,21 @@ def preview_import_file(self, request, *args, **kwargs): buffer.close() return response - @detail_route(methods=["post"], detail=True) - def copy(self, request, *args, **kwargs): + @detail_route(methods=["get"], detail=True) + def validate(self, request, *args, **kwargs): instance = self.get_object() - new_instance = instance.copy() - serializer = OccurrenceReportBulkImportSchemaSerializer(new_instance) - return Response(serializer.data, status=status.HTTP_201_CREATED) + errors = instance.validate(request.user.id) + if errors: + return Response(errors, status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_200_OK) - @detail_route(methods=["put"], detail=True) - def save_column(self, request, *args, **kwargs): + @detail_route(methods=["post"], detail=True) + def copy(self, request, *args, **kwargs): instance = self.get_object() - column_data = request.data.get("column_data", None) - if not column_data: - raise serializers.ValidationError("Column data is required") - serializer = OccurrenceReportBulkImportSchemaColumnSerializer( - instance, data=column_data + new_instance = instance.copy(request) + serializer = OccurrenceReportBulkImportSchemaSerializer( + new_instance, context={"request": request} ) - serializer.is_valid(raise_exception=True) - serializer.save() - - serializer = OccurrenceReportBulkImportSchemaSerializer(instance) return Response(serializer.data, status=status.HTTP_201_CREATED) @detail_route(methods=["get"], detail=False) @@ -6412,31 +6428,43 @@ def default_value_choices(self, request, *args, **kwargs): ) return Response(default_value_field.choices, status=status.HTTP_200_OK) - @detail_route(methods=["patch"], detail=True) - def reorder_column(self, request, *args, **kwargs): - instance = self.get_object() - # Don't order columns that haven't been saved - pk = request.data.get("id", None) - if not pk: - raise serializers.ValidationError( - "column must have id field to be reordered (i.e. record must be saved first)" - ) +class OccurrenceReportBulkImportSchemaColumnViewSet( + viewsets.GenericViewSet, + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + mixins.UpdateModelMixin, + mixins.ListModelMixin, +): + queryset = OccurrenceReportBulkImportSchemaColumn.objects.all() + serializer_class = OccurrenceReportBulkImportSchemaColumnSerializer + permission_classes = [OccurrenceReportBulkImportPermission] - order = request.data.get("order", None) - if order is None: - raise serializers.ValidationError("order field is missing from column") + @detail_route(methods=["get"], detail=True) + def preview_foreign_key_values_xlsx(self, request, *args, **kwargs): + instance = self.get_object() - try: - column = instance.columns.get(pk=pk) - except OccurrenceReportBulkImportSchemaColumn.DoesNotExist: - raise serializers.ValidationError( - f"Column with id {pk} not found in schema" + buffer = BytesIO() + workbook = instance.preview_foreign_key_values_xlsx + if not workbook: + return Response( + {"message": "No foreign key values to preview"}, + status=status.HTTP_404_NOT_FOUND, ) + workbook.save(buffer) + buffer.seek(0) + filename = ( + f"{instance.django_import_content_type.model}-{instance.django_import_field_name}" + f"-foreign-key-list-values.xlsx" + ) + response = HttpResponse(buffer.read(), content_type="application/vnd.ms-excel") + response["Content-Disposition"] = f"attachment; filename={filename}" + buffer.close() + return response - column.to(order) - - # instance.refresh_from_db() - - serializer = OccurrenceReportBulkImportSchemaSerializer(instance) - return Response(serializer.data, status=status.HTTP_201_CREATED) + @list_route(methods=["get"], detail=False) + def get_lookup_filter_types(self, request, *args, **kwargs): + return Response( + SchemaColumnLookupFilter.LOOKUP_FILTER_TYPES, + status=status.HTTP_200_OK, + ) diff --git a/boranga/components/occurrence/models.py b/boranga/components/occurrence/models.py index eebafd92..2d1e7411 100644 --- a/boranga/components/occurrence/models.py +++ b/boranga/components/occurrence/models.py @@ -1,12 +1,19 @@ import hashlib import json import logging +import mimetypes import os +import random +import string +import traceback +import zipfile +import zoneinfo from abc import abstractmethod from datetime import datetime +from datetime import timezone as dt_timezone from decimal import Decimal +from io import BytesIO -import dateutil import openpyxl import pyproj import reversion @@ -19,13 +26,15 @@ from django.contrib.gis.db.models.functions import Area from django.contrib.gis.geos import GEOSGeometry, Polygon from django.core.cache import cache -from django.core.exceptions import ValidationError +from django.core.exceptions import FieldDoesNotExist, FieldError, ValidationError from django.core.files.storage import FileSystemStorage +from django.core.files.uploadedfile import InMemoryUploadedFile from django.core.validators import MaxValueValidator, MinValueValidator from django.db import IntegrityError, models, transaction -from django.db.models import CharField, Count, Func, Max, Q -from django.db.models.functions import Cast +from django.db.models import CharField, Count, Func, ManyToManyField, Max, Q +from django.db.models.functions import Cast, Length from django.utils import timezone +from django.utils.functional import cached_property from ledger_api_client.ledger_models import EmailUserRO as EmailUser from ledger_api_client.managed_models import SystemGroup from multiselectfield import MultiSelectField @@ -33,7 +42,7 @@ from openpyxl.styles.fonts import Font from openpyxl.utils import get_column_letter from openpyxl.worksheet.datavalidation import DataValidation -from ordered_model.models import OrderedModel +from ordered_model.models import OrderedModel, OrderedModelManager from taggit.managers import TaggableManager from boranga import exceptions @@ -77,11 +86,17 @@ SubmitterInformationModelMixin, ) from boranga.helpers import ( + belongs_to_by_user_id, clone_model, + get_choices_for_field, get_display_field_for_model, + get_mock_request, + get_openpyxl_data_validation_type_for_django_field, + is_django_admin, is_occurrence_approver, is_occurrence_assessor, member_ids, + no_commas_validator, ) from boranga.ledger_api_utils import retrieve_email_user from boranga.settings import ( @@ -201,6 +216,12 @@ class OccurrenceReport(SubmitterInformationModelMixin, RevisionedMixin): (PROCESSING_STATUS_CLOSED, "DeListed"), ) + VALID_BULK_IMPORT_PROCESSING_STATUSES = [ + (PROCESSING_STATUS_WITH_ASSESSOR, "With Assessor"), + (PROCESSING_STATUS_WITH_APPROVER, "With Approver"), + (PROCESSING_STATUS_APPROVED, "Approved"), + ] + FINALISED_STATUSES = [ PROCESSING_STATUS_APPROVED, PROCESSING_STATUS_DECLINED, @@ -269,7 +290,9 @@ class OccurrenceReport(SubmitterInformationModelMixin, RevisionedMixin): occurrence_report_number = models.CharField(max_length=9, blank=True, default="") # Field to use when importing data from the legacy system - migrated_from_id = models.CharField(max_length=50, blank=True, default="") + migrated_from_id = models.CharField( + max_length=50, blank=True, null=True, unique=True + ) observation_date = models.DateTimeField(null=True, blank=True) reported_date = models.DateTimeField(auto_now_add=True, null=False, blank=False) @@ -470,8 +493,8 @@ def has_main_observer(self): def has_assessor_mode(self, request): status_with_assessor = [ - "with_assessor", - "with_referral", + OccurrenceReport.PROCESSING_STATUS_WITH_ASSESSOR, + OccurrenceReport.PROCESSING_STATUS_WITH_REFERRAL, ] if self.processing_status not in status_with_assessor: return False @@ -1092,6 +1115,14 @@ def send_referral(self, request, referral_email, referral_text): "The user you want to send the referral to does not exist in the ledger database" ) + # Don't allow the user to refer to themselves + if referee.id == request.user.id: + raise ValidationError("You cannot refer to yourself") + + # Don't allow the user to refer to the submitter + if referee.id == self.submitter: + raise ValidationError("You cannot refer to the submitter") + # Check if the referral has already been sent to this user if OccurrenceReportReferral.objects.filter( referral=referee.id, occurrence_report=self @@ -1444,9 +1475,7 @@ def update_occurrence_report_referral_doc_filename(instance, filename): class OccurrenceReportProposalRequest(models.Model): occurrence_report = models.ForeignKey(OccurrenceReport, on_delete=models.CASCADE) - subject = models.CharField(max_length=200, blank=True) text = models.TextField(blank=True) - officer = models.IntegerField(null=True) # EmailUserRO class Meta: app_label = "boranga" @@ -1827,7 +1856,13 @@ class CoordinateSource(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -1849,7 +1884,13 @@ class LocationAccuracy(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -1887,8 +1928,6 @@ class OCRLocation(models.Model): location_accuracy = models.ForeignKey( LocationAccuracy, on_delete=models.SET_NULL, null=True, blank=True ) - geojson_point = gis_models.PointField(srid=4326, blank=True, null=True) - geojson_polygon = gis_models.PolygonField(srid=4326, blank=True, null=True) region = models.ForeignKey( Region, default=None, on_delete=models.CASCADE, null=True, blank=True @@ -2154,7 +2193,13 @@ class LandForm(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -2175,7 +2220,13 @@ class RockType(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -2196,7 +2247,13 @@ class SoilType(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -2217,7 +2274,13 @@ class SoilColour(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -2238,7 +2301,13 @@ class Drainage(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -2259,7 +2328,13 @@ class SoilCondition(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -2287,6 +2362,8 @@ class OCRHabitatComposition(models.Model): null=True, related_name="habitat_composition", ) + # TODO: Consider fixing these to use a function that returns the choices + # as setting them in the __init__ method creates issues in other parts of the application land_form = MultiSelectField(max_length=250, blank=True, choices=[], null=True) rock_type = models.ForeignKey( RockType, on_delete=models.SET_NULL, null=True, blank=True @@ -2421,7 +2498,13 @@ class Intensity(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -2498,7 +2581,13 @@ class ObservationMethod(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -2548,7 +2637,13 @@ class PlantCountMethod(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -2569,7 +2664,13 @@ class PlantCountAccuracy(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -2590,7 +2691,13 @@ class CountedSubject(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -2611,7 +2718,13 @@ class PlantCondition(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -2707,7 +2820,13 @@ class PrimaryDetectionMethod(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -2727,7 +2846,13 @@ class ReproductiveState(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -2748,7 +2873,13 @@ class AnimalHealth(models.Model): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -2769,7 +2900,13 @@ class DeathReason(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -2789,7 +2926,13 @@ class SecondarySign(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -2899,7 +3042,13 @@ class IdentificationCertainty(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -2920,7 +3069,9 @@ class SampleType(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False) + name = models.CharField( + max_length=250, blank=False, null=False, validators=[no_commas_validator] + ) group_type = models.ForeignKey( GroupType, on_delete=models.SET_NULL, null=True, blank=True ) @@ -2942,7 +3093,9 @@ class SampleDestination(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False) + name = models.CharField( + max_length=250, blank=False, null=False, validators=[no_commas_validator] + ) class Meta: app_label = "boranga" @@ -2961,7 +3114,9 @@ class PermitType(ArchivableModel): """ - name = models.CharField(max_length=250, blank=False, null=False) + name = models.CharField( + max_length=250, blank=False, null=False, validators=[no_commas_validator] + ) group_type = models.ForeignKey( GroupType, on_delete=models.SET_NULL, null=True, blank=True ) @@ -3197,7 +3352,13 @@ def source(self): class WildStatus(ArchivableModel): - name = models.CharField(max_length=250, blank=False, null=False, unique=True) + name = models.CharField( + max_length=250, + blank=False, + null=False, + unique=True, + validators=[no_commas_validator], + ) class Meta: app_label = "boranga" @@ -3245,7 +3406,9 @@ class Occurrence(RevisionedMixin): occurrence_number = models.CharField(max_length=9, blank=True, default="") # Field to use when importing data from the legacy system - migrated_from_id = models.CharField(max_length=50, blank=True, default="") + migrated_from_id = models.CharField( + max_length=50, blank=True, null=True, unique=True + ) occurrence_name = models.CharField( max_length=250, blank=True, null=True, unique=True @@ -4188,8 +4351,6 @@ class OCCLocation(models.Model): location_accuracy = models.ForeignKey( LocationAccuracy, on_delete=models.SET_NULL, null=True, blank=True ) - geojson_point = gis_models.PointField(srid=4326, blank=True, null=True) - geojson_polygon = gis_models.PolygonField(srid=4326, blank=True, null=True) region = models.ForeignKey( Region, default=None, on_delete=models.CASCADE, null=True, blank=True @@ -4846,8 +5007,12 @@ def full_name(self): class OccurrenceTenurePurpose(ArchivableModel): - label = models.CharField(max_length=100, blank=True, null=True) - code = models.CharField(max_length=20, blank=True, null=True) + label = models.CharField( + max_length=100, blank=True, null=True, validators=[no_commas_validator] + ) + code = models.CharField( + max_length=20, blank=True, null=True, validators=[no_commas_validator] + ) class Meta: app_label = "boranga" @@ -4859,8 +5024,12 @@ def __str__(self): class OccurrenceTenureVesting(models.Model): - label = models.CharField(max_length=100, blank=True, null=True) - code = models.CharField(max_length=20, blank=True, null=True) + label = models.CharField( + max_length=100, blank=True, null=True, validators=[no_commas_validator] + ) + code = models.CharField( + max_length=20, blank=True, null=True, validators=[no_commas_validator] + ) class Meta: app_label = "boranga" @@ -5110,15 +5279,26 @@ def validate_bulk_import_file_extension(value): ext = os.path.splitext(value.name)[1] valid_extensions = [".xlsx"] if ext not in valid_extensions: - raise ValidationError( - "Only .xlsx files are supported by the bulk import facility!" - ) + raise ValidationError("The bulk import file must be a .xlsx file") + +def validate_bulk_import_associated_files_extension(value): + ext = os.path.splitext(value.name)[1] + valid_extensions = [".zip"] + if ext not in valid_extensions: + raise ValidationError("The associated documents file must be a .zip file") + +# TODO: Would be nice to have the object id in the file path +# bit tricky before the object has been saved.. def get_occurrence_report_bulk_import_path(instance, filename): return f"occurrence_report/bulk-imports/{timezone.now()}/{filename}" +def get_occurrence_report_bulk_import_associated_files_path(instance, filename): + return f"occurrence_report/bulk-imports/{timezone.now()}/{filename}" + + class OccurrenceReportBulkImportTask(ArchivableModel): schema = models.ForeignKey( "OccurrenceReportBulkImportSchema", @@ -5132,6 +5312,14 @@ class OccurrenceReportBulkImportTask(ArchivableModel): storage=private_storage, validators=[validate_bulk_import_file_extension], ) + _associated_files_zip = models.FileField( + upload_to=get_occurrence_report_bulk_import_associated_files_path, + max_length=512, + storage=private_storage, + validators=[validate_bulk_import_associated_files_extension], + null=True, + blank=True, + ) # A hash of the file to allow for duplicate detection file_hash = models.CharField(max_length=64, null=True, blank=True) @@ -5330,9 +5518,7 @@ def validate_headers(self, _file, schema): try: workbook = openpyxl.load_workbook(_file, read_only=True) except Exception as e: - logger.error(f"Error opening bulk import file {_file.name}: {e}") - logger.error("Unable to validate headers.") - return + raise ValidationError(f"Error opening bulk import file {_file}: {e}") sheet = workbook.active @@ -5362,6 +5548,7 @@ def validate_headers(self, _file, schema): error_string += f" The file has the following headers that are not part of the schema: {extra_headers}" raise ValidationError(error_string) + @transaction.atomic def process(self): if self.processing_status == self.PROCESSING_STATUS_COMPLETED: logger.info(f"Bulk import task {self.id} has already been processed") @@ -5401,6 +5588,9 @@ def process(self): # Get the first sheet sheet = workbook.active + # headers + headers = [cell.value for cell in sheet[1] if cell.value is not None] + # Get the rows rows = list( sheet.iter_rows( @@ -5412,7 +5602,7 @@ def process(self): ) errors = [] - + ocr_migrated_from_ids = [] # Process the rows for index, row in enumerate(rows): self.rows_processed = index + 1 @@ -5425,34 +5615,22 @@ def process(self): self.save() - self.process_row(index, row, errors) + self.process_row(ocr_migrated_from_ids, index, headers, row, errors) - if errors: - self.processing_status = ( - OccurrenceReportBulkImportTask.PROCESSING_STATUS_FAILED - ) - self.datetime_error = timezone.now() - self.error_message = "Errors occurred during processing:\n" - for error in errors: - self.error_message += error["error_message"] + "\n" - else: - # Set the task to completed - self.processing_status = ( - OccurrenceReportBulkImportTask.PROCESSING_STATUS_COMPLETED - ) - self.datetime_completed = timezone.now() - self.save() + if errors and len(errors) > 0: + # If a single thing went wrong roll back everything + # Imports either work completely or fail completely + transaction.set_rollback(True) return errors - def process_row(self, index, row, errors): - logger.debug(f"Processing row: Index {index}, Data: {row}") + def process_row(self, ocr_migrated_from_ids, index, headers, row, errors): row_hash = hashlib.sha256(str(row).encode()).hexdigest() if OccurrenceReport.objects.filter(import_hash=row_hash).exists(): duplicate_ocr = OccurrenceReport.objects.get(import_hash=row_hash) error_message = ( f"Row {index} has the exact same data as " - f"Occurrence Report {duplicate_ocr.occurrence_report_number}" + f"Occurrence Report {duplicate_ocr.occurrence_report_number} did when it was imported." ) errors.append( { @@ -5464,28 +5642,78 @@ def process_row(self, index, row, errors): ) return + ocr_migrated_from_id = row[0] + mode = "create" + if ( + ocr_migrated_from_id in ocr_migrated_from_ids + or OccurrenceReport.objects.filter( + migrated_from_id=ocr_migrated_from_id + ).exists() + ): + mode = "update" + + if ocr_migrated_from_id not in ocr_migrated_from_ids: + # Because this is happening in a transaction we need to keep track of + # the ocr_migrated_from_ids that have been processed in this transaction + ocr_migrated_from_ids.append(ocr_migrated_from_id) + row_error_count = 0 total_column_error_count = 0 models = {} + geometries = {} + many_to_many_fields = {} # Validate each cell - for index, column in enumerate(self.schema.columns.all()): - # logger.debug(f" Processing column: {column}") - - # logger.debug(f" Cell value: {row[index]}") + for column_index, column in enumerate(self.schema.columns.all()): column_error_count = 0 - cell_value = row[index] + cell_value = row[column_index] + + cell_value, errors_added = column.validate( + self, cell_value, mode, index, headers, row, errors + ) + + model_class = apps.get_model( + "boranga", column.django_import_content_type.model + ) + field = model_class._meta.get_field(column.django_import_field_name) + + # Special case: geojson feature collection + # TODO: Consider modifying this so that it can support multiple geometry fields + # in the same model like the m2m one does below + if ( + type(field) is gis_models.GeometryField + and type(cell_value) is list + and len(cell_value) > 0 + ): + # Store every feature / geometry in the geometries dict + geometries[model_class._meta.model_name] = cell_value + + # Set the first geometry as the cell value so that it is + # created with the main model instance + cell_value = geometries[model_class._meta.model_name][0] + + # Special case: Many to many fields + if type(field) is ManyToManyField: + # Store the many to many field in the many_to_many_fields dict + # As you can't assign a list of models directly to a many to many field + # via model_data + if model_class._meta.model_name not in many_to_many_fields: + many_to_many_fields[model_class._meta.model_name] = [] + many_to_many_fields[model_class._meta.model_name].append( + {"field": column.django_import_field_name, "value": cell_value} + ) - cell_value, errors_added = column.validate(cell_value, index, errors) + # Continue to the next column without adding the cell value to the model data + continue column_error_count += errors_added row_error_count += column_error_count total_column_error_count += column_error_count - if column_error_count: + if column_error_count > 0: continue model_name = column.django_import_content_type.model @@ -5501,16 +5729,6 @@ def process_row(self, index, row, errors): model_instances = {} for current_model_name in models: - logger.debug(f"Processing model: {current_model_name}") - mode = "create" - - # If we are at the top level model, check if we are creating a new instance or updating an existing one - if ( - current_model_name == OccurrenceReport._meta.model_name - and OccurrenceReport.objects.filter(migrated_from_id=row[0]).exists() - ): - mode = "update" - model_data = dict( zip( models[current_model_name]["field_names"], @@ -5523,34 +5741,153 @@ def process_row(self, index, row, errors): "boranga", current_model_name, ) - current_model_instance = model_class(**model_data) - logger.debug( - f"{current_model_name}.__dict__: {current_model_instance.__dict__}" - ) + if mode == "update": + # Remove empty values from the model data + model_data = {k: v for k, v in model_data.items() if v is not None} + + if current_model_name == OccurrenceReport._meta.model_name: + # Remove the migrated_from_id field as we don't want to update it + model_data.pop("migrated_from_id", None) # For OccurrenceReport check if we are creating or updating # and set appropriate fields if so if current_model_name == OccurrenceReport._meta.model_name: if mode == "create": + current_model_instance = OccurrenceReport(**model_data) current_model_instance.bulk_import_task_id = self.pk current_model_instance.import_hash = row_hash current_model_instance.group_type_id = self.schema.group_type_id + current_model_instance.submitter = self.email_user + current_model_instance.lodgement_date = timezone.now() else: - current_model_instance.pk = OccurrenceReport.objects.get( + current_model_instance = OccurrenceReport.objects.get( migrated_from_id=row[0] - ).pk + ) + for field, value in model_data.items(): + setattr(current_model_instance, field, value) + elif current_model_name == Occurrence._meta.model_name: + occ_migrated_from_id = model_data.pop("migrated_from_id", None) + occurrence_number = model_data.pop("occurrence_number", None) + + if occ_migrated_from_id and occurrence_number: + error_message = ( + "Both migrated_from_id and occurrence_number were provided. " + "Please provide only one of these fields." + ) + errors.append( + { + "row_index": index, + "error_type": "ambiguous_occurrence_idenfitier", + "data": model_data, + "error_message": error_message, + } + ) + return + + if occurrence_number: + try: + current_model_instance = Occurrence.objects.get( + occurrence_number=occurrence_number + ) + except Occurrence.DoesNotExist: + error_message = "The occurrence number provided does not exist in the database" + errors.append( + { + "row_index": index, + "error_type": "invalid_occurrence_number", + "data": model_data, + "error_message": error_message, + } + ) + return + else: + if not occ_migrated_from_id: + if not model_data.get("group_type"): + model_data["group_type"] = self.schema.group_type + current_model_instance = Occurrence(**model_data) + else: + if Occurrence.objects.filter( + migrated_from_id=occ_migrated_from_id + ).exists(): + current_model_instance = Occurrence.objects.get( + migrated_from_id=occ_migrated_from_id + ) + else: + current_model_instance = Occurrence.objects.create( + migrated_from_id=occ_migrated_from_id, + group_type=self.schema.group_type, + species=model_instances[ + OccurrenceReport._meta.model_name + ].species, + ) + + if ( + not current_model_instance.group_type + == self.schema.group_type + ): + error_message = ( + "The group type of the occurrence does not " + "match the group type of the schema" + ) + errors.append( + { + "row_index": index, + "error_type": "invalid_occurrence_group_type", + "data": model_data, + "error_message": error_message, + } + ) + return + + occurrence_report = model_instances[ + OccurrenceReport._meta.model_name + ] + if ( + not current_model_instance.species + == occurrence_report.species + ): + error_message = ( + "The species of the occurrence does not match " + "the species of the occurrence report" + ) + errors.append( + { + "row_index": index, + "error_type": "invalid_occurrence_species", + "data": model_data, + "error_message": error_message, + } + ) + return + + for field, value in model_data.items(): + setattr(current_model_instance, field, value) + + elif current_model_name == SubmitterInformation._meta.model_name: + # Submitter information is created automatically when an OccurrenceReport is created + occurrence_report = model_instances[OccurrenceReport._meta.model_name] + current_model_instance = occurrence_report.submitter_information + for field, value in model_data.items(): + setattr(current_model_instance, field, value) + else: + current_model_instance = model_class(**model_data) # If we are at the top level model (OccurrenceReport) we don't need to relate it to anything - if not current_model_name == OccurrenceReport._meta.model_name: + # We also don't need to relate the Occurrence model to anything here as it is dealt with + # as a special case further down + # TODO: Optimize this relationships finder to minimise looping and move to a separate function + if current_model_name not in [ + OccurrenceReport._meta.model_name, + Occurrence._meta.model_name, + SubmitterInformation._meta.model_name, + ]: # Relate this model to it's parent instance related_to_parent = False + # Look through all the model instances that have already been saved + for potential_parent_model_key in [m for m in model_instances]: - # Look through all the models being imported except for the current model - for potential_parent_model_key in [ - m for m in models if m != current_model_name - ]: # Check if this model has a relationship with the current model potential_parent_instance = model_instances[ potential_parent_model_key @@ -5561,8 +5898,6 @@ def process_row(self, index, row, errors): # to the parent model for field in current_model_instance._meta.get_fields(): if field.related_model == potential_parent_instance.__class__: - logger.debug(f" ---> {field} is a relationship") - # If it does, set the relationship setattr( current_model_instance, @@ -5572,14 +5907,9 @@ def process_row(self, index, row, errors): related_to_parent = True break - if related_to_parent: - break - # If we didn't find a relationship in the current model, search the parent model for field in potential_parent_instance.__class__._meta.get_fields(): if field.related_model == current_model_instance: - logger.debug(f" ---> {field} is a relationship") - # If it does, set the relationship setattr( current_model_instance, @@ -5589,9 +5919,6 @@ def process_row(self, index, row, errors): related_to_parent = True break - if related_to_parent: - break - if not related_to_parent: error_message = ( "Could not find a parent model to relate this model to " @@ -5608,11 +5935,49 @@ def process_row(self, index, row, errors): return try: - current_model_instance.save() + if mode == "create" or (mode == "update" and len(model_data.keys())): + current_model_instance.save() + + self.ocr_bulk_import_generate_action_logs(mode, current_model_instance) + model_instances[current_model_instance._meta.model_name] = ( current_model_instance ) - logger.debug(f"Model instance created: {current_model_instance}") + logger.info(f"Model instance saved: {current_model_instance}") + + # Deal with special case of relating Occurrence to OccurrenceReport + if ( + current_model_instance._meta.model_name + == Occurrence._meta.model_name + ): + current_model_instance.occurrence_reports.add( + model_instances[OccurrenceReport._meta.model_name] + ) + + # Deal with special case of creating mutliple geometries based on the + # geojson text from the column + if current_model_instance._meta.model_name in geometries: + logger.info( + f"Creating multiple geometries for {current_model_instance}" + ) + for geometry in geometries[current_model_instance._meta.model_name][ + 1: + ]: + current_model_instance.pk = None + current_model_instance.geometry = geometry + current_model_instance.save() + + # Deal with special case of many to many fields + if current_model_instance._meta.model_name in many_to_many_fields: + logger.info( + f"Adding many to many fields for {current_model_instance}" + ) + for m2m_field in many_to_many_fields[ + current_model_instance._meta.model_name + ]: + field = getattr(current_model_instance, m2m_field["field"]) + field.set(m2m_field["value"]) + except IntegrityError as e: logger.error(f"Error creating model instance: {e}") errors.append( @@ -5626,6 +5991,57 @@ def process_row(self, index, row, errors): return + def ocr_bulk_import_generate_action_logs(self, mode, model_instance): + if not model_instance._meta.model_name == OccurrenceReport._meta.model_name: + return + + log_suffix = " (via bulk importer)" + bulk_importer = EmailUser.objects.get(id=self.email_user) + request = get_mock_request(bulk_importer) + if mode == "create": + model_instance.log_user_action( + OccurrenceReportUserAction.ACTION_LODGE_PROPOSAL.format( + model_instance.occurrence_report_number + ) + + log_suffix, + request, + ) + if model_instance.processing_status in [ + OccurrenceReport.PROCESSING_STATUS_WITH_APPROVER, + OccurrenceReport.PROCESSING_STATUS_APPROVED, + ]: + assessor = EmailUser.objects.get(id=model_instance.assigned_officer) + request = get_mock_request(assessor) + model_instance.log_user_action( + OccurrenceReportUserAction.ACTION_PROPOSED_APPROVAL.format( + model_instance.occurrence_report_number + ) + + log_suffix, + request, + ) + if ( + model_instance.processing_status + == OccurrenceReport.PROCESSING_STATUS_APPROVED + ): + approver = EmailUser.objects.get(id=model_instance.assigned_approver) + request = get_mock_request(approver) + model_instance.log_user_action( + OccurrenceReportUserAction.ACTION_APPROVE.format( + model_instance.occurrence_report_number, + approver.get_full_name(), + ) + + log_suffix, + request, + ) + elif mode == "update": + model_instance.log_user_action( + OccurrenceReportUserAction.ACTION_EDIT_APPLICATION.format( + model_instance.occurrence_report_number + ) + + log_suffix, + request, + ) + def retry(self): self.processing_status = self.PROCESSING_STATUS_QUEUED self.datetime_started = None @@ -5636,8 +6052,7 @@ def retry(self): self.save() def revert(self): - # TODO: Using delete here due to the sheer number of records that could be created - # Still need to consider if we want to archive them + # Note: Using delete here due to the sheer number of records that could be created OccurrenceReport.objects.filter(bulk_import_task=self).delete() self.processing_status = self.PROCESSING_STATUS_ARCHIVED @@ -5653,7 +6068,8 @@ class OccurrenceReportBulkImportSchema(models.Model): name = models.CharField(max_length=255, blank=True, null=True) tags = TaggableManager(blank=True) datetime_created = models.DateTimeField(auto_now_add=True) - datetime_updated = models.DateTimeField(default=datetime.now) + datetime_updated = models.DateTimeField(auto_now=True) + is_master = models.BooleanField(default=False) class Meta: app_label = "boranga" @@ -5689,6 +6105,23 @@ def save(self, *args, **kwargs): django_import_field_name="migrated_from_id", ) + @property + def mandatory_fields(self): + if self.group_type.name == "community": + return [ + "migrated_from_id", + "community", + "processing_status", + "assigned_officer", + ] + + return [ + "migrated_from_id", + "species", + "processing_status", + "assigned_officer", + ] + @property def preview_import_file(self): workbook = openpyxl.Workbook() @@ -5719,25 +6152,45 @@ def preview_import_file(self): f"Model {model_class} does not have field {column.django_import_field_name}" ) model_field = model_class._meta.get_field(column.django_import_field_name) - logger.debug(f"model_field_type: {type(model_field)}") + + allow_blank = model_field.null + if allow_blank and column.xlsx_data_validation_allow_blank is False: + allow_blank = False + dv = None if column.default_value is not None: dv = DataValidation( type=dv_types["list"], - allow_blank=model_field.null, + allow_blank=allow_blank, formula1=column.default_value, error=f"This field may only contain the value '{column.default_value}'", errorTitle="Invalid value for column with default value", prompt="Either leave the field blank or enter the default value", promptTitle="Value", ) + elif isinstance( + model_field, MultiSelectField + ): # MultiSelectField is a custom field, not a standard Django field + # Unfortunately there is no easy way to embed validation in .xlsx + # for a comma separated list of values so this will be validated + # during the import process + continue elif ( isinstance(model_field, models.fields.CharField) and model_field.choices ): + choices = [c[0] for c in model_field.choices] + if ( + model_class is OccurrenceReport + and model_field.name == "processing_status" + ): + choices = [ + c[0] + for c in OccurrenceReport.VALID_BULK_IMPORT_PROCESSING_STATUSES + ] dv = DataValidation( type=dv_types["list"], - allow_blank=model_field.null, - formula1=",".join([c[0] for c in model_field.choices]), + allow_blank=allow_blank, + formula1=",".join(choices), error="Please select a valid option from the list", errorTitle="Invalid selection", prompt="Select a value from the list", @@ -5746,7 +6199,7 @@ def preview_import_file(self): elif isinstance(model_field, models.fields.CharField): dv = DataValidation( type=dv_types["textLength"], - allow_blank=model_field.null, + allow_blank=allow_blank, operator=dv_operators["lessThanOrEqual"], formula1=f"{model_field.max_length}", error="Text must be less than or equal to {model_field.max_length} characters", @@ -5761,7 +6214,7 @@ def preview_import_file(self): type=dv_types["date"], operator=dv_operators["greaterThanOrEqual"], formula1="1900-01-01", - allow_blank=model_field.null, + allow_blank=allow_blank, error="Please enter a valid date", errorTitle="Invalid date", prompt="Enter a date", @@ -5773,12 +6226,15 @@ def preview_import_file(self): ) for cell in worksheet[column_letter]: cell.style = date_style - elif isinstance(model_field, models.fields.IntegerField): + elif ( + isinstance(model_field, models.fields.IntegerField) + and column.is_emailuser_column is False + ): dv = DataValidation( type=dv_types["whole"], operator=dv_operators["greaterThanOrEqual"], formula1="0", - allow_blank=model_field.null, + allow_blank=allow_blank, error="Please enter a whole number", errorTitle="Invalid number", prompt="Enter a whole number", @@ -5787,7 +6243,7 @@ def preview_import_file(self): elif isinstance(model_field, models.fields.DecimalField): dv = DataValidation( type=dv_types["decimal"], - allow_blank=model_field.null, + allow_blank=allow_blank, error="Please enter a decimal number", errorTitle="Invalid number", prompt="Enter a decimal number", @@ -5796,7 +6252,7 @@ def preview_import_file(self): elif isinstance(model_field, models.fields.BooleanField): dv = DataValidation( type=dv_types["list"], - allow_blank=model_field.null, + allow_blank=allow_blank, formula1='"True,False"', error="Please select True or False", errorTitle="Invalid selection", @@ -5817,6 +6273,7 @@ def preview_import_file(self): if ( not related_model_qs.exists() or related_model_qs.count() == 0 + or related_model._meta.model_name in ["species", "community"] or related_model_qs.count() > settings.OCR_BULK_IMPORT_LOOKUP_TABLE_RECORD_LIMIT ): @@ -5828,7 +6285,7 @@ def preview_import_file(self): dv = DataValidation( type=dv_types["list"], - allow_blank=model_field.null, + allow_blank=allow_blank, formula1=f'"{",".join([str(getattr(obj, display_field)) for obj in related_model_qs])}"', error="Please select a valid option from the list", errorTitle="Invalid selection", @@ -5863,48 +6320,264 @@ def preview_import_file(self): return workbook @transaction.atomic - def copy(self): - if not self.pk: - raise ValueError("Schema must be saved before it can be copied") + def validate(self, request_user_id: int): + # Tries adding valid sample data to each column and then saving to the database + # Returns any errors that occur + # Rolls back the transaction regardless of success or failure - if OccurrenceReportBulkImportSchema.objects.filter( - group_type=self.group_type - ).exists(): - highest_version = OccurrenceReportBulkImportSchema.objects.filter( - group_type=self.group_type - ).aggregate(Max("version"))["version__max"] - else: - highest_version = 0 - new_schema = OccurrenceReportBulkImportSchema( - group_type=self.group_type, - version=highest_version + 1, - ) - if self.name: - new_schema.name = f"{self.name} (Copy)" - else: - new_schema.name = f"Copy of Version {self.version}" - new_schema.save() - django_import_content_type = ct_models.ContentType.objects.get_for_model( - OccurrenceReport - ) - # Copy all columns except those that were automatically created + errors = [] + + columns = self.columns.all() + if not columns.exists() or columns.count() == 0: + errors.append( + { + "error_type": "no_columns", + "error_message": "No columns found in the schema", + } + ) + return errors + + for field in self.mandatory_fields: + if not columns.filter( + django_import_content_type=ct_models.ContentType.objects.get_for_model( + OccurrenceReport + ), + django_import_field_name=field, + ).exists(): + errors.append( + { + "error_type": "missing_mandatory_column", + "error_message": f"Missing mandatory occurrence report column for django field: {field}", + } + ) + return errors + + # Create a sample row of data + sample_row = [] + + # Special case where only a migrated_from_id or occurrence_number + # Should be provided when both columns are present otherwise validation will fail + row_contains_occ_migrated_from_id = ( + columns.filter( + django_import_content_type=ct_models.ContentType.objects.get_for_model( + Occurrence + ), + django_import_field_name="migrated_from_id", + ).exists() + and columns.filter( + django_import_content_type=ct_models.ContentType.objects.get_for_model( + Occurrence + ), + django_import_field_name="occurrence_number", + ).exists() + ) + species_or_community_identifier = None + for column in columns: + sample_value = column.get_sample_value( + errors, species_or_community_identifier + ) + if ( + column.django_import_content_type.model == Occurrence._meta.model_name + and column.django_import_field_name == "species" + ): + species_or_community_identifier = Species.objects.get( + taxonomy__scientific_name=sample_value + ) + + if ( + column.django_import_content_type.model == Occurrence._meta.model_name + and column.django_import_field_name == "community" + ): + species_or_community_identifier = Community.objects.get( + taxonomy__community_migrated_id=sample_value + ) + + if ( + column.django_import_content_type.model == Occurrence._meta.model_name + and column.django_import_field_name == "migrated_from_id" + and row_contains_occ_migrated_from_id + ): + sample_value = "" + sample_row.append(sample_value) + + logger.info(f"Sample row: {sample_row}") + + preview_import_file = self.preview_import_file + + # Write the sample row to the file + sheet = preview_import_file.active + sheet.append(sample_row) + + # Convert the import file to a Django File object + import_file_name = f"sample_{self.group_type.name}_{self.version}.xlsx" + import_file = InMemoryUploadedFile( + preview_import_file, + "_file", + import_file_name, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + None, + None, + ) + sample_associated_file = InMemoryUploadedFile( + BytesIO(b"I am a test file"), + "file", + "sample_file.txt", + "text/plain", + 0, + None, + ) + # Create a .zip file to house the sample associated file + zip_buffer = BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zip_file: + zip_file.writestr( + sample_associated_file.name, sample_associated_file.read() + ) + zip_file_name = f"sample_{self.group_type.name}_{self.version}.zip" + zip_file = InMemoryUploadedFile( + zip_buffer, + "file", + zip_file_name, + "application/zip", + 0, + None, + ) + + import_task = OccurrenceReportBulkImportTask( + schema=self, + _file=import_file, + _associated_files_zip=zip_file, + rows=1, + email_user=request_user_id, + ) + + # Convert the file to bytes + buffer = BytesIO() + preview_import_file.save(buffer) + buffer.seek(0) + + try: + import_task.validate_headers(buffer, self) + except ValidationError as e: + logger.error(f"Error validating headers: {e}") + logger.error(traceback.format_exc()) + errors.append( + { + "error_type": "header_validation", + "error_message": e.message, + } + ) + transaction.set_rollback(True) + return errors + + headers = [cell.value for cell in sheet[1] if cell.value is not None] + + # Get the rows + row = list( + sheet.iter_rows( + min_row=2, + max_row=2, + max_col=self.columns.count(), + values_only=True, + ) + )[0] + + ocr_migrated_from_ids = [] + + try: + import_task.process_row(ocr_migrated_from_ids, 0, headers, row, errors) + except Exception as e: + logger.error(f"Error processing sample row: {e}") + logger.error(traceback.format_exc()) + errors.append( + { + "error_type": "row_validation", + "error_message": f"Error processing sample row: {e}", + } + ) + transaction.set_rollback(True) + return errors + + transaction.set_rollback(True) + return errors + + @transaction.atomic + def copy(self, request): + if not self.pk: + raise ValueError("Schema must be saved before it can be copied") + + if OccurrenceReportBulkImportSchema.objects.filter( + group_type=self.group_type + ).exists(): + highest_version = OccurrenceReportBulkImportSchema.objects.filter( + group_type=self.group_type + ).aggregate(Max("version"))["version__max"] + else: + highest_version = 0 + + new_schema = OccurrenceReportBulkImportSchema( + group_type=self.group_type, + version=highest_version + 1, + ) + # If the user copying the schema has elevated permissions, the new schema + # will also be a master schema otherwise it will be a regular schema + if self.is_master and is_django_admin(request): + new_schema.is_master = True + + if self.name: + new_schema.name = f"{self.name} (Copy)" + else: + new_schema.name = f"Copy of Version {self.version}" + new_schema.save() + django_import_content_type = ct_models.ContentType.objects.get_for_model( + OccurrenceReport + ) + # Copy all columns except those that were automatically created for column in self.columns.exclude( django_import_content_type=django_import_content_type, django_import_field_name="migrated_from_id", ): - new_column = OccurrenceReportBulkImportSchemaColumn.objects.get( - pk=column.pk - ) - new_column.pk = None + # Note: Due to an issue in django-ordered-model you can't use the + # method of cloning objects here where to make a copy of the object + # you set the pk to None and save it (that will mess up the ordering of + # the original schema's columns) Strange bug but can't be bothered looking into it further + new_column = OccurrenceReportBulkImportSchemaColumn() + for field in column._meta.fields: + if field.name == "id": + continue + setattr(new_column, field.name, getattr(column, field.name)) new_column.schema = new_schema new_column.save() + for lookup_filter in column.lookup_filters.all(): + new_lookup_filter = SchemaColumnLookupFilter.objects.create( + schema_column=new_column, + filter_field_name=lookup_filter.filter_field_name, + filter_type=lookup_filter.filter_type, + ) + + for value in lookup_filter.values.all(): + SchemaColumnLookupFilterValue.objects.create( + lookup_filter=new_lookup_filter, + filter_value=value.filter_value, + ) + new_schema.tags.add(*self.tags.all()) + if self.is_master and not is_django_admin(request): + # Columns copied from a 'master' schema will not be editable + # for occurrence approvers (only by superusers and django admins) + new_schema.columns.all().update(is_editable=False) + return new_schema +class OccurrenceReportBulkImportSchemaColumnManager(OrderedModelManager): + def get_queryset(self): + return super().get_queryset().prefetch_related("lookup_filters") + + class OccurrenceReportBulkImportSchemaColumn(OrderedModel): + objects = OccurrenceReportBulkImportSchemaColumnManager() schema = models.ForeignKey( OccurrenceReportBulkImportSchema, related_name="columns", @@ -5920,51 +6593,30 @@ class OccurrenceReportBulkImportSchemaColumn(OrderedModel): related_name="import_columns", ) django_import_field_name = models.CharField(max_length=50, blank=False, null=False) - django_lookup_field_name = models.CharField( - max_length=50, default="id", blank=True, null=True - ) + django_lookup_field_name = models.CharField(max_length=50, blank=True, null=True) # The name of the column header in the .xlsx file xlsx_column_header_name = models.CharField(max_length=50, blank=False, null=False) - # The following fields are used to embed data validation in the .xlsx file - # so that the users can do a quick check before uploading - xlsx_data_validation_type = models.CharField( - max_length=20, - choices=sorted( - [(x, x) for x in DataValidation.type.values], - key=lambda x: (x[0] is None, x), - ), - null=True, - blank=True, - ) + # This field allows the user that is generating the schema the ability to + # make non mandatory fields mandatory for a specific schema xlsx_data_validation_allow_blank = models.BooleanField(default=False) - xlsx_data_validation_operator = models.CharField( - max_length=20, - choices=sorted( - [(x, x) for x in DataValidation.operator.values], - key=lambda x: (x[0] is None, x), - ), - null=True, - blank=True, - ) - xlsx_data_validation_formula1 = models.CharField( - max_length=50, blank=True, null=True - ) - xlsx_data_validation_formula2 = models.CharField( - max_length=50, blank=True, null=True - ) + + # Columns copied from a 'master' schema will not be editable + # by occurrence approvers (only by superusers and django admins) + is_editable = models.BooleanField(default=True) order_with_respect_to = "schema" - DEFAULT_VALUE_REQUEST_USER_ID = "request_user_id" - DEFAULT_VALUE_CHOICES = ((DEFAULT_VALUE_REQUEST_USER_ID, "Request User ID"),) + DEFAULT_VALUE_BULK_IMPORT_SUBMITTER = "bulk_import_submitter" + DEFAULT_VALUE_CHOICES = ( + (DEFAULT_VALUE_BULK_IMPORT_SUBMITTER, "Bulk Import Submitter"), + ) default_value = models.CharField( max_length=255, choices=DEFAULT_VALUE_CHOICES, blank=True, null=True ) - - # TODO: How are we going to do the list lookup validation for much larger datasets (mostly for species) + is_emailuser_column = models.BooleanField(default=False) class Meta(OrderedModel.Meta): app_label = "boranga" @@ -5996,149 +6648,1014 @@ class Meta(OrderedModel.Meta): def __str__(self): return f"{self.xlsx_column_header_name} - {self.schema}" - def validate(self, cell_value, index, errors): - errors_added = 0 - if not self.xlsx_data_validation_allow_blank and not cell_value: + @property + def model_name(self): + if not self.django_import_content_type: + return None + + model = self.django_import_content_type.model_class() + return model._meta.verbose_name + + @property + def field(self): + if not self.django_import_content_type or not self.django_import_field_name: + return None + + return self.django_import_content_type.model_class()._meta.get_field( + self.django_import_field_name + ) + + @property + def field_type(self): + if not self.field: + return None + + return self.field.get_internal_type() + + @property + def xlsx_validation_type(self): + if not self.field: + return None + + return get_openpyxl_data_validation_type_for_django_field( + self.field, column=self + ) + + @property + def text_length(self): + if not self.field: + return None + + if not isinstance(self.field, models.CharField): + return 32767 + + return self.field.max_length + + @property + def choices(self): + if not self.field: + return None + + model_class = self.django_import_content_type.model_class() + + return get_choices_for_field(model_class, self.field) + + @cached_property + def related_model(self): + if not self.django_import_content_type or not self.django_import_field_name: + return None + + field = self.django_import_content_type.model_class()._meta.get_field( + self.django_import_field_name + ) + if not isinstance(field, (models.ForeignKey, models.ManyToManyField)): + return None + + return field.related_model + + @cached_property + def display_field(self): + related_model = self.related_model + + if not related_model: + return None + + if self.django_lookup_field_name: + display_field = self.django_lookup_field_name + else: + display_field = get_display_field_for_model(related_model) + + return display_field + + @cached_property + def related_model_qs(self): + display_field = self.display_field + + filter_dict = {f"{display_field}__isnull": False} + related_model_qs = self.related_model.objects.filter(**filter_dict) + + if issubclass(self.related_model, ArchivableModel): + related_model_qs = self.related_model.objects.exclude(archived=True) + + if hasattr(self.related_model, "group_type"): + related_model_qs = related_model_qs.only(display_field, "group_type") + else: + related_model_qs = related_model_qs.only(display_field) + + return related_model_qs.order_by(display_field) + + @cached_property + def filtered_related_model_qs(self): + if not self.related_model_qs: + return None + + if not self.lookup_filters.exists(): + return self.related_model_qs + + related_model_qs = self.related_model_qs + + related_model = self.related_model + + # If the related model has a group_type field, filter by the schema's group type + # (i.e. flora, fauna or community) + if hasattr(related_model, "group_type"): + related_model_qs = related_model_qs.filter( + group_type=self.schema.group_type + ) + + # Apply any lookup filters if they exist + for lookup_filter in self.lookup_filters.all(): + lookup_filter.filter_field_name + "__" + lookup_filter.filter_type + lookup_filter_value = lookup_filter.values.first().filter_value + if lookup_filter.values.count() > 1: + lookup_filter_value = lookup_filter.values.values_list( + "filter_value", flat=True + ) + if lookup_filter.filter_type == "in" and not isinstance( + lookup_filter_value, list + ): + lookup_filter_value = [lookup_filter_value] + + related_model_qs = related_model_qs.filter( + **{ + lookup_filter.filter_field_name + + "__" + + lookup_filter.filter_type: lookup_filter_value + } + ) + + return related_model_qs + + @cached_property + def foreign_key_count(self): + if not self.related_model_qs: + return 0 + + # Don't return the filtered count here as if we add filters on the front end + # the count will change which will then result in the advanced lookup interface + # No longer being shown + return self.related_model_qs.count() + + @cached_property + def requires_lookup_field(self): + if not self.django_import_content_type or not self.django_import_field_name: + return False + + if ( + self.django_import_content_type + == ct_models.ContentType.objects.get_for_model(OccurrenceReport) + and self.django_import_field_name in ["species", "community"] + ): + return True + + return ( + self.foreign_key_count > settings.OCR_BULK_IMPORT_LOOKUP_TABLE_RECORD_LIMIT + ) + + @cached_property + def filtered_foreign_key_count(self): + if not self.filtered_related_model_qs: + return 0 + + return self.filtered_related_model_qs.count() + + def get_sample_value(self, errors, species_or_community_identifier=None): + if not self.django_import_content_type: errors.append( { - "row_index": index, - "error_type": "column", - "data": cell_value, - "error_message": f"Value in column {self.xlsx_column_header_name} is blank", + "error_type": "no_import_content_type", + "error_message": f"No import content type set for column {self}", } ) - errors_added += 1 + return None - if self.xlsx_data_validation_type == "textLength": - if len(cell_value) > int(self.xlsx_data_validation_formula1): - error_message = f"Value {cell_value} in column {self.xlsx_column_header_name} has too many characters" - errors.append( - { - "row_index": index, - "error_type": "column", - "data": cell_value, - "error_message": error_message, - } - ) - errors_added += 1 + if not self.django_import_field_name: + errors.append( + { + "error_type": "no_import_field_name", + "error_message": f"No import field name set for column {self}", + } + ) + return None - if self.xlsx_data_validation_type == "whole": - if not isinstance(cell_value, int): + try: + field = self.django_import_content_type.model_class()._meta.get_field( + self.django_import_field_name + ) + except FieldDoesNotExist: + error_message = ( + f"Field {self.django_import_field_name} not found in model " + f"{self.django_import_content_type.model_class()}" + ) + errors.append( + { + "error_type": "field_not_found", + "error_message": error_message, + } + ) + return None + + if ( + self.django_import_content_type + == ct_models.ContentType.objects.get_for_model(OccurrenceReport) + and self.django_import_field_name == "processing_status" + ): + # Special case as there are only 3 processing statuses allow for OCR + return random.choice( + [ + OccurrenceReport.PROCESSING_STATUS_WITH_ASSESSOR, + OccurrenceReport.PROCESSING_STATUS_WITH_APPROVER, + OccurrenceReport.PROCESSING_STATUS_APPROVED, + ] + ) + + if isinstance(field, models.ForeignKey): + related_model_qs = self.filtered_related_model_qs + + if not related_model_qs.exists(): errors.append( { - "row_index": index, - "error_type": "column", - "data": cell_value, - "error_message": f"Value {cell_value} in column {self.column_header_name} is not an integer", + "error_type": "no_records", + "error_message": f"No records found for foreign key {field.related_model._meta.model_name}", } ) - errors_added += 1 - if self.xlsx_data_validation_type == "decimal": - try: - cell_value = Decimal(cell_value) - except Exception: + display_field = self.display_field + + random_value = ( + related_model_qs.order_by("?") + .values_list(display_field, flat=True) + .first() + ) + + return random_value + + if isinstance(field, models.ManyToManyField): + related_model_qs = self.filtered_related_model_qs + + if not related_model_qs.exists(): + error_message = f"No records found for many to many field {field.related_model._meta.model_name}" errors.append( { - "row_index": index, - "error_type": "column", - "data": cell_value, - "error_message": f"Value {cell_value} in column {self.column_header_name} is not a decimal", + "error_type": "no_records", + "error_message": error_message, } ) - errors_added += 1 - if self.xlsx_data_validation_type == "date": - try: - cell_value = dateutil.parser.parse(cell_value) - except Exception: + display_field = self.display_field + + random_values = list( + related_model_qs.order_by("?") + .values_list(display_field, flat=True) + .distinct()[: random.randint(1, 3)] + ) + + return ",".join(random_values) + + if isinstance(field, MultiSelectField): + model_class = self.django_import_content_type.model_class() + # Unfortunatly have to have an actual model instance to get the choices + # as they are defined in __init__ + model_instance = model_class() + choices = model_instance._meta.get_field( + self.django_import_field_name + ).choices + if not choices or len(choices) == 0: errors.append( { - "row_index": index, - "error_type": "column", - "data": cell_value, - "error_message": f"Value {cell_value} in column {self.column_header_name} is not a date", + "error_type": "no_choices", + "error_message": f"No choices found for column {self.xlsx_column_header_name}", } ) - errors_added += 1 - - if self.xlsx_data_validation_type == "time": + return None + return random.choice([choice[1] for choice in choices]) + + if isinstance(field, models.BooleanField): + return random.choice([True, False]) + + if isinstance(field, models.DecimalField): + decimal_places = field.decimal_places if field.decimal_places else 2 + max_digits = field.max_digits if field.max_digits else 5 + max_value = 10 ** (max_digits - decimal_places - 1) + return round(random.uniform(0, max_value), decimal_places) + + if isinstance(field, models.IntegerField): + if self.is_emailuser_column: + user_qs = EmailUser.objects.filter(is_active=True) + if field.name == "assigned_officer": + ocr_officer_ids = member_ids( + settings.GROUP_NAME_OCCURRENCE_ASSESSOR + ) + user_qs = user_qs.filter(id__in=ocr_officer_ids) + if field.name == "assigned_approver": + ocr_approver_ids = member_ids( + settings.GROUP_NAME_OCCURRENCE_APPROVER + ) + user_qs = user_qs.filter(id__in=ocr_approver_ids) + user = user_qs.order_by("?").first() + return user.email + else: + min_value = field.min_value if hasattr(field, "min_value") else 0 + max_value = field.max_value if hasattr(field, "max_value") else 10000 + return random.randint(min_value, max_value) + + if isinstance(field, models.DateField): + start = datetime(1900, 1, 1, 0, 0, 0, tzinfo=dt_timezone.utc).date() + end = timezone.now().date() + random_date = start + (end - start) * random.random() + return random_date.strftime("%d/%m/%Y") + + if isinstance(field, models.DateTimeField): + start = datetime(1900, 1, 1, 0, 0, 0, tzinfo=dt_timezone.utc) + end = timezone.now() + random_datetime = start + (end - start) * random.random() + return random_datetime.strftime("%d/%m/%Y %H:%M:%S") + + if isinstance(field, (models.CharField, models.TextField)): + if ( + self.django_import_content_type.model_class() == Occurrence + and self.django_import_field_name == "occurrence_number" + ): + # Special case for occurrence number (Make sure that the occurrence is of the same + # group type as the schema and same species or community name as the sample data) + group_type = self.schema.group_type + random_occurrence = Occurrence.objects.filter(group_type=group_type) + filter_field = { + "species__taxonomy__scientific_name": species_or_community_identifier + } + if group_type.name == "community": + filter_field = { + "community__taxonomy__community_migrated_id": species_or_community_identifier + } + return ( + random_occurrence.filter(**filter_field) + .order_by("?") + .first() + .occurrence_number + ) + + if hasattr(field, "max_length") and field.max_length: + random_length = random.randint(1, field.max_length) + else: + random_length = random.randint(1, 1000) + return "".join( + random.choices( + string.ascii_letters + string.digits + " ", k=random_length + ) + ) + + if isinstance(field, gis_models.GeometryField): + # Generate a random point and polygon that falls within Western Australia + # In this case the dbca building in Kensington + return json.dumps( + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [115.8840195356077, -31.99563118840819], + }, + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [115.88337912139104, -31.995016820738698], + [115.88337912139104, -31.99586499034117], + [115.88434603648648, -31.99586499034117], + [115.88434603648648, -31.995016820738698], + [115.88337912139104, -31.995016820738698], + ] + ], + }, + }, + ], + } + ) + + if isinstance(field, models.FileField): + return "sample_file.txt" + + raise ValueError( + f"Not able to generate sample data for field {field} of type {type(field)}" + ) + + @property + def preview_foreign_key_values_xlsx(self): + related_model_qs = self.filtered_related_model_qs + + if not related_model_qs: + raise ValueError("No related model queryset found") + + workbook = openpyxl.Workbook() + worksheet = workbook.active + + display_field = self.display_field + + # Query the max character length of the display field + max_length = related_model_qs.aggregate( + max_length=Max(Length(Cast(display_field, output_field=CharField()))) + )["max_length"] + + if len(self.xlsx_column_header_name) > max_length: + max_length = len(self.xlsx_column_header_name) + + headers = [self.xlsx_column_header_name] + worksheet.append(headers) + for cell_value in related_model_qs.order_by(display_field).values_list( + display_field, flat=True + ): + worksheet.append([cell_value]) + + # Make the headers bold + worksheet["A1"].font = Font(bold=True) + + # Make the column widths appropriate + worksheet.column_dimensions["A"].width = max_length + 2 + + return workbook + + def validate(self, task, cell_value, mode, index, headers, row, errors): + from boranga.components.spatial.utils import get_geometry_array_from_geojson + + errors_added = 0 + + if mode == "update" and (cell_value is None or cell_value == ""): + # When updating an OCR many of the columns may be blank + # So we don't need to validate them if they don't have a value + return cell_value, errors_added + + try: + model_class = apps.get_model( + "boranga", self.django_import_content_type.model + ) + except LookupError: + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": f"Model class {self.django_import_content_type.model} not found", + } + ) + errors_added += 1 + return cell_value, errors_added + + if not hasattr(model_class, self.django_import_field_name): + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": f"Field {self.django_import_field_name} not found in model {model_class}", + } + ) + errors_added += 1 + return cell_value, errors_added + + field = model_class._meta.get_field(self.django_import_field_name) + + if self.default_value: + if self.default_value == self.DEFAULT_VALUE_BULK_IMPORT_SUBMITTER: + cell_value = task.email_user + return cell_value, errors_added + + if ( + self.django_import_content_type + == ct_models.ContentType.objects.get_for_model(OccurrenceReport) + and self.django_import_field_name == "processing_status" + ): + if cell_value not in [ + c[0] for c in OccurrenceReport.VALID_BULK_IMPORT_PROCESSING_STATUSES + ]: + error_message = ( + f"Value {cell_value} in column {self.xlsx_column_header_name} " + f"is not a valid processing status (must be one of " + f"{str([c[0] for c in OccurrenceReport.VALID_BULK_IMPORT_PROCESSING_STATUSES])})" + ) + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": error_message, + } + ) + errors_added += 1 + return cell_value, errors_added + + if cell_value in [ + OccurrenceReport.PROCESSING_STATUS_WITH_APPROVER, + OccurrenceReport.PROCESSING_STATUS_APPROVED, + ]: + assigned_officer_column = self.schema.columns.filter( + django_import_content_type=ct_models.ContentType.objects.get_for_model( + OccurrenceReport + ), + django_import_field_name="assigned_officer", + ).first() + assigned_officer_email = row[ + headers.index(assigned_officer_column.xlsx_column_header_name) + ] + assigned_officer_id = None + try: + assigned_officer_id = EmailUser.objects.get( + email=assigned_officer_email + ).id + except EmailUser.DoesNotExist: + error_message = ( + "No ledger user found for assigned_officer with " + f"email address: {assigned_officer_email}" + ) + errors.append( + { + "row_index": index, + "error_type": "column", + "data": assigned_officer_email, + "error_message": error_message, + } + ) + errors_added += 1 + return cell_value, errors_added + + if not belongs_to_by_user_id( + assigned_officer_id, settings.GROUP_NAME_OCCURRENCE_ASSESSOR + ): + error_message = ( + f"User with email {assigned_officer_email} " + f"(Ledger Emailuser ID: {assigned_officer_id}) is not a member" + " of the occurrence assessor group" + ) + errors.append( + { + "row_index": index, + "error_type": "column", + "data": assigned_officer_email, + "error_message": error_message, + } + ) + errors_added += 1 + if cell_value == OccurrenceReport.PROCESSING_STATUS_APPROVED: + assigned_approver_column = self.schema.columns.filter( + django_import_content_type=ct_models.ContentType.objects.get_for_model( + OccurrenceReport + ), + django_import_field_name="assigned_approver", + ).first() + assigned_approver_email = row[ + headers.index(assigned_approver_column.xlsx_column_header_name) + ] + assigned_approver_id = None + try: + assigned_approver_id = EmailUser.objects.get( + email=assigned_approver_email + ).id + except EmailUser.DoesNotExist: + error_message = ( + "No ledger user found for assigned_approver with " + f"email address: {assigned_approver_email}" + ) + errors.append( + { + "row_index": index, + "error_type": "column", + "data": assigned_approver_email, + "error_message": error_message, + } + ) + errors_added += 1 + return cell_value, errors_added + + if not belongs_to_by_user_id( + assigned_approver_id, settings.GROUP_NAME_OCCURRENCE_APPROVER + ): + error_message = ( + f"User with email {assigned_approver_email} " + f"(Ledger Emailuser ID: {assigned_approver_id}) is not a member" + " of the occurrence approver group" + ) + errors.append( + { + "row_index": index, + "error_type": "column", + "data": assigned_approver_email, + "error_message": error_message, + } + ) + errors_added += 1 + + return cell_value, errors_added + + if not self.xlsx_data_validation_allow_blank and ( + cell_value is None or cell_value == "" + ): + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": f"Value in column {self.xlsx_column_header_name} is blank", + } + ) + errors_added += 1 + + xlsx_data_validation_type = self.xlsx_validation_type + + if self.is_emailuser_column: + if not cell_value: + # Depending on the status of the OCR the assigned_officer and / or assigned_approver + # fields may be blank + return cell_value, errors_added try: - cell_value = dateutil.parser.parse(cell_value) - except Exception: + cell_value = EmailUser.objects.get(email=cell_value).id + except EmailUser.DoesNotExist: + error_message = f"No ledger user found with email address: {cell_value}" + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": error_message, + } + ) + errors_added += 1 + return cell_value, errors_added + + if isinstance(field, MultiSelectField): + if not cell_value: + return cell_value, errors_added + + if not isinstance(cell_value, str): errors.append( { "row_index": index, "error_type": "column", "data": cell_value, - "error_message": f"Value {cell_value} in column {self.column_header_name} is not a time", + "error_message": f"Value {cell_value} in column {self.xlsx_column_header_name} is not a string", } ) errors_added += 1 + return cell_value, errors_added + + # Unfortunatly have to have an actual model instance to get the choices + # as they are defined in __init__ + model_instance = model_class() + choices = model_instance._meta.get_field( + self.django_import_field_name + ).choices + + display_values = cell_value.split(",") + cell_value = [] + for display_value in [ + display_value.strip() for display_value in display_values + ]: + if display_value not in [choice[1] for choice in choices]: + error_message = ( + f"Value '{display_value}' in column {self.xlsx_column_header_name} " + "is not in the list" + ) + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": error_message, + } + ) + errors_added += 1 + else: + cell_value.append( + [ + choice[0] + for choice in field.choices + if choice[1] == display_value + ][0] + ) + return cell_value, errors_added - if self.xlsx_data_validation_type == "list": - if cell_value not in self.xlsx_data_validation_formula1: + if isinstance(field, models.FileField): + if not task._associated_files_zip: + error_message = "No associated files zip found" errors.append( { "row_index": index, "error_type": "column", "data": cell_value, - "error_message": f"Value {cell_value} in column {self.column_header_name} is not in the list", + "error_message": error_message, } ) errors_added += 1 + return cell_value, errors_added - model_class = apps.get_model("boranga", self.django_import_content_type.model) - if hasattr(model_class, self.django_import_field_name): - field = model_class._meta.get_field(self.django_import_field_name) - if isinstance(field, models.ForeignKey): - related_model = field.related_model - related_model_qs = related_model.objects.all() + associated_files_zip = zipfile.ZipFile(task._associated_files_zip, "r") - # Check if the related model is Archivable - if issubclass(related_model, ArchivableModel): - related_model_qs = related_model_qs.exclude(archived=True) + # Check if the file exists in the zip + if cell_value not in associated_files_zip.namelist(): + error_message = f"File {cell_value} not found in associated files zip" + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": error_message, + } + ) + errors_added += 1 + return cell_value, errors_added - if not related_model_qs.exists() or related_model_qs.count() == 0: - return cell_value, errors_added + # Get a reference to the file in memory + file_in_memory = associated_files_zip.open(cell_value) - if ( - related_model_qs.count() - > settings.OCR_BULK_IMPORT_LOOKUP_TABLE_RECORD_LIMIT - ): - # Use the django lookup field to find the value - lookup_field = self.django_lookup_field_name - try: - related_model_instance = related_model_qs.get( - **{lookup_field: cell_value} - ) - except related_model.DoesNotExist: - error_message = ( - f"Can't find {self.django_import_field_name} record by looking up " - f"{self.django_lookup_field_name} with value {cell_value} " - f"for column {self.column_header_name}" - ) - errors.append( - { - "row_index": index, - "error_type": "column", - "data": cell_value, - "error_message": error_message, - } - ) - errors_added += 1 - return cell_value, errors_added + extension = cell_value.split(".")[-1].lower() + # Get the content type of the file + try: + content_type = mimetypes.types_map["." + str(extension)] + except KeyError: + error_message = f"File extension {extension} not found in mimetypes" + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": error_message, + } + ) + errors_added += 1 + return cell_value, errors_added + + cell_value = InMemoryUploadedFile( + file_in_memory, field.name, cell_value, content_type, None, None + ) + + return cell_value, errors_added + + if isinstance(field, gis_models.GeometryField): + try: + geom_json = json.loads(cell_value) + except json.JSONDecodeError: + error_message = f"Value {cell_value} in column {self.xlsx_column_header_name} is not a valid JSON" + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": error_message, + } + ) + errors_added += 1 + return cell_value, errors_added + + cell_value = [] + + geojson_type = geom_json.get("type", None) + if not geojson_type or geojson_type != "FeatureCollection": + error_message = ( + f"Value {cell_value} in column {self.xlsx_column_header_name} " + "does not contain a valid FeatureCollection" + ) + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": error_message, + } + ) + errors_added += 1 + + return cell_value, errors_added + + cell_value = get_geometry_array_from_geojson( + geom_json, + cell_value, + index, + self.xlsx_column_header_name, + errors, + errors_added, + ) + + return cell_value, errors_added - # Replace the lookup cell_value with the actual instance to assigned - cell_value = related_model_instance + if xlsx_data_validation_type == "textLength" and field.max_length: + if len(str(cell_value)) > field.max_length: + error_message = f"Value {cell_value} in column {self.xlsx_column_header_name} has too many characters" + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": error_message, + } + ) + errors_added += 1 + + if xlsx_data_validation_type == "whole": + if not isinstance(cell_value, int): + errors_message = f"Value {cell_value} in column {self.xlsx_column_header_name} is not an integer" + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": errors_message, + } + ) + errors_added += 1 + + if xlsx_data_validation_type == "decimal": + try: + cell_value = Decimal(cell_value) + except Exception: + error_message = f"Value {cell_value} in column {self.xlsx_column_header_name} is not a decimal" + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": error_message, + } + ) + errors_added += 1 + + if xlsx_data_validation_type in ["date", "time"]: + # The cell_value should already be a datetime object since openpyxl + # converts cells formatted as dates to datetime objects automatically + # but when validating the schema + if not isinstance(cell_value, datetime): + try: + if xlsx_data_validation_type == "date": + cell_value = datetime.strptime(cell_value, "%d/%m/%Y") + elif xlsx_data_validation_type == "time": + cell_value = datetime.strptime(cell_value, "%d/%m/%Y %H:%M:%S") + except ValueError: + error_message = ( + f"Value {cell_value} in column {self.xlsx_column_header_name} " + "was not able to be converted to a datetime object" + ) + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": error_message, + } + ) + errors_added += 1 return cell_value, errors_added - display_field = get_display_field_for_model(related_model) + # Make the datetime object timezone aware + cell_value = cell_value.replace( + tzinfo=zoneinfo.ZoneInfo(settings.TIME_ZONE) + ) - if cell_value not in related_model_qs.values_list( - display_field, flat=True - ): - error_message = f"Value {cell_value} in column {self.column_header_name} is not in the lookup table" + return cell_value, errors_added + + if isinstance(field, models.ForeignKey): + related_model = field.related_model + related_model_qs = self.related_model_qs + + # Check if the related model is Archivable + if issubclass(related_model, ArchivableModel): + related_model_qs = related_model_qs.exclude(archived=True) + + if not related_model_qs.exists() or related_model_qs.count() == 0: + error_message = f"No records found for foreign key {field.related_model._meta.model_name}" + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": error_message, + } + ) + return cell_value, errors_added + + # Use the django lookup field to find the value + if self.django_lookup_field_name: + lookup_field = self.django_lookup_field_name + else: + lookup_field = get_display_field_for_model(related_model) + + try: + related_model_instance = related_model_qs.get( + **{lookup_field: cell_value} + ) + + except FieldError: + error_message = ( + f"Can't find {self.django_import_field_name} record by looking up " + f"{lookup_field} with value {cell_value} " + f"for column {self.xlsx_column_header_name} " + f" no field {lookup_field} found in {related_model}" + ) + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": error_message, + } + ) + errors_added += 1 + return cell_value, errors_added + except related_model.DoesNotExist: + error_message = ( + f"Can't find {self.django_import_field_name} record by looking up " + f"{self.django_lookup_field_name} with value {cell_value} " + f"for column {self.xlsx_column_header_name}" + ) + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": error_message, + } + ) + errors_added += 1 + return cell_value, errors_added + + # Replace the lookup cell_value with the actual instance to be assigned + cell_value = related_model_instance + return cell_value, errors_added + + if isinstance(field, models.ManyToManyField): + related_model = field.related_model + related_model_qs = related_model.objects.all() + + # Check if the related model is Archivable + if issubclass(related_model, ArchivableModel): + related_model_qs = related_model_qs.exclude(archived=True) + + if not related_model_qs.exists() or related_model_qs.count() == 0: + return cell_value, errors_added + + # Use the django lookup field to find the value + if self.django_lookup_field_name: + lookup_field = self.django_lookup_field_name + else: + lookup_field = get_display_field_for_model(related_model) + + lookup_field += "__in" + + cell_value = [ + c.strip() + for c in cell_value.split(settings.OCR_BULK_IMPORT_M2M_DELIMITER) + ] + + try: + related_model_instances = related_model_qs.filter( + **{lookup_field: cell_value} + ) + except FieldError: + error_message = ( + f"Can't find {self.django_import_field_name} record by looking up " + f"{lookup_field} with value {cell_value} " + f"for column {self.xlsx_column_header_name} " + f" no field {lookup_field} found in {related_model}" + ) + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": error_message, + } + ) + errors_added += 1 + return cell_value, errors_added + except related_model.DoesNotExist: + error_message = ( + f"Can't find {self.django_import_field_name} record by looking up " + f"{self.django_lookup_field_name} with value {cell_value} " + f"for column {self.xlsx_column_header_name}" + ) + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": error_message, + } + ) + errors_added += 1 + return cell_value, errors_added + + # Replace the lookup cell_value with a list of model instances to be assigned + cell_value = list(related_model_instances) + return cell_value, errors_added + + if isinstance(field, models.BooleanField): + if cell_value is None or cell_value == "": + if not field.null: + error_message = ( + f"Value {cell_value} in column {self.xlsx_column_header_name} " + "is required to be a boolean" + ) errors.append( { "row_index": index, @@ -6148,10 +7665,111 @@ def validate(self, cell_value, index, errors): } ) errors_added += 1 + return cell_value, errors_added + + if cell_value not in [True, False]: + error_message = ( + f"Value {cell_value} in column {self.xlsx_column_header_name} " + "is not a valid boolean" + ) + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": error_message, + } + ) + errors_added += 1 + return cell_value, errors_added return cell_value, errors_added +class SchemaColumnLookupFilter(models.Model): + schema_column = models.ForeignKey( + OccurrenceReportBulkImportSchemaColumn, + related_name="lookup_filters", + on_delete=models.CASCADE, + ) + + LOOKUP_FILTER_TYPE_EXACT = "exact" + LOOKUP_FILTER_TYPE_IEXACT = "iexact" + LOOKUP_FILTER_TYPE_CONTAINS = "contains" + LOOKUP_FILTER_TYPE_ICONTAINS = "icontains" + LOOKUP_FILTER_TYPE_STARTSWITH = "startswith" + LOOKUP_FILTER_TYPE_ISTARTSWITH = "istartswith" + LOOKUP_FILTER_TYPE_ENDSWITH = "endswith" + LOOKUP_FILTER_TYPE_IENDSWITH = "iendswith" + LOOKUP_FILTER_TYPE_GT = "gt" + LOOKUP_FILTER_TYPE_GTE = "gte" + LOOKUP_FILTER_TYPE_LT = "lt" + LOOKUP_FILTER_TYPE_LTE = "lte" + # Only supporting a single value per lookup filter at this stage + # LOOKUP_FILTER_TYPE_IN = "in" + + LOOKUP_FILTER_TYPES = ( + (LOOKUP_FILTER_TYPE_EXACT, "Exact"), + (LOOKUP_FILTER_TYPE_IEXACT, "Case-insensitive Exact"), + (LOOKUP_FILTER_TYPE_CONTAINS, "Contains"), + (LOOKUP_FILTER_TYPE_ICONTAINS, "Case-insensitive Contains"), + (LOOKUP_FILTER_TYPE_STARTSWITH, "Starts with"), + (LOOKUP_FILTER_TYPE_ISTARTSWITH, "Case-insensitive Starts with"), + (LOOKUP_FILTER_TYPE_ENDSWITH, "Ends with"), + (LOOKUP_FILTER_TYPE_IENDSWITH, "Case-insensitive Ends with"), + (LOOKUP_FILTER_TYPE_GT, "Greater than"), + (LOOKUP_FILTER_TYPE_GTE, "Greater than or equal to"), + (LOOKUP_FILTER_TYPE_LT, "Less than"), + (LOOKUP_FILTER_TYPE_LTE, "Less than or equal to"), + # (LOOKUP_FILTER_TYPE_IN, "In"), + ) + + filter_field_name = models.CharField(max_length=50, blank=False, null=False) + filter_type = models.CharField( + max_length=50, + choices=LOOKUP_FILTER_TYPES, + default=LOOKUP_FILTER_TYPE_EXACT, + blank=False, + null=False, + ) + + class Meta: + app_label = "boranga" + verbose_name = "Schema Column Lookup Filter" + verbose_name_plural = "Schema Column Lookup Filters" + constraints = [ + models.UniqueConstraint( + fields=["schema_column", "filter_field_name", "filter_type"], + name="unique_schema_column_lookup_field", + violation_error_message=( + "A lookup filter with the same name and type " + "already exists for this schema column" + ), + ) + ] + + def __str__(self): + return f"{self.schema_column} - {self.filter_field_name}" + + +class SchemaColumnLookupFilterValue(models.Model): + lookup_filter = models.ForeignKey( + SchemaColumnLookupFilter, + related_name="values", + on_delete=models.CASCADE, + ) + + filter_value = models.CharField(max_length=255, blank=True, null=True) + + class Meta: + app_label = "boranga" + verbose_name = "Schema Column Lookup Filter Value" + verbose_name_plural = "Schema Column Lookup Filter Values" + + def __str__(self): + return f"{self.lookup_filter} - {self.filter_value}" + + # Occurrence Report Document reversion.register(OccurrenceReportDocument) diff --git a/boranga/components/occurrence/permissions.py b/boranga/components/occurrence/permissions.py index 6d975ca2..93457b6d 100644 --- a/boranga/components/occurrence/permissions.py +++ b/boranga/components/occurrence/permissions.py @@ -14,6 +14,7 @@ is_conservation_status_approver, is_conservation_status_assessor, is_contributor, + is_django_admin, is_occurrence_approver, is_occurrence_assessor, is_occurrence_report_referee, @@ -433,7 +434,23 @@ def has_permission(self, request, view): if request.user.is_superuser: return True - return is_occurrence_assessor(request) + return is_django_admin(request) or is_occurrence_approver(request) + + def has_object_permission(self, request, view, obj): + if not request.user.is_authenticated: + return False + + if request.user.is_superuser: + return True + + if ( + view.action != "copy" + and request.method not in permissions.SAFE_METHODS + and obj.is_master + ): + return is_django_admin(request) + + return is_django_admin(request) or is_occurrence_approver(request) class OccurrencePermission(BasePermission): diff --git a/boranga/components/occurrence/serializers.py b/boranga/components/occurrence/serializers.py index 23aa866a..16f682c8 100644 --- a/boranga/components/occurrence/serializers.py +++ b/boranga/components/occurrence/serializers.py @@ -64,6 +64,8 @@ OCRObserverDetail, OCRPlantCount, OCRVegetationStructure, + SchemaColumnLookupFilter, + SchemaColumnLookupFilterValue, ) from boranga.components.spatial.utils import wkb_to_geojson from boranga.components.species_and_communities.models import ( @@ -72,9 +74,11 @@ ) from boranga.components.users.serializers import SubmitterInformationSerializer from boranga.helpers import ( + check_file, is_conservation_status_approver, is_conservation_status_assessor, is_contributor, + is_django_admin, is_internal, is_new_external_contributor, is_occurrence_approver, @@ -806,20 +810,6 @@ def get_has_points(self, obj): .exists() ) - # def get_geojson_point(self,obj): - # if(obj.geojson_point): - # coordinates = GEOSGeometry(obj.geojson_point).coords - # return coordinates - # else: - # return None - - # def get_geojson_polygon(self,obj): - # if(obj.geojson_polygon): - # coordinates = GEOSGeometry(obj.geojson_polygon).coords - # return coordinates - # else: - # return None - class BaseTypeSerializer(serializers.Serializer): model_class = serializers.SerializerMethodField() @@ -2358,9 +2348,7 @@ class Meta: "reason", "reason_text", "amendment_request_documents", - "subject", "text", - "officer", "status", "occurrence_report", ] @@ -3843,8 +3831,33 @@ def get_last_updated_by(self, obj): return None -class OccurrenceReportBulkImportSchemaColumnSerializer(serializers.ModelSerializer): +class SchemaColumnLookupFilterValueNestedSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(allow_null=True, required=False) + filter_value = serializers.CharField( + allow_null=True, allow_blank=True, required=False + ) + + class Meta: + model = SchemaColumnLookupFilterValue + fields = ["id", "filter_value"] + read_only_fields = ("id",) + validators = [] # Validation is done in the top parent serializer + + +class SchemaColumnLookupFilterNestedSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(allow_null=True, required=False) + values = SchemaColumnLookupFilterValueNestedSerializer( + many=True, allow_null=True, required=False + ) + + class Meta: + model = SchemaColumnLookupFilter + fields = "__all__" + read_only_fields = ("id",) + validators = [] # Validation is done in the top parent serializer + +class OccurrenceReportBulkImportSchemaColumnSerializer(serializers.ModelSerializer): class Meta: model = OccurrenceReportBulkImportSchemaColumn fields = "__all__" @@ -3856,15 +3869,74 @@ class OccurrenceReportBulkImportSchemaColumnNestedSerializer( ): id = serializers.IntegerField(allow_null=True, required=False) order = serializers.IntegerField() + foreign_key_count = serializers.IntegerField(read_only=True) + requires_lookup_field = serializers.BooleanField(read_only=True) + model_name = serializers.CharField(read_only=True) + field_type = serializers.CharField(read_only=True) + xlsx_validation_type = serializers.CharField(read_only=True) + text_length = serializers.IntegerField(read_only=True) + choices = serializers.ListField(child=serializers.ListField(), read_only=True) + # Must keep both the full foreign key count and filtered as if the foreign_key_count + # fluctuates when filters are added it will cause issues with the frontend + foreign_key_count = serializers.IntegerField(read_only=True) + filtered_foreign_key_count = serializers.IntegerField(read_only=True) + lookup_filters = SchemaColumnLookupFilterNestedSerializer( + many=True, allow_null=True, required=False + ) + is_editable_by_user = serializers.SerializerMethodField(read_only=True) class Meta: model = OccurrenceReportBulkImportSchemaColumn fields = "__all__" validators = [] # Validation is done in the parent serializer + def get_is_editable_by_user(self, obj): + request = self.context.get("request") + if obj.is_editable: + return True + + return request.user.is_superuser or is_django_admin(request) + + +class OccurrenceReportBulkImportSchemaListSerializer(serializers.ModelSerializer): + tags = TagListSerializerField(allow_null=True, required=False) + group_type_display = serializers.CharField(source="group_type.name", read_only=True) + version = serializers.CharField(read_only=True) + can_user_edit = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = OccurrenceReportBulkImportSchema + fields = [ + "id", + "version", + "name", + "tags", + "group_type", + "group_type_display", + "datetime_created", + "datetime_updated", + "is_master", + "can_user_edit", + ] + read_only_fields = ("id",) + + def get_can_user_edit(self, obj): + if not obj.is_master: + return True + + request = self.context.get("request") + + if not request.user.is_authenticated: + return False + + if request.user.is_superuser: + return True + + return is_django_admin(request) + class OccurrenceReportBulkImportSchemaSerializer( - TaggitSerializer, serializers.ModelSerializer + TaggitSerializer, OccurrenceReportBulkImportSchemaListSerializer ): columns = OccurrenceReportBulkImportSchemaColumnNestedSerializer( many=True, allow_null=True, required=False @@ -3872,14 +3944,27 @@ class OccurrenceReportBulkImportSchemaSerializer( tags = TagListSerializerField(allow_null=True, required=False) group_type_display = serializers.CharField(source="group_type.name", read_only=True) version = serializers.CharField(read_only=True) + can_user_edit = serializers.SerializerMethodField(read_only=True) + can_user_toggle_master = serializers.SerializerMethodField(read_only=True) class Meta: model = OccurrenceReportBulkImportSchema fields = "__all__" read_only_fields = ("id",) + def get_can_user_toggle_master(self, obj): + request = self.context.get("request") + if not request.user.is_authenticated: + return False + + if request.user.is_superuser: + return True + + return is_django_admin(request) + @transaction.atomic def update(self, instance, validated_data): + request = self.context.get("request") columns_data = validated_data.pop("columns", None) if not columns_data or len(columns_data) == 0: return super().update(instance, validated_data) @@ -3890,12 +3975,61 @@ def update(self, instance, validated_data): ] instance.columns.exclude(id__in=ids_to_keep).delete() for column_data in columns_data: - OccurrenceReportBulkImportSchemaColumn.objects.update_or_create( - pk=column_data.get("id"), defaults=column_data + if not is_django_admin(request): + column_data.pop("is_editable", None) + lookup_filters_data = column_data.pop("lookup_filters", None) + column, created = ( + OccurrenceReportBulkImportSchemaColumn.objects.update_or_create( + pk=column_data.get("id"), defaults=column_data + ) ) + + if not lookup_filters_data: + column.lookup_filters.all().delete() + continue + + ids_to_keep = [ + lookup_filter["id"] + for lookup_filter in lookup_filters_data + if "id" in lookup_filter + ] + column.lookup_filters.exclude(id__in=ids_to_keep).delete() + + for lookup_filter in lookup_filters_data: + values_data = lookup_filter.pop("values", None) + lookup, created = SchemaColumnLookupFilter.objects.update_or_create( + pk=lookup_filter.get("id"), + schema_column=column, + defaults=lookup_filter, + ) + if not values_data: + continue + + # For now not supporting multiple values + filter_value = values_data.pop(0) + + filter_value, created = ( + SchemaColumnLookupFilterValue.objects.update_or_create( + pk=filter_value.get("id"), + lookup_filter=lookup, + defaults=filter_value, + ) + ) + return super().update(instance, validated_data) +class OccurrenceReportBulkImportSchemaOccurrenceApproverSerializer( + OccurrenceReportBulkImportSchemaSerializer +): + is_master = serializers.BooleanField(read_only=True) + + class Meta: + model = OccurrenceReportBulkImportSchema + fields = "__all__" + read_only_fields = ("id",) + + class OccurrenceReportBulkImportTaskSerializer(serializers.ModelSerializer): estimated_processing_time_human_readable = serializers.CharField(read_only=True) total_time_taken_human_readable = serializers.CharField(read_only=True) @@ -3903,6 +4037,9 @@ class OccurrenceReportBulkImportTaskSerializer(serializers.ModelSerializer): file_name = serializers.CharField(read_only=True) percentage_complete = serializers.CharField(read_only=True) schema_id = serializers.IntegerField(write_only=True) + group_type_name = serializers.CharField( + source="schema__group_type__name", read_only=True + ) class Meta: model = OccurrenceReportBulkImportTask @@ -3922,10 +4059,19 @@ class Meta: "estimated_processing_time_human_readable", "total_time_taken_human_readable", "percentage_complete", + "group_type_name", ) def validate(self, attrs): _file = attrs["_file"] + check_file(_file, OccurrenceReportBulkImportTask._meta.model_name) + + _associated_files_zip = attrs.get("_associated_files_zip", None) + if _associated_files_zip: + check_file( + _associated_files_zip, OccurrenceReportBulkImportTask._meta.model_name + ) + try: schema = OccurrenceReportBulkImportSchema.objects.get(id=attrs["schema_id"]) except OccurrenceReportBulkImportSchema.DoesNotExist: diff --git a/boranga/components/spatial/utils.py b/boranga/components/spatial/utils.py index 77a6ceb4..d2017384 100644 --- a/boranga/components/spatial/utils.py +++ b/boranga/components/spatial/utils.py @@ -13,7 +13,7 @@ import shapely.geometry as shp from django.apps import apps from django.contrib.contenttypes import models as ct_models -from django.contrib.gis.geos import GEOSGeometry +from django.contrib.gis.geos import GEOSGeometry, Polygon from django.core.cache import cache from django.core.exceptions import ValidationError from django.db import IntegrityError @@ -28,6 +28,7 @@ from boranga import settings from boranga.components.occurrence.models import ( BufferGeometry, + GeometryBase, OccurrenceGeometry, OccurrenceTenure, ) @@ -75,7 +76,7 @@ def intersect_geometry_with_layer( # though both forms are (topologically) valid by OGC definition, the jts (java topology suite) library only # seems to except singleton lists # (https://www.tsusiatsoftware.net/jts/javadoc/com/vividsolutions/jts/io/WKTReader.html) - logger.warn( + logger.warning( f"Converting MultiPoint geometry {test_geom} to double-bracket notation" ) test_geom_wkt = ( @@ -149,7 +150,7 @@ def populate_occurrence_tenure_data(geometry_instance, features, request): tenure_area_ewkb = feature_json_to_geosgeometry(feature).ewkb if not feature_id: - logger.warn(f"Feature does not have an ID: {feature}") + logger.warning(f"Feature does not have an ID: {feature}") continue # Check if an occurrence tenure entry already exists for this feature ID occurrence_tenure_before = OccurrenceTenure.objects.filter( @@ -260,7 +261,7 @@ def save_geometry( instance_model_name = instance._meta.model.__name__ if not geometry_data: - logger.warn(f"No {instance_model_name} geometry to save") + logger.warning(f"No {instance_model_name} geometry to save") return {} InstanceGeometry = apps.get_model( @@ -283,7 +284,9 @@ def save_geometry( == InstanceGeometry.objects.filter(**{instance_fk_field_name: instance}).count() ): # No feature to save and no feature to delete - logger.warn(f"{instance_model_name} geometry has no features to save or delete") + logger.warning( + f"{instance_model_name} geometry has no features to save or delete" + ) return {} action = request.data.get("action", None) @@ -295,7 +298,7 @@ def save_geometry( geometry_type = feature.get("geometry").get("type") # Check if feature is of a supported type, continue if not if geometry_type not in supported_geometry_types: - logger.warn( + logger.warning( f"{instance_model_name}: {instance} contains a feature that is not a " f"{' or '.join(supported_geometry_types)}: {feature}" ) @@ -387,7 +390,7 @@ def save_geometry( logger.info("No tenure intersects query layer specified") intersect_layer = None except TileLayer.MultipleObjectsReturned: - logger.warn("Multiple tenure intersects query layers found") + logger.warning("Multiple tenure intersects query layers found") intersect_layer = None else: plausibility_geometries = PlausibilityGeometry.objects.filter( @@ -452,7 +455,7 @@ def save_geometry( try: geometry = InstanceGeometry.objects.get(id=feature.get("id")) except InstanceGeometry.DoesNotExist: - logger.warn( + logger.warning( f"{instance_model_name} geometry does not exist: {feature.get('id')}" ) continue @@ -1001,3 +1004,63 @@ def process_proxy(request, remoteurl, queryString, auth_user, auth_password): "Access Denied", content_type="text/html", status=401 ) return http_response + + +def get_geometry_array_from_geojson( + geojson: dict, + cell_value: any, + index: int, + column_name: str, + errors: list, + errors_added: int, +) -> list: + """ + Extracts the geometry array from a GeoJSON object. + """ + if not geojson: + return None + + features = geojson.get("features") + + geoms = [] + bbox = Polygon.from_bbox(GeometryBase.EXTENT) + + for feature in features: + geom = feature.get("geometry") + + if not geom: + error_message = ( + f"Geometry not defined in {cell_value} for column {column_name}" + ) + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": error_message, + } + ) + errors_added += 1 + continue + + geom = GEOSGeometry(json.dumps(geom)) + + if not geom.within(bbox): + error_message = ( + f"Geomtry defined in {cell_value} for column {column_name} " + "is not within Western Australia" + ) + errors.append( + { + "row_index": index, + "error_type": "column", + "data": cell_value, + "error_message": error_message, + } + ) + errors_added += 1 + continue + + geoms.append(geom) + + return geoms diff --git a/boranga/components/species_and_communities/models.py b/boranga/components/species_and_communities/models.py index 3ec61a43..44cfe938 100644 --- a/boranga/components/species_and_communities/models.py +++ b/boranga/components/species_and_communities/models.py @@ -26,7 +26,11 @@ UserAction, ) from boranga.components.main.related_item import RelatedItem -from boranga.helpers import is_species_communities_approver, member_ids +from boranga.helpers import ( + is_species_communities_approver, + member_ids, + no_commas_validator, +) from boranga.ledger_api_utils import retrieve_email_user from boranga.settings import GROUP_NAME_SPECIES_COMMUNITIES_APPROVER @@ -54,7 +58,9 @@ def update_community_comms_log_filename(instance, filename): class Region(models.Model): - name = models.CharField(unique=True, default=None, max_length=200) + name = models.CharField( + unique=True, default=None, max_length=200, validators=[no_commas_validator] + ) forest_region = models.BooleanField(default=False) class Meta: @@ -66,7 +72,9 @@ def __str__(self): class District(models.Model): - name = models.CharField(unique=True, max_length=200) + name = models.CharField( + unique=True, max_length=200, validators=[no_commas_validator] + ) code = models.CharField(unique=True, max_length=3, null=True) region = models.ForeignKey( Region, on_delete=models.CASCADE, related_name="districts" @@ -1518,6 +1526,9 @@ def get_related_items(self, filter_type, **kwargs): ) ) + # Remove duplicates + return_list = list(set(return_list)) + return return_list @property @@ -2045,7 +2056,9 @@ class DocumentCategory(ArchivableModel): - Table """ - document_category_name = models.CharField(max_length=128, unique=True) + document_category_name = models.CharField( + max_length=128, unique=True, validators=[no_commas_validator] + ) class Meta: app_label = "boranga" @@ -2078,6 +2091,7 @@ class DocumentSubCategory(ArchivableModel): document_sub_category_name = models.CharField( max_length=128, unique=True, + validators=[no_commas_validator], ) class Meta: @@ -2247,7 +2261,9 @@ class ThreatCategory(ArchivableModel): # e.g. mechnical disturbance """ - name = models.CharField(max_length=128, blank=False, unique=True) + name = models.CharField( + max_length=128, blank=False, unique=True, validators=[no_commas_validator] + ) class Meta: app_label = "boranga" @@ -2267,7 +2283,9 @@ class CurrentImpact(ArchivableModel): """ - name = models.CharField(max_length=100, blank=False, unique=True) + name = models.CharField( + max_length=100, blank=False, unique=True, validators=[no_commas_validator] + ) class Meta: app_label = "boranga" @@ -2287,7 +2305,9 @@ class PotentialImpact(ArchivableModel): """ - name = models.CharField(max_length=100, blank=False, unique=True) + name = models.CharField( + max_length=100, blank=False, unique=True, validators=[no_commas_validator] + ) class Meta: app_label = "boranga" @@ -2307,7 +2327,9 @@ class PotentialThreatOnset(ArchivableModel): """ - name = models.CharField(max_length=100, blank=False, unique=True) + name = models.CharField( + max_length=100, blank=False, unique=True, validators=[no_commas_validator] + ) class Meta: app_label = "boranga" @@ -2325,7 +2347,9 @@ class ThreatAgent(ArchivableModel): """ - name = models.CharField(max_length=100, blank=False, unique=True) + name = models.CharField( + max_length=100, blank=False, unique=True, validators=[no_commas_validator] + ) class Meta: app_label = "boranga" diff --git a/boranga/components/users/models.py b/boranga/components/users/models.py index 691d9a52..abc7e05d 100755 --- a/boranga/components/users/models.py +++ b/boranga/components/users/models.py @@ -12,6 +12,7 @@ Document, UserAction, ) +from boranga.helpers import no_commas_validator from boranga.ledger_api_utils import retrieve_email_user private_storage = FileSystemStorage( @@ -23,7 +24,7 @@ class SubmitterCategory(ArchivableModel): - name = models.CharField(max_length=100) + name = models.CharField(max_length=100, validators=[no_commas_validator]) USER_TYPE_CHOICE_EXTERNAL = "external" USER_TYPE_CHOICE_INTERNAL = "internal" USER_TYPE_CHOICES = [ diff --git a/boranga/data/initial_data.json b/boranga/data/initial_data.json deleted file mode 100755 index d19c6a1d..00000000 --- a/boranga/data/initial_data.json +++ /dev/null @@ -1,239 +0,0 @@ -[ -{ - "model": "boranga.applicationtype", - "pk": 1, - "fields": { - "name": "T Class", - "order": 1, - "visible": true - } -}, -{ - "model": "boranga.applicationtype", - "pk": 2, - "fields": { - "name": "Filming", - "order": 2, - "visible": true - } -}, -{ - "model": "boranga.applicationtype", - "pk": 3, - "fields": { - "name": "Event", - "order": 3, - "visible": true - } -}, -{ - "model": "boranga.activitymatrix", - "pk": 1, - "fields": { - "name": "Commercial Operator", - "description": "testing", - "schema": [ - {} - ], - "replaced_by": null, - "version": 1, - "ordered": false - } -}, -{ - "model": "boranga.proposaltype", - "pk": 1, - "fields": { - "description": "T Class Dummy", - "name": "T Class", - "schema": [ - {} - ], - "replaced_by": null, - "version": 1 - } -}, -{ - "model": "boranga.proposalassessorgroup", - "pk": 1, - "fields": { - "name": "Default Group", - "region": null, - "default": true, - "members": [ - 255 - ] - } -}, -{ - "model": "boranga.proposalapprovergroup", - "pk": 1, - "fields": { - "name": "Default Group", - "region": null, - "default": true, - "members": [ - 255 - ] - } -}, -{ - "model": "boranga.proposal", - "pk": 1, - "fields": { - "proposal_type": "new_proposal", - "data": [ - { - "regionActivitySection": [ - { - "Sub-activity level 2": "", - "Sub-activity level 1": "", - "District": null, - "Management area": "", - "Region": null, - "ActivityType": "" - } - ] - } - ], - "assessor_data": null, - "comment_data": null, - "schema": [ - {} - ], - "proposed_issuance_approval": null, - "customer_status": "draft", - "applicant": 1, - "lodgement_number": "P000001", - "lodgement_sequence": 0, - "lodgement_date": null, - "proxy_applicant": null, - "submitter": 255, - "assigned_officer": null, - "assigned_approver": null, - "processing_status": "draft", - "id_check_status": "not_checked", - "compliance_check_status": "not_checked", - "character_check_status": "not_checked", - "review_status": "not_reviewed", - "approval": null, - "previous_application": null, - "proposed_decline_status": false, - "title": null, - "activity": "", - "tenure": null, - "region": null, - "district": null, - "application_type": 1, - "approval_level": "", - "approval_level_document": null, - "approval_comment": "" - } -}, -{ - "model": "boranga.proposal", - "pk": 2, - "fields": { - "proposal_type": "new_proposal", - "data": [ - { - "regionActivitySection": [ - { - "Sub-activity level 2": "", - "Sub-activity level 1": "", - "District": null, - "Management area": "", - "Region": null, - "ActivityType": "" - } - ] - } - ], - "assessor_data": null, - "comment_data": null, - "schema": [ - {} - ], - "proposed_issuance_approval": null, - "customer_status": "draft", - "applicant": 1, - "lodgement_number": "P000002", - "lodgement_sequence": 0, - "lodgement_date": null, - "proxy_applicant": null, - "submitter": 255, - "assigned_officer": null, - "assigned_approver": null, - "processing_status": "draft", - "id_check_status": "not_checked", - "compliance_check_status": "not_checked", - "character_check_status": "not_checked", - "review_status": "not_reviewed", - "approval": null, - "previous_application": null, - "proposed_decline_status": false, - "title": null, - "activity": "", - "tenure": null, - "region": null, - "district": null, - "application_type": 1, - "approval_level": "", - "approval_level_document": null, - "approval_comment": "" - } -}, -{ - "model": "boranga.proposal", - "pk": 3, - "fields": { - "proposal_type": "new_proposal", - "data": [ - { - "regionActivitySection": [ - { - "Sub-activity level 2": "", - "Sub-activity level 1": "", - "District": null, - "Management area": "", - "Region": null, - "ActivityType": "" - } - ] - } - ], - "assessor_data": null, - "comment_data": null, - "schema": [ - {} - ], - "proposed_issuance_approval": null, - "customer_status": "draft", - "applicant": 1, - "lodgement_number": "P000003", - "lodgement_sequence": 0, - "lodgement_date": null, - "proxy_applicant": null, - "submitter": 255, - "assigned_officer": null, - "assigned_approver": null, - "processing_status": "draft", - "id_check_status": "not_checked", - "compliance_check_status": "not_checked", - "character_check_status": "not_checked", - "review_status": "not_reviewed", - "approval": null, - "previous_application": null, - "proposed_decline_status": false, - "title": null, - "activity": "", - "tenure": null, - "region": null, - "district": null, - "application_type": 1, - "approval_level": "", - "approval_level_document": null, - "approval_comment": "" - } -} -] diff --git a/boranga/frontend/boranga/package-lock.json b/boranga/frontend/boranga/package-lock.json index a977fd54..7d2facb7 100644 --- a/boranga/frontend/boranga/package-lock.json +++ b/boranga/frontend/boranga/package-lock.json @@ -66,11 +66,11 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", - "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.25.7.tgz", + "integrity": "sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==", "dependencies": { - "@babel/highlight": "^7.24.7", + "@babel/highlight": "^7.25.7", "picocolors": "^1.0.0" }, "engines": { @@ -78,35 +78,35 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.25.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.4.tgz", - "integrity": "sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==", + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.8.tgz", + "integrity": "sha512-ZsysZyXY4Tlx+Q53XdnOFmqwfB9QDTHYxaZYajWRoBLuLEAwI2UIbtxOjWh/cFaa9IKUlcB+DDuoskLuKu56JA==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/generator": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.6.tgz", - "integrity": "sha512-VPC82gr1seXOpkjAAKoLhP50vx4vGNlF4msF64dSFq1P8RfB+QAuJWGHPXXPc8QyfVWwwB/TNNU4+ayZmHNbZw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.7.tgz", + "integrity": "sha512-5Dqpl5fyV9pIAD62yK9P7fcA768uVPUyrQmqpqstHWgMma4feF1x/oFysBCVZLY5wJ2GkMUCdsNDnGZrPoR6rA==", "dependencies": { - "@babel/types": "^7.25.6", + "@babel/types": "^7.25.7", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^2.5.1" + "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz", - "integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.7.tgz", + "integrity": "sha512-DniTEax0sv6isaw6qSQSfV4gVRNtw2rte8HHM45t9ZR0xILaufBRNkpMifCRiAPyvL4ACD6v0gfCwCmtOQaV4A==", "dependencies": { - "@babel/compat-data": "^7.25.2", - "@babel/helper-validator-option": "^7.24.8", - "browserslist": "^4.23.1", + "@babel/compat-data": "^7.25.7", + "@babel/helper-validator-option": "^7.25.7", + "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -115,35 +115,35 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", - "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.7.tgz", + "integrity": "sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", - "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz", + "integrity": "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz", - "integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.7.tgz", + "integrity": "sha512-ytbPLsm+GjArDYXJ8Ydr1c/KJuutjF2besPNbIZnZ6MKUxi/uTA22t2ymmA4WFjZFpjiAMO0xuuJPqK2nvDVfQ==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/highlight": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", - "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.7.tgz", + "integrity": "sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw==", "dependencies": { - "@babel/helper-validator-identifier": "^7.24.7", + "@babel/helper-validator-identifier": "^7.25.7", "chalk": "^2.4.2", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" @@ -217,11 +217,11 @@ } }, "node_modules/@babel/parser": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz", - "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==", + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.8.tgz", + "integrity": "sha512-HcttkxzdPucv3nNFmfOOMfFf64KgdJVqm1KaCm25dPGMLElo9nsLvXeJECQg8UzPuBGLyTSA0ZzqCtDSzKTEoQ==", "dependencies": { - "@babel/types": "^7.25.6" + "@babel/types": "^7.25.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -231,9 +231,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.6.tgz", - "integrity": "sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz", + "integrity": "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -242,28 +242,28 @@ } }, "node_modules/@babel/template": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz", - "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.7.tgz", + "integrity": "sha512-wRwtAgI3bAS+JGU2upWNL9lSlDcRCqD05BZ1n3X2ONLH1WilFP6O1otQjeMK/1g0pvYcXC7b/qVUB1keofjtZA==", "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/parser": "^7.25.0", - "@babel/types": "^7.25.0" + "@babel/code-frame": "^7.25.7", + "@babel/parser": "^7.25.7", + "@babel/types": "^7.25.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.6.tgz", - "integrity": "sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ==", - "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.25.6", - "@babel/parser": "^7.25.6", - "@babel/template": "^7.25.0", - "@babel/types": "^7.25.6", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.7.tgz", + "integrity": "sha512-jatJPT1Zjqvh/1FyJs6qAHL+Dzb7sTb+xr7Q+gM1b+1oBsMsQQ4FkVKb6dFlJvLlVssqkRzV05Jzervt9yhnzg==", + "dependencies": { + "@babel/code-frame": "^7.25.7", + "@babel/generator": "^7.25.7", + "@babel/parser": "^7.25.7", + "@babel/template": "^7.25.7", + "@babel/types": "^7.25.7", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -272,12 +272,12 @@ } }, "node_modules/@babel/types": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz", - "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==", + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.8.tgz", + "integrity": "sha512-JWtuCu8VQsMladxVz/P4HzHUGCAwpuqacmowgXFs5XjxIgKuNjnLokQzuVjlTvIzODaDmpjT3oxcC48vyk9EWg==", "dependencies": { - "@babel/helper-string-parser": "^7.24.8", - "@babel/helper-validator-identifier": "^7.24.7", + "@babel/helper-string-parser": "^7.25.7", + "@babel/helper-validator-identifier": "^7.25.7", "to-fast-properties": "^2.0.0" }, "engines": { @@ -308,9 +308,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", - "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz", + "integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" @@ -407,9 +407,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -437,13 +437,13 @@ } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", "deprecated": "Use @eslint/config-array instead", "dev": true, "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", + "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" }, @@ -599,9 +599,9 @@ "integrity": "sha512-/Ri4xDDpe12NT6Ex/DRgHzLlobiQXEW/hmG08w1wj/YU7hLemk97c+zHQFp0iZQ9r7YqgLEXZR2sls4HxBf9NA==" }, "node_modules/@polka/url": { - "version": "1.0.0-next.25", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", - "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==" + "version": "1.0.0-next.28", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", + "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==" }, "node_modules/@popperjs/core": { "version": "2.11.8", @@ -2647,9 +2647,9 @@ "integrity": "sha512-DauBl25PKZZ0WVJr42a6CNvI6efsdzofl9sajqZr2Gf5Gu733WkDdUGiPkUHXiUvYGzNNlFQde2wdZdfQPG+yw==" }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==" }, "node_modules/@types/express": { "version": "4.17.21", @@ -2663,9 +2663,20 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "4.19.5", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.5.tgz", - "integrity": "sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.0.tgz", + "integrity": "sha512-AbXMTZGt40T+KON9/Fdxx0B2WK5hsgxcfXJLr5bFpZ7b4JCex2WyQPTEKdXqfHiY5nKKBScZ7yCoO6Pvgxfvnw==", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/express/node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -2730,9 +2741,9 @@ "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==" }, "node_modules/@types/node": { - "version": "22.5.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz", - "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", "dependencies": { "undici-types": "~6.19.2" } @@ -2756,9 +2767,9 @@ "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" }, "node_modules/@types/qs": { - "version": "6.9.15", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", - "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==" + "version": "6.9.16", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.16.tgz", + "integrity": "sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A==" }, "node_modules/@types/range-parser": { "version": "1.2.7", @@ -3011,49 +3022,49 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/@vue/compiler-core": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.1.tgz", - "integrity": "sha512-WdjF+NSgFYdWttHevHw5uaJFtKPalhmxhlu2uREj8cLP0uyKKIR60/JvSZNTp0x+NSd63iTiORQTx3+tt55NWQ==", + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.12.tgz", + "integrity": "sha512-ISyBTRMmMYagUxhcpyEH0hpXRd/KqDU4ymofPgl2XAkY9ZhQ+h0ovEZJIiPop13UmR/54oA2cgMDjgroRelaEw==", "dependencies": { "@babel/parser": "^7.25.3", - "@vue/shared": "3.5.1", + "@vue/shared": "3.5.12", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.0" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.1.tgz", - "integrity": "sha512-Ao23fB1lINo18HLCbJVApvzd9OQe8MgmQSgyY5+umbWj2w92w9KykVmJ4Iv2US5nak3ixc2B+7Km7JTNhQ8kSQ==", + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.12.tgz", + "integrity": "sha512-9G6PbJ03uwxLHKQ3P42cMTi85lDRvGLB2rSGOiQqtXELat6uI4n8cNz9yjfVHRPIu+MsK6TE418Giruvgptckg==", "dependencies": { - "@vue/compiler-core": "3.5.1", - "@vue/shared": "3.5.1" + "@vue/compiler-core": "3.5.12", + "@vue/shared": "3.5.12" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.1.tgz", - "integrity": "sha512-DFizMNH8eDglLhlfwJ0+ciBsztaYe3fY/zcZjrqL1ljXvUw/UpC84M1d7HpBTCW68SNqZyIxrs1XWmf+73Y65w==", + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.12.tgz", + "integrity": "sha512-2k973OGo2JuAa5+ZlekuQJtitI5CgLMOwgl94BzMCsKZCX/xiqzJYzapl4opFogKHqwJk34vfsaKpfEhd1k5nw==", "dependencies": { "@babel/parser": "^7.25.3", - "@vue/compiler-core": "3.5.1", - "@vue/compiler-dom": "3.5.1", - "@vue/compiler-ssr": "3.5.1", - "@vue/shared": "3.5.1", + "@vue/compiler-core": "3.5.12", + "@vue/compiler-dom": "3.5.12", + "@vue/compiler-ssr": "3.5.12", + "@vue/shared": "3.5.12", "estree-walker": "^2.0.2", "magic-string": "^0.30.11", - "postcss": "^8.4.44", + "postcss": "^8.4.47", "source-map-js": "^1.2.0" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.1.tgz", - "integrity": "sha512-C1hpSHQgRM8bg+5XWWD7CkFaVpSn9wZHCLRd10AmxqrH17d4EMP6+XcZpwBOM7H1jeStU5naEapZZWX0kso1tQ==", + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.12.tgz", + "integrity": "sha512-eLwc7v6bfGBSM7wZOGPmRavSWzNFF6+PdRhE+VFJhNCgHiF8AM7ccoqcv5kBXA2eWUfigD7byekvf/JsOfKvPA==", "dependencies": { - "@vue/compiler-dom": "3.5.1", - "@vue/shared": "3.5.1" + "@vue/compiler-dom": "3.5.12", + "@vue/shared": "3.5.12" } }, "node_modules/@vue/component-compiler-utils": { @@ -3115,9 +3126,9 @@ "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==" }, "node_modules/@vue/shared": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.1.tgz", - "integrity": "sha512-NdcTRoO4KuW2RSFgpE2c+E/R/ZHaRzWPxAGxhmxZaaqLh6nYCXx7lc9a88ioqOCxCaV2SFJmujkxbUScW7dNsQ==" + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.12.tgz", + "integrity": "sha512-L2RPSAwUFbgZH20etwrXyVyCBu9OxRSi8T/38QsvnkJyvq2LufW2lDCOzm7t/U9C1mkhJGWYfCuFBCmIuNivrg==" }, "node_modules/@vue/vue-loader-v15": { "name": "vue-loader", @@ -3380,9 +3391,9 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz", - "integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", "dependencies": { "acorn": "^8.11.0" }, @@ -3757,9 +3768,9 @@ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -3769,7 +3780,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -3852,9 +3863,9 @@ } }, "node_modules/browserslist": { - "version": "4.23.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", - "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.0.tgz", + "integrity": "sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A==", "funding": [ { "type": "opencollective", @@ -3870,8 +3881,8 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001646", - "electron-to-chromium": "^1.5.4", + "caniuse-lite": "^1.0.30001663", + "electron-to-chromium": "^1.5.28", "node-releases": "^2.0.18", "update-browserslist-db": "^1.1.0" }, @@ -4023,9 +4034,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001655", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz", - "integrity": "sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==", + "version": "1.0.30001668", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001668.tgz", + "integrity": "sha512-nWLrdxqCdblixUO+27JtGJJE/txpJlyUy5YN1u53wLZkP0emYCo5zgS6QYft7VUYR42LGgi/S5hdLZTrnyIddw==", "funding": [ { "type": "opencollective", @@ -4406,9 +4417,9 @@ } }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "engines": { "node": ">= 0.6" } @@ -4852,11 +4863,11 @@ "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" }, "node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -5249,9 +5260,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron-to-chromium": { - "version": "1.5.14", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.14.tgz", - "integrity": "sha512-bEfPECb3fJ15eaDnu9LEJ2vPGD6W1vt7vZleSVyFhYuMIKm3vz/g9lt7IvEzgdwj58RjbPKUF2rXTCN/UW47tQ==" + "version": "1.5.38", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.38.tgz", + "integrity": "sha512-VbeVexmZ1IFh+5EfrYz1I0HTzHVIlJa112UEWhciPyeOcKJGeTv6N8WnG4wsQB81DGCaVEGhpSb6o6a8WYFXXg==" }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -5267,9 +5278,9 @@ } }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "engines": { "node": ">= 0.8" } @@ -5306,9 +5317,9 @@ } }, "node_modules/envinfo": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.13.0.tgz", - "integrity": "sha512-cvcaMr7KqXVh4nyzGTVqTum+gAiL265x5jUWQIDLq//zOGbW+gSW/C+OWLleY/rs9Qole6AZLMXPbtIFQbqu+Q==", + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.14.0.tgz", + "integrity": "sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg==", "bin": { "envinfo": "dist/cli.js" }, @@ -5418,16 +5429,17 @@ } }, "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", @@ -5832,36 +5844,36 @@ } }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -5961,9 +5973,9 @@ "dev": true }, "node_modules/fast-uri": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", - "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==" + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.2.tgz", + "integrity": "sha512-GR6f0hD7XXyNJa25Tb9BuIdN0tdr+0BMi6/CJPH3wJO1JjNG3n/VsSw38AwRdKZABm8lGbPfakLRkYzx2V9row==" }, "node_modules/fastest-levenshtein": { "version": "1.0.16", @@ -6035,12 +6047,12 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -6123,9 +6135,9 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.8.tgz", - "integrity": "sha512-xgrmBhBToVKay1q2Tao5LI26B83UhrB/vM1avwVSDzt8rx3rO6AizBAaF46EgksTVr+rFTQaqZZ9MVBfUe4nig==", + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", "funding": [ { "type": "individual", @@ -6736,9 +6748,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", - "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz", + "integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==", "dependencies": { "@types/http-proxy": "^1.17.8", "http-proxy": "^1.18.1", @@ -7207,14 +7219,14 @@ } }, "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", "bin": { "jsesc": "bin/jsesc" }, "engines": { - "node": ">=4" + "node": ">=6" } }, "node_modules/json-buffer": { @@ -7309,20 +7321,20 @@ } }, "node_modules/launch-editor": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.8.2.tgz", - "integrity": "sha512-eF5slEUZXmi6WvFzI3dYcv+hA24/iKnROf24HztcURJpSz9RBmBgz5cNCVOeguouf1llrwy6Yctl4C4HM+xI8g==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.9.1.tgz", + "integrity": "sha512-Gcnl4Bd+hRO9P9icCP/RVVT2o8SFlPXofuCxvA2SaZuH45whSvf5p8x5oih5ftLiVhEI4sp5xDY+R+b3zJBh5w==", "dependencies": { "picocolors": "^1.0.0", "shell-quote": "^1.8.1" } }, "node_modules/launch-editor-middleware": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/launch-editor-middleware/-/launch-editor-middleware-2.8.1.tgz", - "integrity": "sha512-GWhcsTuzgRQguiiei0BrbLX7rS5Pcj+6VyynZlS7zlnmUvVAUUBnJjILIhuIgWJXn1WSMtkfHAkXFSoQeJvwdQ==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/launch-editor-middleware/-/launch-editor-middleware-2.9.1.tgz", + "integrity": "sha512-4wF6AtPtaIENiZdH/a+3yW8Xni7uxzTEDd1z+gH00hUWBCSmQknFohznMd9BWhLk8MXObeB5ir69GbIr9qFW1w==", "dependencies": { - "launch-editor": "^2.8.1" + "launch-editor": "^2.9.1" } }, "node_modules/lerc": { @@ -7608,9 +7620,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.11", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", - "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", + "version": "0.30.12", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", + "integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } @@ -7650,9 +7662,12 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge-source-map": { "version": "1.1.0", @@ -7917,9 +7932,9 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/multicast-dns": { "version": "7.2.5", @@ -8507,9 +8522,9 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" }, "node_modules/path-type": { "version": "4.0.0", @@ -8660,9 +8675,9 @@ } }, "node_modules/postcss": { - "version": "8.4.45", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.45.tgz", - "integrity": "sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "funding": [ { "type": "opencollective", @@ -8679,8 +8694,8 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -9299,9 +9314,9 @@ } }, "node_modules/proj4": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/proj4/-/proj4-2.12.0.tgz", - "integrity": "sha512-cQJxcVX7+fmAhOxoazKgk76GkGYQ5HcLod4rdy2MizhPvLdrZQJThxsHoz/TjjdxUvTm/rbozMgE0q9mdXKWIw==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/proj4/-/proj4-2.12.1.tgz", + "integrity": "sha512-vmhP3hmstjXjzFwg8QXJwpoj4n7GVrXk3ZW3DzNK/Ur4cuwXq7ZiMXaWYvLYLQbX8n4MXgbwTr4lthOUZltBpA==", "dependencies": { "mgrs": "1.0.0", "wkt-parser": "^1.3.3" @@ -9338,9 +9353,9 @@ "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==" }, "node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -9355,11 +9370,11 @@ } }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -9835,9 +9850,9 @@ "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==" }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -9870,10 +9885,13 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } }, "node_modules/serialize-javascript": { "version": "6.0.2", @@ -9954,14 +9972,14 @@ } }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" @@ -10113,9 +10131,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "engines": { "node": ">=0.10.0" } @@ -10372,9 +10390,9 @@ } }, "node_modules/sweetalert2": { - "version": "11.13.2", - "resolved": "https://registry.npmjs.org/sweetalert2/-/sweetalert2-11.13.2.tgz", - "integrity": "sha512-Q361QVJrDce3pA+46m2JmfDQyxxlmVE6t7ScoMwubm2PQKTlUqaMpzWq/DZRSPL8Sg2hUCzUAXQ9dwMPnbsy7Q==", + "version": "11.14.3", + "resolved": "https://registry.npmjs.org/sweetalert2/-/sweetalert2-11.14.3.tgz", + "integrity": "sha512-6NuBHWJCv2gtw4y8PUXLB41hty+V6U2mKZMAvydL1IRPcORR0yuyq3cjFD/+ByrCk3muEFggbZX/x6HwmbVfbA==", "funding": { "type": "individual", "url": "https://github.com/sponsors/limonte" @@ -10389,9 +10407,9 @@ } }, "node_modules/terser": { - "version": "5.31.6", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.6.tgz", - "integrity": "sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==", + "version": "5.34.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.34.1.tgz", + "integrity": "sha512-FsJZ7iZLd/BXkz+4xrRTGJ26o/6VTjQytUk8b8OxkwcD2I+79VPJlz7qss1+zE7h8GNIScFqXcDyJ/KqBYZFVA==", "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -10656,9 +10674,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", - "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", "funding": [ { "type": "opencollective", @@ -10674,8 +10692,8 @@ } ], "dependencies": { - "escalade": "^3.1.2", - "picocolors": "^1.0.1" + "escalade": "^3.2.0", + "picocolors": "^1.1.0" }, "bin": { "update-browserslist-db": "cli.js" @@ -10883,9 +10901,9 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/webpack": { - "version": "5.94.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", - "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", + "version": "5.95.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.95.0.tgz", + "integrity": "sha512-2t3XstrKULz41MNMBF+cJ97TyHdyQ8HCt//pqErqDvNjU9YQBnZxIHa11VXsi7F3mb5/aO2tuDxdeTPdU7xu9Q==", "dependencies": { "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", diff --git a/boranga/frontend/boranga/src/api.js b/boranga/frontend/boranga/src/api.js index 5aed3129..84507f55 100644 --- a/boranga/frontend/boranga/src/api.js +++ b/boranga/frontend/boranga/src/api.js @@ -24,6 +24,7 @@ module.exports = { document_categories_dict: "/api/document_categories_dict", filtered_organisations: '/api/filtered_organisations', help_text_entries: "/api/help_text_entries", + lookup_schema_types: "/api/occurrence_report_bulk_import_schema_columns/get_lookup_filter_types/", marine_treeview: "/api/marine_treeview", occurrence_report_bulk_imports: "/api/occurrence_report_bulk_imports/", occurrence_report_bulk_import_schemas: "/api/occurrence_report_bulk_import_schemas/", @@ -293,6 +294,10 @@ module.exports = { return `/api/committee/${committee_id}/committee_members/`; }, + fields_by_model_name: function (model_name) { + `/api/content_types/fields_by_model_name/?model_name=${model_name}`; + }, + group_type_community: group_type_community, group_type_fauna: group_type_fauna, group_type_flora: group_type_flora, diff --git a/boranga/frontend/boranga/src/components/common/blank_template.vue b/boranga/frontend/boranga/src/components/common/blank_template.vue deleted file mode 100755 index 0190575b..00000000 --- a/boranga/frontend/boranga/src/components/common/blank_template.vue +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - diff --git a/boranga/frontend/boranga/src/components/common/comms_logs.vue b/boranga/frontend/boranga/src/components/common/comms_logs.vue index 1432a5d8..1ae9f22a 100755 --- a/boranga/frontend/boranga/src/components/common/comms_logs.vue +++ b/boranga/frontend/boranga/src/components/common/comms_logs.vue @@ -4,7 +4,7 @@
Logs
- +
View @@ -18,7 +18,7 @@
- +
View diff --git a/boranga/frontend/boranga/src/components/common/component_map.vue b/boranga/frontend/boranga/src/components/common/component_map.vue index 33bd83ef..3fba787c 100644 --- a/boranga/frontend/boranga/src/components/common/component_map.vue +++ b/boranga/frontend/boranga/src/components/common/component_map.vue @@ -4580,6 +4580,15 @@ export default { layer_name = this.defaultQueryLayerName; } const format = new GeoJSON(); + if (!this.layerSources[layer_name]) { + // Just adding this to cover the case of the user quickly + // pressing save and exit button before the map is fully loaded + // and the layer sources are not yet available + console.error( + `Layer source ${layer_name} not found. Cannot get features.` + ); + return; + } const layerFeatures = this.layerSources[layer_name].getFeatures(); const features = []; diff --git a/boranga/frontend/boranga/src/components/common/component_map_test.vue b/boranga/frontend/boranga/src/components/common/component_map_test.vue deleted file mode 100644 index 257d638a..00000000 --- a/boranga/frontend/boranga/src/components/common/component_map_test.vue +++ /dev/null @@ -1,329 +0,0 @@ - - - - - - diff --git a/boranga/frontend/boranga/src/components/common/conservation_status/community_status.vue b/boranga/frontend/boranga/src/components/common/conservation_status/community_status.vue index db08d555..5b2a3882 100644 --- a/boranga/frontend/boranga/src/components/common/conservation_status/community_status.vue +++ b/boranga/frontend/boranga/src/components/common/conservation_status/community_status.vue @@ -1,7 +1,7 @@
@@ -268,8 +269,8 @@
@@ -302,8 +303,8 @@ @@ -313,7 +314,7 @@
@@ -323,7 +324,7 @@
@@ -537,7 +538,6 @@ export default { change_codes: [], filtered_wa_legislative_categories: [], filtered_wa_priority_categories: [], - filtered_recommended_wa_legislative_categories: [], referral_comments_boxes: [], community_display: '', } @@ -562,7 +562,7 @@ export default { this.conservation_status_obj.processing_status == "With Assessor"; }, listing_and_review_due_date_disabled: function () { - return this.isReadOnly || this.conservation_status_obj.processing_status != "With Assessor" + return this.isReadOnly || !["With Assessor", "Unlocked"].includes(this.conservation_status_obj.processing_status) }, approval_level_disabled: function () { return this.isReadOnly || !['With Assessor', 'With Referral'].includes(this.conservation_status_obj.processing_status); @@ -607,7 +607,7 @@ export default { ) { return true; } else { - if (this.conservation_status_obj.processing_status == "Ready For Agenda") { + if (["Ready For Agenda", "Approved", "Closed", "DeListed", "Discarded"].includes(this.conservation_status_obj.processing_status)) { return true; } if ( @@ -635,7 +635,7 @@ export default { } }, conservation_list_proposed: function () { - return !(this.conservation_status_obj.processing_status == "Approved" || this.conservation_status_obj.processing_status == "DeListed") + return !(['Approved', 'DeListed', 'Declined', 'Closed', 'Unlocked'].includes(this.conservation_status_obj.processing_status)) }, canViewCurrentList: function () { return (this.conservation_status_obj.processing_status == "Approved" || this.conservation_status_obj.processing_status == "DeListed") ? false : true; @@ -688,6 +688,7 @@ export default { let data = e.params.data.id; vm.conservation_status_obj.community_id = data vm.community_display = e.params.data.text; + vm.$emit('saveConservationStatus'); }). on("select2:unselect", function (e) { var selected = $(e.currentTarget); @@ -738,28 +739,6 @@ export default { }); }); }, - filterRecommendedWALegislativeCategories: function (event) { - this.$nextTick(() => { - if (event) { - this.conservation_status_obj.recommended_wa_legislative_category_id = null; - } - - this.filtered_recommended_wa_legislative_categories = this.wa_priority_categories.filter((choice) => { - return choice.list_ids.includes(this.conservation_status_obj.recommended_wa_legislative_list_id); - }); - }); - }, - filterRecommendedWAPriorityCategories: function (event) { - this.$nextTick(() => { - if (event) { - this.conservation_status_obj.recommended_wa_priority_category_id = null; - } - - this.filtered_recommended_wa_priority_categories = this.wa_priority_categories.filter((choice) => { - return choice.list_ids.includes(this.conservation_status_obj.recommended_wa_priority_list_id); - }); - }); - }, generateReferralCommentBoxes: function () { var box_visibility = this.conservation_status_obj.assessor_mode.assessor_box_view var assessor_mode = this.conservation_status_obj.assessor_mode.assessor_level @@ -785,6 +764,16 @@ export default { toggleComment: function (updatedShowComment) { this.isShowComment = updatedShowComment; }, + saveConservationStatus: function (e) { + if(e.target.classList.contains('input.select2-search__field')){ + // We will emit this save from the select 2 event instead + // as it requires some time to populate the selected value + // and we don't want to save the conservation status before the + // select2 value is populated + return; + } + this.$emit('saveConservationStatus'); + }, }, created: async function () { let vm = this; @@ -802,8 +791,6 @@ export default { this.getCommunityDisplay(); this.filterWALegislativeCategories(); this.filterWAPriorityCategories(); - this.filterRecommendedWALegislativeCategories(); - this.filterRecommendedWAPriorityCategories(); if (!vm.is_external) { this.generateReferralCommentBoxes(); } diff --git a/boranga/frontend/boranga/src/components/common/conservation_status/cs_documents.vue b/boranga/frontend/boranga/src/components/common/conservation_status/cs_documents.vue index 650aa91e..c2cea3a6 100644 --- a/boranga/frontend/boranga/src/components/common/conservation_status/cs_documents.vue +++ b/boranga/frontend/boranga/src/components/common/conservation_status/cs_documents.vue @@ -306,7 +306,7 @@ export default { .then((response) => { swal.fire({ title: 'Discarded', - text: 'Your document has been removed', + text: 'The document has been discarded', icon: 'success', customClass: { confirmButton: 'btn btn-primary' diff --git a/boranga/frontend/boranga/src/components/common/conservation_status/cs_more_referrals.vue b/boranga/frontend/boranga/src/components/common/conservation_status/cs_more_referrals.vue index e14415b9..dba8b209 100644 --- a/boranga/frontend/boranga/src/components/common/conservation_status/cs_more_referrals.vue +++ b/boranga/frontend/boranga/src/components/common/conservation_status/cs_more_referrals.vue @@ -58,8 +58,6 @@ export default { title: 'Referral', data: 'referral', render: function (data, type, full) { - console.log(data); - return `${data.first_name} ${data.last_name}`; } }, @@ -88,7 +86,6 @@ export default { { title: 'Referral Comments', data: 'referral_comment', - 'render': function (value) { var ellipsis = '...', truncated = _.truncate(value, { @@ -233,11 +230,16 @@ export default { // activate popover when table is drawn. vm.table.on('draw.dt', function () { - var $tablePopover = $(this).find('[data-bs-toggle="popover"]'); - if ($tablePopover.length > 0) { - $tablePopover.popover(); + var tablePopover = $(this).find('[data-bs-toggle="popover"]'); + if (tablePopover.length > 0) { + new bootstrap.Popover(tablePopover, { + html: true, + trigger: 'hover', + placement: 'bottom', + container: 'body', + }); // the next line prevents from scrolling up to the top after clicking on the popover. - $($tablePopover).on('click', function (e) { + $(tablePopover).on('click', function (e) { e.preventDefault(); return true; }); diff --git a/boranga/frontend/boranga/src/components/common/conservation_status/species_status.vue b/boranga/frontend/boranga/src/components/common/conservation_status/species_status.vue index 51609115..d07e7042 100644 --- a/boranga/frontend/boranga/src/components/common/conservation_status/species_status.vue +++ b/boranga/frontend/boranga/src/components/common/conservation_status/species_status.vue @@ -1,7 +1,7 @@ @@ -276,8 +277,8 @@ @@ -310,8 +311,8 @@ @@ -321,7 +322,7 @@
@@ -331,7 +332,7 @@
@@ -544,7 +545,6 @@ export default { change_codes: [], filtered_wa_legislative_categories: [], filtered_wa_priority_categories: [], - filtered_recommended_wa_legislative_categories: [], referral_comments_boxes: [], species_display: '', taxon_previous_name: '', @@ -569,7 +569,7 @@ export default { this.conservation_status_obj.processing_status == "With Assessor"; }, listing_and_review_due_date_disabled: function () { - return this.isReadOnly || this.conservation_status_obj.processing_status != "With Assessor" + return this.isReadOnly || !["With Assessor", "Unlocked"].includes(this.conservation_status_obj.processing_status) }, approval_level_disabled: function () { return this.isReadOnly || !['With Assessor', 'With Referral'].includes(this.conservation_status_obj.processing_status); @@ -628,7 +628,7 @@ export default { return true; }, conservation_list_proposed: function () { - return !(this.conservation_status_obj.processing_status == "Approved" || this.conservation_status_obj.processing_status == "DeListed") + return !(['Approved', 'DeListed', 'Declined', 'Closed', 'Unlocked'].includes(this.conservation_status_obj.processing_status)) }, canViewCurrentList: function () { return (this.conservation_status_obj.processing_status == "Approved" || this.conservation_status_obj.processing_status == "DeListed") ? false : true; @@ -681,6 +681,7 @@ export default { vm.conservation_status_obj.species_taxonomy_id = data.id vm.species_display = data.text; vm.taxon_previous_name = data.taxon_previous_name; + vm.$emit('saveConservationStatus'); }). on("select2:unselect", function (e) { var selected = $(e.currentTarget); @@ -734,28 +735,6 @@ export default { }); }); }, - filterRecommendedWALegislativeCategories: function (event) { - this.$nextTick(() => { - if (event) { - this.conservation_status_obj.recommended_wa_legislative_category_id = null; - } - - this.filtered_recommended_wa_legislative_categories = this.wa_priority_categories.filter((choice) => { - return choice.list_ids.includes(this.conservation_status_obj.recommended_wa_legislative_list_id); - }); - }); - }, - filterRecommendedWAPriorityCategories: function (event) { - this.$nextTick(() => { - if (event) { - this.conservation_status_obj.recommended_wa_priority_category_id = null; - } - - this.filtered_recommended_wa_priority_categories = this.wa_priority_categories.filter((choice) => { - return choice.list_ids.includes(this.conservation_status_obj.recommended_wa_priority_list_id); - }); - }); - }, generateReferralCommentBoxes: function () { var box_visibility = this.conservation_status_obj.assessor_mode.assessor_box_view var assessor_mode = this.conservation_status_obj.assessor_mode.assessor_level @@ -783,6 +762,16 @@ export default { toggleComment: function (updatedShowComment) { this.isShowComment = updatedShowComment; }, + saveConservationStatus: function (e) { + if(e.target.classList.contains('input.select2-search__field')){ + // We will emit this save from the select 2 event instead + // as it requires some time to populate the selected value + // and we don't want to save the conservation status before the + // select2 value is populated + return; + } + this.$emit('saveConservationStatus'); + }, }, created: async function () { let vm = this; @@ -800,8 +789,6 @@ export default { this.getSpeciesDisplay(); this.filterWALegislativeCategories(); this.filterWAPriorityCategories(); - this.filterRecommendedWALegislativeCategories(); - this.filterRecommendedWAPriorityCategories(); if (!vm.is_external) { this.generateReferralCommentBoxes(); } diff --git a/boranga/frontend/boranga/src/components/common/more_referrals.vue b/boranga/frontend/boranga/src/components/common/more_referrals.vue deleted file mode 100755 index 01f7b161..00000000 --- a/boranga/frontend/boranga/src/components/common/more_referrals.vue +++ /dev/null @@ -1,271 +0,0 @@ - - - diff --git a/boranga/frontend/boranga/src/components/common/occurrence/occ_documents.vue b/boranga/frontend/boranga/src/components/common/occurrence/occ_documents.vue index 10b1bea9..d9eb9577 100644 --- a/boranga/frontend/boranga/src/components/common/occurrence/occ_documents.vue +++ b/boranga/frontend/boranga/src/components/common/occurrence/occ_documents.vue @@ -297,7 +297,7 @@ export default { .then((response) => { swal.fire({ title: 'Discarded', - text: 'Your document has been removed', + text: 'The document has been discarded', icon: 'success', customClass: { confirmButton: 'btn btn-primary', diff --git a/boranga/frontend/boranga/src/components/common/occurrence/ocr_documents.vue b/boranga/frontend/boranga/src/components/common/occurrence/ocr_documents.vue index 6bad99d0..6cf4d0a9 100644 --- a/boranga/frontend/boranga/src/components/common/occurrence/ocr_documents.vue +++ b/boranga/frontend/boranga/src/components/common/occurrence/ocr_documents.vue @@ -311,7 +311,7 @@ export default { .then((response) => { swal.fire({ title: 'Discarded', - text: 'Your document has been removed', + text: 'The document has been discarded', icon: 'success', customClass: { confirmButton: 'btn btn-primary', diff --git a/boranga/frontend/boranga/src/components/common/occurrence/ocr_more_referrals.vue b/boranga/frontend/boranga/src/components/common/occurrence/ocr_more_referrals.vue index c26bc201..59902e2c 100644 --- a/boranga/frontend/boranga/src/components/common/occurrence/ocr_more_referrals.vue +++ b/boranga/frontend/boranga/src/components/common/occurrence/ocr_more_referrals.vue @@ -58,8 +58,6 @@ export default { title: 'Referral', data: 'referral', render: function (data, type, full) { - console.log(data); - return `${data.first_name} ${data.last_name}`; } }, @@ -201,7 +199,7 @@ export default { myDefaultAllowList.table = [] let vm = this; - let table_id = 'cs-more-referrals-table' + vm._uid; + let table_id = 'ocr-more-referrals-table' + vm._uid; let popover_name = 'popover-' + vm._uid; let my_content = '
' let my_template = '' @@ -223,11 +221,15 @@ export default { // activate popover when table is drawn. vm.table.on('draw.dt', function () { - var $tablePopover = $(this).find('[data-bs-toggle="popover"]'); - if ($tablePopover.length > 0) { - $tablePopover.popover(); - // the next line prevents from scrolling up to the top after clicking on the popover. - $($tablePopover).on('click', function (e) { + var tablePopover = $(this).find('[data-bs-toggle="popover"]'); + if (tablePopover.length > 0) { + new bootstrap.Popover(tablePopover, { + html: true, + trigger: 'hover', + placement: 'bottom', + container: 'body', + }); // the next line prevents from scrolling up to the top after clicking on the popover. + $(tablePopover).on('click', function (e) { e.preventDefault(); return true; }); diff --git a/boranga/frontend/boranga/src/components/common/species_communities/community_documents.vue b/boranga/frontend/boranga/src/components/common/species_communities/community_documents.vue index 84b241b6..86b955bf 100644 --- a/boranga/frontend/boranga/src/components/common/species_communities/community_documents.vue +++ b/boranga/frontend/boranga/src/components/common/species_communities/community_documents.vue @@ -295,7 +295,7 @@ export default { .then((response) => { swal.fire({ title: 'Discarded', - text: 'Your document has been removed', + text: 'The document has been discarded', icon: 'success', customClass: { confirmButton: 'btn btn-primary' diff --git a/boranga/frontend/boranga/src/components/common/species_communities/species_documents.vue b/boranga/frontend/boranga/src/components/common/species_communities/species_documents.vue index 939632bd..c7a05527 100644 --- a/boranga/frontend/boranga/src/components/common/species_communities/species_documents.vue +++ b/boranga/frontend/boranga/src/components/common/species_communities/species_documents.vue @@ -293,7 +293,7 @@ export default { .then((response) => { swal.fire({ title: 'Discarded', - text: 'Your document has been removed', + text: 'The document has been discarded', icon: 'success', customClass: { confirmButton: 'btn btn-primary' diff --git a/boranga/frontend/boranga/src/components/external/conservation_status/conservation_status_proposal.vue b/boranga/frontend/boranga/src/components/external/conservation_status/conservation_status_proposal.vue index 5cbce99f..ebcdb741 100644 --- a/boranga/frontend/boranga/src/components/external/conservation_status/conservation_status_proposal.vue +++ b/boranga/frontend/boranga/src/components/external/conservation_status/conservation_status_proposal.vue @@ -39,7 +39,8 @@ + :canEditStatus="canEditStatus" :is_external="true" ref="conservation_status" + @saveConservationStatus="save_wo_confirm">
@@ -60,20 +61,25 @@
@@ -83,10 +89,10 @@

- Back to - Dashboard -

+ Back to + Dashboard +

@@ -122,7 +128,6 @@ export default { savingCSProposal: false, paySubmitting: false, newText: "", - pBody: 'pBody', missing_fields: [], proposal_parks: null, isSaved: false, @@ -139,31 +144,9 @@ export default { cs_proposal_form_url: function () { return (this.conservation_status_obj) ? `/api/conservation_status/${this.conservation_status_obj.id}/draft.json` : ''; }, - application_fee_url: function () { - return (this.proposal) ? `/application_fee/${this.proposal.id}/` : ''; - }, - proposal_submit_url: function () { - return (this.proposal) ? `/api/proposal/${this.proposal.id}/submit.json` : ''; - //return this.submit(); - }, - canEditActivities: function () { - return this.conservation_status_obj ? this.conservation_status_obj.can_user_edit : 'false'; - }, canEditStatus: function () { return this.conservation_status_obj ? this.conservation_status_obj.can_user_edit : 'false'; }, - canEditPeriod: function () { - return this.proposal ? this.conservation_status_obj.can_user_edit : 'false'; - }, - application_type_tclass: function () { - return api_endpoints.t_class; - }, - application_type_filming: function () { - return api_endpoints.filming; - }, - application_type_event: function () { - return api_endpoints.event; - }, display_group_type: function () { let group_type_string = this.conservation_status_obj.group_type // to Capitalize only first character @@ -251,11 +234,12 @@ export default { } }); }, - save_wo_confirm: function (e) { - let vm = this; - let formData = vm.set_formData() + save_wo_confirm: function () { + this.$http.post(this.cs_proposal_form_url, this.conservation_status_obj).then(res => { - vm.$http.post(vm.cs_proposal_form_url, formData); + }, err => { + console.log(err); + }); }, save_before_submit: async function (e) { let vm = this; diff --git a/boranga/frontend/boranga/src/components/internal/conservation_status/back_to_assessor.vue b/boranga/frontend/boranga/src/components/internal/conservation_status/back_to_assessor.vue new file mode 100644 index 00000000..f27038fa --- /dev/null +++ b/boranga/frontend/boranga/src/components/internal/conservation_status/back_to_assessor.vue @@ -0,0 +1,103 @@ + + + diff --git a/boranga/frontend/boranga/src/components/internal/conservation_status/conservation_status.vue b/boranga/frontend/boranga/src/components/internal/conservation_status/conservation_status.vue index 316857b0..3ffa6c38 100644 --- a/boranga/frontend/boranga/src/components/internal/conservation_status/conservation_status.vue +++ b/boranga/frontend/boranga/src/components/internal/conservation_status/conservation_status.vue @@ -27,7 +27,8 @@
-