Skip to content

Allow users to flag reports #309

Open

Description

@medihack
No description provided.

Activity

  1. samuelvkwong commented on Sep 24, 2026

    @samuelvkwong
    Member

    This elaborates the issue with findings from the September 2026 ADIT/RADIS brainstorming, where the bullet reads: "Users allowed to flag reports and admin can view them and withdraw, talk to user that reported".

    Motivation

    A user who notices a wrong, misassigned, duplicated or sensitive report has no in-app way to tell the administrators. They have to email support out of band and the admin has to locate the report by hand. Once #294 / #308 lands, admins can withdraw a report, but there is still no inbound channel that points them at the report, records who raised it and why, and keeps a resolution trail. The bullet asks for that loop: user flags, admin sees the queue, admin withdraws or dismisses, admin can reach the reporter.

    Current behaviour (main, 30fa750)

    • No flag concept exists. The only per-user-per-report content is Note: FK owner, FK report, text, one row per (owner, report) via UniqueConstraint (radis/notes/models.py:14-34).
    • Apps add buttons under each report through register_report_panel_button (radis/reports/site.py:86-90); the panel iterates them (radis/reports/templates/reports/_report_buttons_panel.html:14-17). Notes registers at order 2 (radis/notes/apps.py:27). The panel already gates the Details link on can_view_report (_report_buttons_panel.html:6-9, radis/reports/templatetags/reports_extras.py:12-17: active group must be in report.groups), and is included from search results, the subscription inbox, the notes list, the report detail page and the report item/summary partials.
    • NoteEditView is LoginRequiredMixin + HtmxOnlyMixin, keyed by report_id, returns 204 and triggers noteChanged_<id> (radis/notes/views.py:39-55, :80-82). It never checks the report's groups; NoteAvailableBadgeView does Report.objects.get(id=report_id) with no group scope either (radis/notes/views.py:87). Group-scoped lookups to copy are in ReportDetailView (radis/reports/views.py:36-39) and chats (radis/chats/views.py:155-157).
    • ReportAdmin has no actions, list_display or list_filter (radis/reports/admin.py:75-117). The staff-only Admin Section page has an empty Stats table and links only Broadcast and Django Admin (radis/core/views.py:38-40, radis/core/templates/core/admin_section.html).
    • Mail: send_mail_to_admins (wraps mail_admins, i.e. settings.ADMINS) and send_mail_to_user exist in the shared package (adit_radis_shared/common/utils/mail.py:8-39) but nothing in radis/ calls them. ADMINS is a single DJANGO_ADMIN_EMAIL documented as the address for critical error notifications (radis/settings/base.py:226-227) and also fed by the mail_admins logging handler (:274-282); SUPPORT_EMAIL (:224) is the address shown to users. Broadcast defers to the Procrastinate task broadcast_mail, which sends from SUPPORT_EMAIL (adit_radis_shared/common/views.py:82-88, common/tasks.py:11-14).
    • On the Allow admins to withdraw and restore reports #308 branch (29741d9): Report.objects.live() (radis/reports/models.py:43-56), withdraw_selected with a mandatory-reason confirmation page (radis/reports/admin.py:135-167), WithdrawnReportAdmin with restore (:173-207), notes views switched to get_object_or_404(Report.objects.live(), ...) (radis/notes/views.py:56, :94). The design spec decides that notes and other references survive withdrawal, are hidden meanwhile, and reappear on restore (docs/superpowers/specs/2026-09-21-report-withdrawal-design.md:34).

    Proposal

    A new app radis.flags mirroring radis.notes (INSTALLED_APPS next to notes, radis/settings/base.py:88-96), sized cheapest-first:

    1. Minimal cut (recommended first PR). Model ReportFlag: report FK (CASCADE, related_name="flags"), user FK, comment TextField (mandatory, like Allow admins to withdraw and restore reports #308's single free-text reason), status OPEN/CLOSED, created_at, closed_at, closed_by, admin_note. Plain UniqueConstraint(user, report) as in Note, so the modal shows the user's existing flag and lets them edit or delete it until closed. A Flag button in the panel (order 4) that respects can_view_report, and a FlagEditView scoped like ReportDetailView (report must be in the user's active group; 404 otherwise). ReportFlagAdmin with list_display (report link to admin:reports_report_change, reporter, reporter email as mailto:, status, created), list_filter (status), raw_id_fields, and a close_selected action with an intermediate form for the admin note. Withdrawing happens on the linked Report admin page using Allow admins to withdraw and restore reports #308's existing action; no second copy of withdraw_selected and no change to Allow admins to withdraw and restore reports #308. Add an "Open flags: N" row to the Admin Section Stats table linking to the changelist filtered by status. This is one new table with two FKs and no Report migration, so none of the large-table concerns from Allow admins to withdraw (soft delete) a report and restore it later #294 apply.
    2. Reporter feedback. On close, email the reporter via send_mail_to_user with the admin note. This is the cheapest traceable reading of "talk to user that reported"; a mailto: column alone leaves no trace in RADIS. Store the sent note on the flag.
    3. Admin notification on new flag. transaction.on_commit -> Procrastinate task (default queue, like broadcast_mail) -> send_mail_to_admins. Proposed content: reporter, reason category if any, and a link to the changelist, but not the free-text comment, which will often quote the report. This is a proposal for the new mail, not an existing rule: the extraction job and broadcast mails carry no report content, but the subscription job mail already embeds a report header with patient ID, age, sex, modalities and study description per new item (radis/subscriptions/templates/subscriptions/subscription_job_finished_mail.html:11, radis/reports/templates/reports/_report_header.html:4-11). Recipient is an open question (see below).
    4. Later: a staff-only in-app Flags page with a per-flag message thread, reason TextChoices, and a per-report badge. A badge would add one query per rendered report on every search/inbox/notes page, as the note badge already does (radis/notes/templatetags/notes_extras.py:13-17), so it is deliberately left out of the minimal cut; the modal can show "you already flagged this" instead.

    Proposed: flags stay private to reporter and staff, out of search and out of the API in v1, in line with #308 keeping withdrawal admin-only (spec decision 4). Only step 1's .live() filter and the link to the withdraw action depend on #308; the model, button, modal and admin listing can go in now against main and switch to Report.objects.live() in a follow-up.

    Open questions

    1. Who may flag: every logged-in user (like notes) or gated by flags.add_reportflag, the way subscriptions (radis/subscriptions/views.py:87) and extractions (radis/extractions/views.py:97) gate creation? Proposed: every logged-in user.
    2. Which inbox receives new-flag mail: ADMINS (the critical-error address), SUPPORT_EMAIL, REGISTRATION_ADMINS (radis/settings/base.py:231, already gets account-approval mail), a new setting, or no mail in v1?
    3. Reason taxonomy: free text only, or an optional fixed category (wrong patient, duplicate, content error, sensitive, other)? Proposed: free text only in v1.
    4. When an admin withdraws from the Report admin, open flags on that report stay OPEN. Proposed: show report__withdrawn_at as a column and filter in the flag admin rather than auto-closing via a cross-app hook; restore does not reopen flags (consistent with spec decision 5 on the Allow admins to withdraw and restore reports #308 branch).
    5. Should the flag store the reporter's active group (as Subscription.group does, radis/subscriptions/views.py:112-113) to allow a per-group filter later? is_staff is global, so a group-scoped queue cannot be enforced today either way.
    6. user FK on delete: CASCADE (as Note) or SET_NULL to keep the audit row after user deletion?

    Implementation notes

    • View scoping: filter Report.objects.filter(groups=user.active_group) and return 404 when there is no active group, rather than assert active_group as ReportDetailView and chats do. Do not copy the unscoped Report.objects.get at radis/notes/views.py:87.
    • Conditional constraints (if a per-status uniqueness is wanted later) have in-repo precedent at radis/extractions/models.py:106-115.
    • Tests: follow the structure of radis/notes/tests/test_views.py (login required, htmx required) and add cases for a report outside the active group -> 404 and two users flagging the same report. For the admin action, follow the Allow admins to withdraw and restore reports #308 branch's radis/reports/tests/test_admin.py (admin_client posting to the changelist with helpers.ACTION_CHECKBOX_NAME). Assert mail by patching send_mail_to_admins / send_mail_to_user the way radis/core/tests/test_models.py:410 patches send_mail; whether Django 6.1's test runner swaps the MAILERS setting (radis/settings/base.py:208) to locmem so mail.outbox works is not verified, and no RADIS test uses mail.outbox today.
    • Docs: a line in docs/user-docs/user-guide.md:69 (report panel buttons) and an admin-guide section next to "Withdrawing Reports" that Allow admins to withdraw and restore reports #308 adds (docs/user-docs/admin-guide.md:303 on the branch).

    Related

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions