Aug 17 - Update bulk actions for inspection/issue list
This commit is contained in:
+234
-22
@@ -15,7 +15,7 @@ from app.models.project import Project
|
||||
from app.models.issue import Issue
|
||||
from app.models.user import User
|
||||
from app.utils.forms import StartInspectionForm, IssueForm
|
||||
from app.utils.decorators import supervisor_required
|
||||
from app.utils.decorators import supervisor_required, return_url
|
||||
from app.utils.pdf_export import generate_inspection_pdf, generate_inspections_list_pdf
|
||||
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
|
||||
from app.models.notification import (
|
||||
@@ -477,7 +477,7 @@ def execute(inspection_id):
|
||||
return redirect(url_for('inspections.index'))
|
||||
|
||||
if inspection.status == 'completed':
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
return redirect(_view_url(inspection_id))
|
||||
|
||||
template = inspection.template
|
||||
form_fields = template.get_form_schema()
|
||||
@@ -600,7 +600,7 @@ def execute(inspection_id):
|
||||
f'status=completed; score={score}')
|
||||
|
||||
flash('Inspection submitted successfully!', 'success')
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
return redirect(_view_url(inspection_id))
|
||||
|
||||
else:
|
||||
_save_draft(inspection, responses)
|
||||
@@ -1220,6 +1220,231 @@ def export_pdf(inspection_id):
|
||||
|
||||
# ── Flag / clear follow-up required ──────────────────────────────────────────
|
||||
|
||||
def _view_url(inspection_id):
|
||||
"""inspections.view URL that carries the list `next` through.
|
||||
|
||||
Actions posted from the detail page redirect back to that same page;
|
||||
re-attaching `next` is what keeps its Back button (and the next action)
|
||||
pointed at the filtered list the user arrived from.
|
||||
"""
|
||||
nxt = request.form.get('next') or request.args.get('next')
|
||||
if nxt:
|
||||
return url_for('inspections.view', inspection_id=inspection_id, next=nxt)
|
||||
return url_for('inspections.view', inspection_id=inspection_id)
|
||||
|
||||
|
||||
def _collect_inspection_photos(inspection):
|
||||
"""Relative storage keys owned by an inspection, for cleanup after delete.
|
||||
|
||||
Two sources: image field values inside the submitted form data (stored as
|
||||
`uploads/...` strings in the notes JSON), and the primary photo of each
|
||||
issue flagged during the inspection. Shared by the single and bulk delete
|
||||
paths so they cannot drift — a miss here leaves orphaned files in storage
|
||||
forever, and it is invisible.
|
||||
"""
|
||||
paths = []
|
||||
if inspection.notes:
|
||||
try:
|
||||
notes_data = json.loads(inspection.notes)
|
||||
form_data = notes_data.get('_form_data', {}) if isinstance(notes_data, dict) else {}
|
||||
for val in form_data.values():
|
||||
if isinstance(val, str) and val.startswith('uploads/'):
|
||||
paths.append(val)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
for issue in inspection.issues.all():
|
||||
if issue.photo_path:
|
||||
paths.append(issue.photo_path)
|
||||
return paths
|
||||
|
||||
|
||||
# ── Bulk actions from the inspections list ───────────────────────────────────
|
||||
|
||||
@bp.route('/bulk', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_action():
|
||||
"""Apply one action to every ticked inspection on the list page.
|
||||
|
||||
Partial-failure policy: act on every eligible row, skip the rest, and
|
||||
report exact counts. Permission is checked per ACTION (all are
|
||||
admin/director level except the PDF export, which anyone who can see the
|
||||
list may run); `skipped` therefore means "this row was not in a state the
|
||||
action applies to".
|
||||
"""
|
||||
back = return_url(url_for('inspections.index'))
|
||||
action = request.form.get('action', '')
|
||||
ids = request.form.getlist('inspection_ids', type=int)
|
||||
|
||||
if not ids:
|
||||
flash('No inspections selected.', 'warning')
|
||||
return redirect(back)
|
||||
|
||||
supervisor = current_user.role in ('admin', 'director')
|
||||
allowed = {
|
||||
'export': True, # read-only, already scoped below
|
||||
'delete': supervisor,
|
||||
'flag_followup': supervisor,
|
||||
'clear_followup': supervisor,
|
||||
}
|
||||
if action not in allowed:
|
||||
flash('Unknown bulk action.', 'danger')
|
||||
return redirect(back)
|
||||
if not allowed[action]:
|
||||
flash('You do not have permission for that bulk action.', 'danger')
|
||||
return redirect(back)
|
||||
|
||||
q = Inspection.query.options(
|
||||
joinedload(Inspection.facility),
|
||||
joinedload(Inspection.template),
|
||||
joinedload(Inspection.inspector),
|
||||
).filter(Inspection.id.in_(ids))
|
||||
|
||||
# Re-apply the viewer's facility scope to the SELECTED ids. The list page
|
||||
# only ever shows in-scope rows, but the id list arrives in the POST body
|
||||
# and must not be trusted — a crafted request could otherwise name any
|
||||
# inspection in the system.
|
||||
if current_user.is_inspector:
|
||||
fids = get_inspector_scope(current_user) or []
|
||||
q = q.filter(Inspection.facility_id.in_(fids)) if fids else q.filter(False)
|
||||
elif current_user.role == 'customer':
|
||||
fids = get_customer_scope(current_user) or []
|
||||
q = q.filter(Inspection.facility_id.in_(fids)) if fids else q.filter(False)
|
||||
|
||||
inspections = q.order_by(Inspection.inspection_date.desc()).all()
|
||||
out_of_scope = len(ids) - len(inspections)
|
||||
changed = 0
|
||||
skipped = out_of_scope
|
||||
|
||||
# ── Export selected to PDF ───────────────────────────────────────────
|
||||
if action == 'export':
|
||||
if not inspections:
|
||||
flash('None of the selected inspections are available to you.', 'warning')
|
||||
return redirect(back)
|
||||
from flask import Response
|
||||
pdf = generate_inspections_list_pdf(
|
||||
inspections,
|
||||
f'Selected inspections ({len(inspections)})',
|
||||
)
|
||||
log_action(ACTION_EXPORT, 'Inspection', None, 'bulk PDF export',
|
||||
f'ids={[i.id for i in inspections]}')
|
||||
return Response(
|
||||
pdf,
|
||||
mimetype='application/pdf',
|
||||
headers={'Content-Disposition':
|
||||
'attachment; filename="selected_inspections.pdf"'},
|
||||
)
|
||||
|
||||
# ── Delete ───────────────────────────────────────────────────────────
|
||||
if action == 'delete':
|
||||
from app.utils import storage
|
||||
photo_paths = []
|
||||
for insp in inspections:
|
||||
photo_paths.extend(_collect_inspection_photos(insp))
|
||||
log_action(ACTION_DELETE, 'Inspection', insp.id,
|
||||
f'{insp.template.name if insp.template else "—"} @ '
|
||||
f'{insp.facility.name if insp.facility else "—"}',
|
||||
f'bulk deleted by {current_user.username}')
|
||||
db.session.delete(insp)
|
||||
changed += 1
|
||||
db.session.commit()
|
||||
# Files only after the rows are gone — an orphaned file is recoverable,
|
||||
# a deleted file belonging to a surviving row is not.
|
||||
for rel_path in photo_paths:
|
||||
storage.delete(rel_path)
|
||||
_flash_bulk(changed, skipped, 'permanently deleted')
|
||||
|
||||
# ── Request follow-up ────────────────────────────────────────────────
|
||||
elif action == 'flag_followup':
|
||||
note = request.form.get('follow_up_note', '').strip() or None
|
||||
for insp in inspections:
|
||||
# Same two guards as the single-inspection route: nothing to follow
|
||||
# up on before submission, and a repeat request must not overwrite
|
||||
# the pending one's note or attribution.
|
||||
if insp.status != 'completed' or insp.follow_up_required:
|
||||
skipped += 1
|
||||
continue
|
||||
insp.follow_up_required = True
|
||||
insp.follow_up_note = note
|
||||
insp.follow_up_requested_by = current_user.id
|
||||
insp.follow_up_requested_at = now_eastern()
|
||||
changed += 1
|
||||
db.session.commit()
|
||||
|
||||
for insp in inspections:
|
||||
if insp.follow_up_requested_by != current_user.id or not insp.follow_up_required:
|
||||
continue
|
||||
body = (f'{current_user.display_name} has requested a follow-up '
|
||||
f're-inspection of "{insp.template.name if insp.template else "—"}" '
|
||||
f'at {insp.facility.name if insp.facility else "—"}.'
|
||||
+ (f' Note: {note}' if note else ''))
|
||||
inspector = db.session.get(User, insp.inspector_id)
|
||||
if inspector and inspector.id != current_user.id:
|
||||
notify(
|
||||
recipient = inspector,
|
||||
title = f'Follow-Up Required: Inspection #{insp.id}',
|
||||
body = body,
|
||||
link = url_for('inspections.view', inspection_id=insp.id),
|
||||
inspection_id = insp.id,
|
||||
event_type = EVENT_INSPECTION_DONE,
|
||||
send_email = True,
|
||||
)
|
||||
# Through the matrix, not straight to managers — rule 73, so
|
||||
# per-contract recipients fire here exactly as they do for a
|
||||
# single request.
|
||||
notify_by_matrix(
|
||||
event_type = EVENT_FOLLOWUP_REQUESTED,
|
||||
title = f'Follow-Up Requested: Inspection #{insp.id}',
|
||||
body = body,
|
||||
link = url_for('inspections.view', inspection_id=insp.id),
|
||||
inspection_id = insp.id,
|
||||
facility_id = insp.facility_id,
|
||||
exclude_user_ids = {current_user.id,
|
||||
inspector.id if inspector else None} - {None},
|
||||
)
|
||||
log_action(ACTION_UPDATE, 'Inspection', insp.id,
|
||||
f'{insp.template.name if insp.template else "—"}',
|
||||
f'bulk follow_up_required=True by {current_user.username}')
|
||||
db.session.commit() # notify() does not commit — rule 70
|
||||
_flash_bulk(changed, skipped, 'flagged for follow-up',
|
||||
skip_reason='not submitted, or already flagged')
|
||||
|
||||
# ── Clear follow-up ──────────────────────────────────────────────────
|
||||
elif action == 'clear_followup':
|
||||
for insp in inspections:
|
||||
if not insp.follow_up_required:
|
||||
skipped += 1
|
||||
continue
|
||||
insp.follow_up_required = False
|
||||
insp.follow_up_note = None
|
||||
insp.follow_up_requested_by = None
|
||||
insp.follow_up_requested_at = None
|
||||
changed += 1
|
||||
log_action(ACTION_UPDATE, 'Inspection', insp.id,
|
||||
f'{insp.template.name if insp.template else "—"}',
|
||||
f'bulk follow_up cleared by {current_user.username}')
|
||||
db.session.commit()
|
||||
_flash_bulk(changed, skipped, 'cleared of the follow-up flag',
|
||||
skip_reason='not flagged')
|
||||
|
||||
current_app.logger.info(
|
||||
'INSPECTIONS | bulk | action=%s user=%s selected=%s changed=%s skipped=%s',
|
||||
action, current_user.username, len(ids), changed, skipped,
|
||||
)
|
||||
return redirect(back)
|
||||
|
||||
|
||||
def _flash_bulk(changed, skipped, verb, skip_reason='no change needed'):
|
||||
"""One consistent result message for every bulk action."""
|
||||
if not changed and not skipped:
|
||||
flash('Nothing to do.', 'info')
|
||||
return
|
||||
parts = [f'{changed} inspection{"s" if changed != 1 else ""} {verb}']
|
||||
if skipped:
|
||||
parts.append(f'{skipped} skipped ({skip_reason})')
|
||||
flash('. '.join(parts) + '.', 'success' if changed else 'warning')
|
||||
|
||||
|
||||
@bp.route('/<int:inspection_id>/flag-followup', methods=['POST'])
|
||||
@login_required
|
||||
def flag_followup(inspection_id):
|
||||
@@ -1245,12 +1470,12 @@ def flag_followup(inspection_id):
|
||||
# Nothing to follow up on until the inspection has been submitted.
|
||||
if inspection.status != 'completed':
|
||||
flash('You can only request a follow-up on a completed inspection.', 'warning')
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
return redirect(_view_url(inspection_id))
|
||||
# Don't let a repeat request overwrite the note/attribution of a pending
|
||||
# one — the flag is already raised and staff are already on it.
|
||||
if inspection.follow_up_required:
|
||||
flash('A follow-up has already been requested for this inspection.', 'info')
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
return redirect(_view_url(inspection_id))
|
||||
elif current_user.role not in ('admin', 'director'):
|
||||
abort(403)
|
||||
|
||||
@@ -1312,7 +1537,7 @@ def flag_followup(inspection_id):
|
||||
flash('Follow-up re-inspection requested. The team has been notified.', 'success')
|
||||
else:
|
||||
flash('Follow-up inspection required flag set.', 'warning')
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
return redirect(_view_url(inspection_id))
|
||||
|
||||
|
||||
@bp.route('/<int:inspection_id>/clear-followup', methods=['POST'])
|
||||
@@ -1332,7 +1557,7 @@ def clear_followup(inspection_id):
|
||||
f'{inspection.template.name} @ {inspection.facility.name}',
|
||||
'follow_up_required=False (cleared)')
|
||||
flash('Follow-up flag cleared.', 'success')
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
return redirect(_view_url(inspection_id))
|
||||
|
||||
|
||||
# ── Start a re-inspection (linked to parent) ──────────────────────────────────
|
||||
@@ -1378,20 +1603,7 @@ def delete(inspection_id):
|
||||
template_name = inspection.template.name
|
||||
inspector_name = inspection.inspector.username
|
||||
|
||||
photo_paths = []
|
||||
if inspection.notes:
|
||||
try:
|
||||
notes_data = json.loads(inspection.notes)
|
||||
form_data = notes_data.get('_form_data', {}) if isinstance(notes_data, dict) else {}
|
||||
for val in form_data.values():
|
||||
if isinstance(val, str) and val.startswith('uploads/'):
|
||||
photo_paths.append(val)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
for issue in inspection.issues.all():
|
||||
if issue.photo_path:
|
||||
photo_paths.append(issue.photo_path)
|
||||
photo_paths = _collect_inspection_photos(inspection)
|
||||
|
||||
db.session.delete(inspection)
|
||||
db.session.commit()
|
||||
@@ -1418,4 +1630,4 @@ def delete(inspection_id):
|
||||
f'has been permanently deleted.',
|
||||
'success'
|
||||
)
|
||||
return redirect(url_for('inspections.index'))
|
||||
return redirect(return_url(url_for('inspections.index')))
|
||||
+201
-13
@@ -15,7 +15,8 @@ from app.models.notification import (
|
||||
EVENT_CUSTOMER_ISSUE_UPDATED,
|
||||
)
|
||||
from app.utils.forms import IssueForm, IssueUpdateForm
|
||||
from app.utils.decorators import supervisor_required, issue_manager_required
|
||||
from app.utils.decorators import (supervisor_required, issue_manager_required,
|
||||
return_url)
|
||||
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
|
||||
from app.utils.pdf_export import generate_issues_list_pdf
|
||||
@@ -430,7 +431,7 @@ def view(issue_id):
|
||||
comment_body = request.form.get('update_notes', '').strip()
|
||||
if not comment_body:
|
||||
flash('Comment cannot be empty.', 'warning')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
comment = IssueComment(
|
||||
issue_id=issue.id,
|
||||
user_id=current_user.id,
|
||||
@@ -444,7 +445,7 @@ def view(issue_id):
|
||||
f'#{issue.id}',
|
||||
'customer comment added')
|
||||
flash('Comment posted.', 'success')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
form = IssueUpdateForm(obj=issue)
|
||||
staff = User.query.filter(User.role.in_(['director', 'inspector', 'external_inspector', 'auditor'])).order_by(User.username).all()
|
||||
@@ -685,7 +686,7 @@ def view(issue_id):
|
||||
f'#{issue.id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'}',
|
||||
f'status={issue.status}; handler={issue.handler_type}; assigned_to={issue.assigned_to}')
|
||||
flash('Issue updated.', 'success')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
is_following = issue.is_followed_by(current_user)
|
||||
|
||||
@@ -728,7 +729,7 @@ def follow(issue_id):
|
||||
flash('You are now following this issue and will receive notifications for any updates.', 'success')
|
||||
else:
|
||||
flash('You are already following this issue.', 'info')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
|
||||
# ── Unfollow ──────────────────────────────────────────────────────────────────
|
||||
@@ -878,7 +879,7 @@ def create():
|
||||
)
|
||||
db.session.commit()
|
||||
flash('Issue created.', 'success')
|
||||
return redirect(url_for('issues.index'))
|
||||
return redirect(return_url(url_for('issues.index')))
|
||||
|
||||
return render_template('issues/form.html', form=form, title='Log New Issue',
|
||||
projects=projects, selected_project_id=selected_project_id)
|
||||
@@ -897,7 +898,7 @@ def verify(issue_id):
|
||||
|
||||
if issue.status not in ('resolved', 'pending_verification'):
|
||||
flash('Only resolved or pending-verification issues can be verified.', 'warning')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
note = request.form.get('verification_note', '').strip() or None
|
||||
|
||||
@@ -917,7 +918,21 @@ def verify(issue_id):
|
||||
f'#{issue_id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'}',
|
||||
f'verified_by={current_user.username}')
|
||||
flash(f'Issue #{issue_id} verified and closed.', 'success')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
|
||||
def _view_url(issue_id):
|
||||
"""issues.view URL that carries the list `next` through.
|
||||
|
||||
An update posted from the detail page redirects back to that same detail
|
||||
page; without re-attaching `next`, the Back button would lose the filters
|
||||
the user arrived with and the next action from this page would too. Only
|
||||
added when there is something to carry, so ordinary links stay clean.
|
||||
"""
|
||||
nxt = request.form.get('next') or request.args.get('next')
|
||||
if nxt:
|
||||
return url_for('issues.view', issue_id=issue_id, next=nxt)
|
||||
return url_for('issues.view', issue_id=issue_id)
|
||||
|
||||
|
||||
@bp.route('/bulk-verify', methods=['POST'])
|
||||
@@ -952,7 +967,180 @@ def bulk_verify():
|
||||
f'bulk_verified_by={current_user.username}')
|
||||
|
||||
flash(f'{verified_count} issue{"s" if verified_count != 1 else ""} verified and closed.', 'success')
|
||||
return redirect(url_for('issues.verification_queue'))
|
||||
# Reachable from BOTH the verification queue and the issues list, so honour
|
||||
# the caller's `next` and fall back to the queue as before.
|
||||
return redirect(return_url(url_for('issues.verification_queue')))
|
||||
|
||||
|
||||
# ── Bulk actions from the issues list ────────────────────────────────────────
|
||||
|
||||
#: Statuses a bulk status change may set, and what an issue must already be in
|
||||
#: for the change to mean anything. Moving an issue to the state it is already
|
||||
#: in is a no-op, so it counts as skipped rather than changed.
|
||||
_BULK_STATUSES = ('open', 'in_progress', 'resolved', 'pending_verification')
|
||||
|
||||
|
||||
@bp.route('/bulk', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_action():
|
||||
"""Apply one action to every ticked issue on the list page.
|
||||
|
||||
Partial-failure policy (matches bulk_verify): act on every eligible row,
|
||||
skip the rest, and report exact counts — never silently drop rows, and
|
||||
never let one ineligible row block the batch.
|
||||
|
||||
Permission is checked per ACTION here rather than per row: all four actions
|
||||
are manager-level, and the roles that hold them have org-wide issue access,
|
||||
so there is no per-row scope question to answer. `skipped` therefore only
|
||||
ever means "this row was not in a state the action applies to".
|
||||
"""
|
||||
back = return_url(url_for('issues.index'))
|
||||
action = request.form.get('action', '')
|
||||
ids = request.form.getlist('issue_ids', type=int)
|
||||
|
||||
if not ids:
|
||||
flash('No issues selected.', 'warning')
|
||||
return redirect(back)
|
||||
|
||||
manager = current_user.role in ('admin', 'director', 'auditor')
|
||||
deleter = current_user.role in ('admin', 'director')
|
||||
|
||||
allowed = {
|
||||
'assign': manager,
|
||||
'status': manager,
|
||||
'verify': manager,
|
||||
'delete': deleter,
|
||||
}
|
||||
if action not in allowed:
|
||||
flash('Unknown bulk action.', 'danger')
|
||||
return redirect(back)
|
||||
if not allowed[action]:
|
||||
flash('You do not have permission for that bulk action.', 'danger')
|
||||
return redirect(back)
|
||||
|
||||
issues = [i for i in (db.session.get(Issue, i_id) for i_id in ids) if i is not None]
|
||||
missing = len(ids) - len(issues)
|
||||
changed = 0
|
||||
skipped = missing
|
||||
|
||||
# ── Assign ───────────────────────────────────────────────────────────
|
||||
if action == 'assign':
|
||||
raw = request.form.get('assigned_to', '')
|
||||
user = None
|
||||
if raw and raw != '0':
|
||||
user = db.session.get(User, int(raw)) if raw.isdigit() else None
|
||||
if user is None:
|
||||
flash('That user no longer exists.', 'danger')
|
||||
return redirect(back)
|
||||
|
||||
for issue in issues:
|
||||
if issue.assigned_to == (user.id if user else None):
|
||||
skipped += 1
|
||||
continue
|
||||
issue.assigned_to = user.id if user else None
|
||||
changed += 1
|
||||
db.session.commit()
|
||||
|
||||
for issue in issues:
|
||||
if issue.assigned_to == (user.id if user else None) and user:
|
||||
notify(
|
||||
recipient = user,
|
||||
title = f'Issue #{issue.id} assigned to you',
|
||||
body = (f'{issue.severity.title()}-severity issue at '
|
||||
f'{issue.resolved_facility.name if issue.resolved_facility else "—"}: '
|
||||
f'{issue.description[:120]}'),
|
||||
link = url_for('issues.view', issue_id=issue.id),
|
||||
issue_id = issue.id,
|
||||
event_type = EVENT_ISSUE_ASSIGNED,
|
||||
send_email = True,
|
||||
)
|
||||
db.session.commit() # notify() does not commit — rule 70
|
||||
|
||||
label = user.display_name if user else 'Unassigned'
|
||||
log_action(ACTION_UPDATE, 'Issue', None, f'bulk assign → {label}',
|
||||
f'ids={[i.id for i in issues]}; changed={changed}')
|
||||
_flash_bulk(changed, skipped, f'assigned to {label}')
|
||||
|
||||
# ── Status ───────────────────────────────────────────────────────────
|
||||
elif action == 'status':
|
||||
new_status = request.form.get('status', '')
|
||||
if new_status not in _BULK_STATUSES:
|
||||
flash('Please choose a status to set.', 'warning')
|
||||
return redirect(back)
|
||||
|
||||
for issue in issues:
|
||||
if issue.status == new_status:
|
||||
skipped += 1
|
||||
continue
|
||||
old = issue.status
|
||||
issue.status = new_status
|
||||
# Keep resolved_at consistent with the status, the same way the
|
||||
# single-issue update does — a resolved issue with no resolved_at
|
||||
# breaks the SLA compliance report and the aging buckets.
|
||||
if new_status == 'resolved' and not issue.resolved_at:
|
||||
issue.resolved_at = now_eastern()
|
||||
elif new_status in ('open', 'in_progress'):
|
||||
issue.resolved_at = None
|
||||
changed += 1
|
||||
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
|
||||
f'bulk status {old} → {new_status} by {current_user.username}')
|
||||
db.session.commit()
|
||||
_flash_bulk(changed, skipped,
|
||||
f'set to {new_status.replace("_", " ").title()}')
|
||||
|
||||
# ── Verify & close ───────────────────────────────────────────────────
|
||||
elif action == 'verify':
|
||||
for issue in issues:
|
||||
if issue.status not in ('resolved', 'pending_verification'):
|
||||
skipped += 1
|
||||
continue
|
||||
issue.status = 'resolved'
|
||||
issue.verified_by = current_user.id
|
||||
issue.verified_at = now_eastern()
|
||||
if not issue.resolved_at:
|
||||
issue.resolved_at = now_eastern()
|
||||
changed += 1
|
||||
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
|
||||
f'bulk_verified_by={current_user.username}')
|
||||
db.session.commit()
|
||||
_flash_bulk(changed, skipped, 'verified and closed',
|
||||
skip_reason='not awaiting verification')
|
||||
|
||||
# ── Delete ───────────────────────────────────────────────────────────
|
||||
elif action == 'delete':
|
||||
from app.utils import storage
|
||||
photo_paths = []
|
||||
for issue in issues:
|
||||
if issue.photo_path:
|
||||
photo_paths.append(issue.photo_path)
|
||||
for lst in (issue.mobile_photo_paths, issue.result_photos):
|
||||
if lst:
|
||||
photo_paths.extend(lst)
|
||||
log_action(ACTION_DELETE, 'Issue', issue.id, f'#{issue.id}',
|
||||
f'bulk deleted by {current_user.username}')
|
||||
db.session.delete(issue)
|
||||
changed += 1
|
||||
db.session.commit()
|
||||
# Files go only after the rows are safely gone — a failure here leaves
|
||||
# an orphaned file, which is recoverable; the reverse is not.
|
||||
for rel_path in photo_paths:
|
||||
storage.delete(rel_path)
|
||||
_flash_bulk(changed, skipped, 'permanently deleted')
|
||||
|
||||
logger.info('ISSUES | bulk | action=%s user=%s selected=%s changed=%s skipped=%s',
|
||||
action, current_user.username, len(ids), changed, skipped)
|
||||
return redirect(back)
|
||||
|
||||
|
||||
def _flash_bulk(changed, skipped, verb, skip_reason='no change needed'):
|
||||
"""One consistent result message for every bulk action."""
|
||||
if not changed and not skipped:
|
||||
flash('Nothing to do.', 'info')
|
||||
return
|
||||
parts = [f'{changed} issue{"s" if changed != 1 else ""} {verb}']
|
||||
if skipped:
|
||||
parts.append(f'{skipped} skipped ({skip_reason})')
|
||||
flash('. '.join(parts) + '.', 'success' if changed else 'warning')
|
||||
|
||||
|
||||
@bp.route('/<int:issue_id>/request-verification', methods=['POST'])
|
||||
@@ -974,11 +1162,11 @@ def request_verification(issue_id):
|
||||
)
|
||||
if not can_act:
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
if issue.status not in ('in_progress',):
|
||||
flash('Issue must be in progress to request verification.', 'warning')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
issue.status = 'pending_verification'
|
||||
db.session.commit()
|
||||
@@ -1006,7 +1194,7 @@ def request_verification(issue_id):
|
||||
)
|
||||
db.session.commit()
|
||||
flash('Issue marked as pending verification. Supervisors have been notified.', 'info')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
# ── Verification queue ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1099,7 +1287,7 @@ def delete(issue_id):
|
||||
f'facility={facility_name}; description={issue_desc}')
|
||||
|
||||
flash(f'Issue #{issue_id_snap} has been permanently deleted.', 'success')
|
||||
return redirect(url_for('issues.index'))
|
||||
return redirect(return_url(url_for('issues.index')))
|
||||
|
||||
|
||||
# ── Quick-assign (AJAX) ───────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user