Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
c3637fc
Dev ops task 16064: OCR Bug regarding missing buffer_radius field
oakdbca Mar 20, 2026
9d8a887
fix: conditionally render ActivatedBy component based on lodgement_date
oakdbca Mar 20, 2026
ceb43b9
fix: normalize original_geometry_ewkb to bytes for accurate compariso…
oakdbca Mar 20, 2026
be81498
feat: add script to strip comments from SQL files in docs/sql-scripts
oakdbca Mar 20, 2026
fc574a6
docs: add instructions to strip comments from SQL scripts for KB comp…
oakdbca Mar 20, 2026
39b175d
refactor: update OCC_MOD_BY and ORF_MOD_BY to access the accounts_ema…
oakdbca Mar 20, 2026
c34dcb6
Dev ops task 16066: SC Change ActionLog on Activation from "Create...…
oakdbca Mar 20, 2026
094ade5
Add DATA_VERIFICATION_READ_ONLY settings/ENV that will prevent users …
oakdbca Mar 20, 2026
5ff948a
Dev ops task 15442: Add missing can_user_copy field to BaseOccurrence…
oakdbca Mar 20, 2026
624371f
chore: add docs/sql-scripts/kb/ to .gitignore to exclude SQL script d…
oakdbca Mar 20, 2026
ce97260
chore: add README for SQL script generation instructions in kb folder
oakdbca Mar 20, 2026
edb8a1a
fix: improve fetch error handling in read-only mode to prevent overwr…
oakdbca Mar 20, 2026
544b961
feat: add active_only parameter to occurrence report queries for comm…
oakdbca Mar 20, 2026
c14e0e1
fix: update occurrence dashboard filters to use data.id instead of da…
oakdbca Mar 20, 2026
bf31abc
Dev ops task 14219: ORF Dashboard - Filter changes. Bug fix of occurr…
oakdbca Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,5 @@ private-media/
**/data_migration/legacy_data/

.db-backups/

docs/sql-scripts/kb/
4 changes: 2 additions & 2 deletions boranga/components/occurrence/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ def get_date(filter_date):

filter_occurrence_name = request.POST.get("filter_occurrence_name")
if filter_occurrence_name and not filter_occurrence_name.lower() == "all":
queryset = queryset.filter(occurrence__occurrence_name__icontains=filter_occurrence_name)
queryset = queryset.filter(occurrence__id=filter_occurrence_name)

filter_common_name = request.POST.get("filter_common_name")
if filter_common_name and not filter_common_name.lower() == "all":
Expand Down Expand Up @@ -2569,7 +2569,7 @@ def filter_queryset(self, request, queryset, view):

filter_occurrence_name = request.POST.get("filter_occurrence_name")
if filter_occurrence_name and not filter_occurrence_name.lower() == "all":
queryset = queryset.filter(occurrence_name__icontains=filter_occurrence_name)
queryset = queryset.filter(id=filter_occurrence_name)

filter_scientific_name = request.POST.get("filter_scientific_name")
if filter_scientific_name and not filter_scientific_name.lower() == "all":
Expand Down
6 changes: 6 additions & 0 deletions boranga/components/occurrence/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1463,6 +1463,7 @@ class BaseOccurrenceReportSerializer(BaseModelSerializer):
has_main_observer = serializers.BooleanField(read_only=True)
is_submitter = serializers.SerializerMethodField()
can_user_edit = serializers.SerializerMethodField()
can_user_copy = serializers.SerializerMethodField()
common_names = serializers.SerializerMethodField(read_only=True)

class Meta:
Expand Down Expand Up @@ -1514,6 +1515,7 @@ class Meta:
"number_of_observers",
"has_main_observer",
"is_submitter",
"can_user_copy",
"record_source",
"comments",
"ocr_for_occ_number",
Expand Down Expand Up @@ -1623,6 +1625,10 @@ def get_can_user_edit(self, obj):
request = self.context["request"]
return obj.can_user_edit(request)

def get_can_user_copy(self, obj):
request = self.context["request"]
return obj.submitter == request.user.id or is_occurrence_assessor(request)


class OccurrenceReportSerializer(BaseOccurrenceReportSerializer):
submitter = serializers.SerializerMethodField(read_only=True)
Expand Down
14 changes: 11 additions & 3 deletions boranga/components/spatial/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -563,8 +563,11 @@ def save_geometry(
is_new_geometry = False
# Capture existing state to detect whether geometry data actually changes
pre_save_geometry_wkb = geometry.geometry.ewkb if geometry.geometry else None
pre_save_original_ewkb = geometry.original_geometry_ewkb
pre_save_buffer_radius = geometry.buffer_radius
# BinaryField returns memoryview from DB; normalise to bytes for comparison
pre_save_original_ewkb = (
bytes(geometry.original_geometry_ewkb) if geometry.original_geometry_ewkb else None
)
pre_save_buffer_radius = getattr(geometry, "buffer_radius", None)
else:
logger.info(f"Creating new geometry for {instance_model_name}: {instance}")

Expand Down Expand Up @@ -605,7 +608,12 @@ def save_geometry(
geometry_changed = (
(geometry_instance.geometry.ewkb if geometry_instance.geometry else None)
!= pre_save_geometry_wkb
or geometry_instance.original_geometry_ewkb != pre_save_original_ewkb
or (
bytes(geometry_instance.original_geometry_ewkb)
if geometry_instance.original_geometry_ewkb
else None
)
!= pre_save_original_ewkb
or geometry_instance.buffer_radius != pre_save_buffer_radius
)
if geometry_changed:
Expand Down
2 changes: 2 additions & 0 deletions boranga/components/species_and_communities/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1389,6 +1389,7 @@ class SpeciesUserAction(UserAction):
ACTION_REINSTATE_SPECIES = "Reinstate Species {}"
ACTION_EDIT_SPECIES = "Edit Species {}"
ACTION_CREATE_SPECIES = "Create new species {}"
ACTION_ACTIVATE_SPECIES = "Activated species {}"
ACTION_SAVE_SPECIES = "Save Species {}"
ACTION_MAKE_HISTORICAL = "Make Species {} historical"
ACTION_IMAGE_UPDATE = "Species Image document updated for Species {}"
Expand Down Expand Up @@ -2305,6 +2306,7 @@ class CommunityUserAction(UserAction):
ACTION_DISCARD_COMMUNITY = "Discard Community {}"
ACTION_REINSTATE_COMMUNITY = "Reinstate Community {}"
ACTION_CREATE_COMMUNITY = "Create new community {}"
ACTION_ACTIVATE_COMMUNITY = "Activated community {}"
ACTION_SAVE_COMMUNITY = "Save Community {}"
ACTION_RENAME_COMMUNITY_MADE_HISTORICAL = "Community {} renamed to {} and made historical"
ACTION_RENAME_COMMUNITY_RETAINED = "Community {} renamed to {} but left active"
Expand Down
8 changes: 4 additions & 4 deletions boranga/components/species_and_communities/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,13 @@ def species_form_submit(species_instance, request, split=False, rename=False):

# Create a log entry for the proposal
species_instance.log_user_action(
SpeciesUserAction.ACTION_CREATE_SPECIES.format(species_instance.species_number),
SpeciesUserAction.ACTION_ACTIVATE_SPECIES.format(species_instance.species_number),
request,
)

# Create a log entry for the user
request.user.log_user_action(
SpeciesUserAction.ACTION_CREATE_SPECIES.format(species_instance.species_number),
SpeciesUserAction.ACTION_ACTIVATE_SPECIES.format(species_instance.species_number),
request,
)

Expand All @@ -80,13 +80,13 @@ def community_form_submit(community_instance, request):

# Create a log entry for the proposal
community_instance.log_user_action(
CommunityUserAction.ACTION_CREATE_COMMUNITY.format(community_instance.community_number),
CommunityUserAction.ACTION_ACTIVATE_COMMUNITY.format(community_instance.community_number),
request,
)

# Create a log entry for the user
request.user.log_user_action(
CommunityUserAction.ACTION_CREATE_COMMUNITY.format(community_instance.community_number),
CommunityUserAction.ACTION_ACTIVATE_COMMUNITY.format(community_instance.community_number),
request,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1200,7 +1200,7 @@ export default {
},
})
.on('select2:select', function (e) {
let data = e.params.data.text;
let data = e.params.data.id;
vm.filterOCCCommunityOccurrenceName = data;
sessionStorage.setItem(
'filterOCCCommunityOccurrenceNameText',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1225,7 +1225,7 @@ export default {
},
})
.on('select2:select', function (e) {
let data = e.params.data.text;
let data = e.params.data.id;
vm.filterOCCFaunaOccurrenceName = data;
sessionStorage.setItem(
'filterOCCFaunaOccurrenceNameText',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1148,7 +1148,7 @@ export default {
},
})
.on('select2:select', function (e) {
let data = e.params.data.text;
let data = e.params.data.id;
vm.filterOCCFloraOccurrenceName = data;
sessionStorage.setItem(
'filterOCCFloraOccurrenceNameText',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1673,6 +1673,7 @@ export default {
term: params.term,
type: 'public',
group_type_id: vm.group_type_id,
active_only: false,
};
return query;
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1677,6 +1677,7 @@ export default {
term: params.term,
type: 'public',
group_type_id: vm.group_type_id,
active_only: false,
};
return query;
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1422,6 +1422,7 @@ export default {
term: params.term,
type: 'public',
group_type_id: vm.group_type_id,
active_only: false,
};
return query;
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
/>

<ActivatedBy
v-if="occurrence.lodgement_date"
:submitter_first_name="submitter_first_name"
:submitter_last_name="submitter_last_name"
:lodgement_date="occurrence.lodgement_date"
Expand Down
88 changes: 88 additions & 0 deletions boranga/frontend/boranga/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,59 @@ const app = createApp(App);

const originalFetch = window.fetch.bind(window);

// --- Read-only mode helpers ---
const READ_ONLY_TOAST_ID = 'boranga-read-only-toast';

function showReadOnlyBanner() {
if (document.getElementById(READ_ONLY_TOAST_ID)) return;

// Toast container — fixed top-right, no page height impact
const container = document.createElement('div');
container.style.cssText = 'position:fixed;top:6px;right:1rem;z-index:9999;';

container.innerHTML = `
<div id="${READ_ONLY_TOAST_ID}" class="toast align-items-center border-0 show" role="alert" aria-live="assertive" aria-atomic="true" style="background-color:#92400e;color:#fff;">
<div class="d-flex">
<div class="toast-body fw-semibold">
\u{1F512} System READ-ONLY for data verification
</div>
</div>
</div>`;

document.body.appendChild(container);
}

function hideReadOnlyBanner() {
document
.getElementById(READ_ONLY_TOAST_ID)
?.closest('div[style]')
?.remove();
}

/** Fetches the current read-only status from the server and updates window.env + banner. */
function refreshReadOnlyStatus() {
return originalFetch('/api/read_only_status')
.then((r) => r.json())
.then((data) => {
window.env = window.env || {};
window.env.read_only = !!data.read_only;
if (window.env.read_only) {
showReadOnlyBanner();
} else {
hideReadOnlyBanner();
}
return window.env.read_only;
})
.catch(() => false);
}

// Check on initial page load.
refreshReadOnlyStatus();

// Do NOT make the outer wrapper async; otherwise window.fetch becomes a Promise.
window.fetch = ((orig) => {
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);

return async (...args) => {
// Normalize URL (string | URL | Request)
let url;
Expand All @@ -49,6 +100,43 @@ window.fetch = ((orig) => {
const sameOrigin = url.origin === window.location.origin;
const isApi = sameOrigin && url.pathname.startsWith('/api');

// Determine request method
const method = (
(args.length > 1 && args[1]?.method) ||
(args[0] instanceof Request && args[0].method) ||
'GET'
).toUpperCase();

// Re-check read-only status from the server on every write attempt.
// DataTables uses POST on _paginated endpoints when the querystring is too
// long — these are read-only list queries and must not be blocked.
if (
isApi &&
WRITE_METHODS.has(method) &&
!url.pathname.includes('_paginated')
) {
const isReadOnly = await refreshReadOnlyStatus();
if (isReadOnly) {
swal.fire({
iconHtml:
'<i class="bi bi-lock-fill" style="font-size:3rem;color:#92400e;"></i>',
customClass: {
icon: 'border-0',
confirmButton: 'btn btn-primary',
},
title: 'Read-Only Mode',
text: 'The system is currently in read-only mode for data verification. No changes can be made at this time.',
});
// Throw an AbortError so the fetch promise rejects silently —
// component reject handlers typically just log and show nothing,
// preventing a second error dialog from overwriting the lock dialog.
throw new DOMException(
'Request blocked: system is in read-only mode.',
'AbortError'
);
}
}

// Merge headers
let headers = new Headers();
if (args.length > 1 && args[1]?.headers) {
Expand Down
44 changes: 44 additions & 0 deletions boranga/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,50 @@ def __call__(self, request):
return redirect(path_ft + "?next=" + quote_plus(request.get_full_path()))


class ReadOnlyMiddleware:
"""Blocks all write requests (POST/PUT/PATCH/DELETE) to the API when
settings.DATA_VERIFICATION_READ_ONLY is truthy. Django admin, authentication,
and other non-API paths are exempt so that admin and ORM-level operations still
work. POST requests to *_paginated endpoints are also allowed through because
DataTables uses POST when the querystring is too long — these are read-only
list queries, not writes."""

EXEMPT_PATH_PREFIXES = (
"/admin/",
"/ledger/",
"/sso/",
"/logout",
"/ssologin",
)
WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})

def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
from django.conf import settings as django_settings

if (
getattr(django_settings, "DATA_VERIFICATION_READ_ONLY", False)
and request.method in self.WRITE_METHODS
and request.path.startswith("/api/")
and not any(request.path.startswith(p) for p in self.EXEMPT_PATH_PREFIXES)
# Allow DataTables POST queries on paginated endpoints (read-only list queries)
and "_paginated" not in request.path
):
from django.http import JsonResponse

return JsonResponse(
{
"detail": "The system is currently in read-only mode for data verification. "
"No changes can be made at this time."
},
status=503,
)

return self.get_response(request)


class RevisionOverrideMiddleware(RevisionMiddleware):
"""
Wraps the entire request in a revision.
Expand Down
2 changes: 2 additions & 0 deletions boranga/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
DISABLE_EMAIL = env("DISABLE_EMAIL", False)
SHOW_TESTS_URL = env("SHOW_TESTS_URL", False)
SHOW_DEBUG_TOOLBAR = env("SHOW_DEBUG_TOOLBAR", False)
DATA_VERIFICATION_READ_ONLY = env("DATA_VERIFICATION_READ_ONLY", False)
TIME_ZONE = "Australia/Perth"

SILENCE_SYSTEM_CHECKS = env("SILENCE_SYSTEM_CHECKS", False)
Expand Down Expand Up @@ -156,6 +157,7 @@ def show_toolbar(request):
}

MIDDLEWARE_CLASSES += [
"boranga.middleware.ReadOnlyMiddleware",
"boranga.middleware.FirstTimeNagScreenMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
]
Expand Down
1 change: 1 addition & 0 deletions boranga/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ def trigger_error(request):
router.registry.sort(key=lambda x: x[0])

api_patterns = [
re_path(r"^api/read_only_status$", views.ReadOnlyStatusView.as_view(), name="read-only-status"),
re_path(r"^api/profile$", users_api.GetProfile.as_view(), name="get-profile"),
re_path(
r"^api/geojson_to_shapefile$",
Expand Down
12 changes: 11 additions & 1 deletion boranga/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.core.management import call_command
from django.http import HttpResponse
from django.http import HttpResponse, JsonResponse
from django.shortcuts import redirect, render
from django.utils.http import url_has_allowed_host_and_scheme
from django.views import View
from django.views.generic import DetailView
from django.views.generic.base import TemplateView

Expand Down Expand Up @@ -167,6 +168,15 @@ class BorangaFurtherInformationView(TemplateView):
template_name = "boranga/further_info.html"


class ReadOnlyStatusView(View):
"""Returns the current read-only status. Never cached."""

def get(self, request):
response = JsonResponse({"read_only": getattr(settings, "DATA_VERIFICATION_READ_ONLY", False)})
response["Cache-Control"] = "no-store"
return response


class ManagementCommandsView(LoginRequiredMixin, UserPassesTestMixin, TemplateView):
template_name = "boranga/mgt-commands.html"

Expand Down
Loading
Loading