Skip to content

Image reading session service#1397

Open
MatMoore wants to merge 3 commits intomainfrom
DTOSS-12709-image-reading-session-service
Open

Image reading session service#1397
MatMoore wants to merge 3 commits intomainfrom
DTOSS-12709-image-reading-session-service

Conversation

@MatMoore
Copy link
Copy Markdown
Contributor

@MatMoore MatMoore commented Apr 30, 2026

Description

Introduce a service with a method to begin a new session and assign a case to it.

If there are no available cases, raise an exception, NoImagesToRead.

This service should ensure the following:

  1. A reader can only see cases linked to their current provider
  2. The same case should not be assigned more than once

I've introduced a new Case model so that one Study can have many Cases. In practice, we should create exactly 2 Cases when finalising the mammogram appointment.

Jira link

https://nhsd-jira.digital.nhs.uk/browse/DTOSS-12709

Review notes

We've now got quite a few models that link together:

  1. The dicom Study is created first, with series and images
  2. Then we attach 2 Cases to the study, representing the first and second read to be done
  3. We will use ReadingSessionService to create ReadingSession/ReadingSessionItem. The ReadingSessionItem links to the case, and the ReadingSession links to the reader.
  4. When the case is read, we will add a Reading to the Study.

Let me know if you have any thoughts on how this could be simplified, I found it quite tricky to model.

I'm also thinking we should move these all to the reading app rather than the dicom app, as they're not really related to dicom. I can do this in a follow up PR unless anyone objects.

To avoid race conditions, I've used SELECT... FOR UPDATE on the query to get the most recent item from the Case queue.

This holds a lock on the selected rows, so that concurrent executions cannot select the same case in between querying the most recent case and using it to create the session. skip_locked means that instead of blocking on concurrent access, it will skip over the locked row. (I manually tested an earlier version of the query in two psql sessions to verify this behaviour.)

Review checklist

  • Check database queries are correctly scoped to current_provider
  • If this changes the gateway API (/api/v1/), confirm whether it is a breaking change — if so, a new major version (/api/v2/) is required (see ADR-006)

It's useful to have an entity representing a thing to be read, that
exists prior to us assigning it to a reader. The models we have now
don't quite fit:

- A `Reading` is not created until the opinion is given
- A `ReadingSessionItem` is not created until it is assigned to a reading session.
- A `Study` logically has 2 cases, the first read and the second read

We have to ensure that each set of images gets assigned and read exactly
2 times (prior to arbitration, which we haven't implemented yet)

I think the simplest way to handle this constraint is to have a separate
`Case` queue, push 2 cases into it per study, and then grab the oldest
case whenever we need to assign one to a reading session. We also
refer to cases in the UI.

Later we might decide to give each case a type (1st read/2nd read) so we
can do further filtering on the image reading dashboard.

When assigning to a reading session, we will link the `ReadingSessionItem`
to the `Case`.

Note: there is a second constraint that a reader can only read the same
study once, and only cases belonging to the current provider will be
visible, so it will actually be a filtered subset of this table that
is available for assigning to a reading session.
The first method is to begin a new session and assign a case to it.

If there are no available cases, raise an exception, `NoImagesToRead`.

This service should ensure the following:

1. A reader can only see cases linked to their current provider
2. The same case should not be assigned more than once

At Case creation time, we will ensure that there are exactly 2 cases per
study (not implemented yet).

To avoid race conditions, I've used `SELECT... FOR UPDATE` to get the
most recent item from the Case queue.

This holds a lock on the selected table, so that concurrent executions
cannot select the same case in between querying the most recent case
and using it to create the session. `skip_locked` means that instead of
blocking on concurrent access, it will skip over the locked row[1].

[1]. https://www.postgresql.org/docs/current/sql-select.html
This will ensure they appear in a consistent order, as we order cases
by created_at.
@MatMoore MatMoore force-pushed the DTOSS-12709-image-reading-session-service branch from ba5046e to da1bc57 Compare April 30, 2026 13:58
@sonarqubecloud
Copy link
Copy Markdown

@MatMoore MatMoore marked this pull request as ready for review April 30, 2026 14:01
session = ReadingSession.objects.create(
reader=self.reader, session_size=settings.READING_SESSION_DEFAULT_SIZE
)
item = session.items.create(session=session, case=case, reading_order=1)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think session=session is redundant.

Suggested change
item = session.items.create(session=session, case=case, reading_order=1)
item = session.items.create(case=case, reading_order=1)

Copy link
Copy Markdown
Contributor

@swebberuk swebberuk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me. Would be good to hear if anyone else has thoughts.

We'll need to determine whether a ReadingSessionItem has been completed or skipped. In the prototype a "Previously skipped" tag is displayed when viewing a set of images the user had previously skipped. There isn't a foreign key between ReadingSessionItem and Reading. Could infer this by checking if there is a Reading that references both the same user and study as referenced by the ReadingSessionItem's ReadingSession and Case.

As we continue developing the image reading pages we should get a better idea of if the model is suitable.


objects = CaseQueryset.as_manager()

study = models.ForeignKey(Study, on_delete=models.CASCADE, related_name="cases")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're using on_delete=models.CASCADE here but on_delete=models.PROTECT on Reading.

Just mentioning in case that wasn't intentional.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants