Skip to content

Add organization storage sizes and stop counting organization channels in user storage - #6291

Draft
yasinelmi wants to merge 7 commits into
learningequality:unstablefrom
yasinelmi:issue-6163-org-storage
Draft

yasinelmi wants to merge 7 commits into
learningequality:unstablefrom
yasinelmi:issue-6163-org-storage

Conversation

@yasinelmi

Copy link
Copy Markdown

Summary

Read-only organization storage, as described in #6163:

  • Organization channel sizes. New GET /api/organization/{id}/channels/ endpoint returning the organization's channels that the user can view (id, name, description, size) plus their total size. A channel's size counts each file checksum once, matching Channel.get_resource_size. The total is the sum of the listed channel sizes, so it always matches the table.
  • Channels tab on the organization page. New OrganizationChannelsTab (KDS KTable + KExternalLink, Composition API, no Vuex/Vuetify) showing Name (opens the channel in a new tab), Description and Size, with "Total size" below the table, following the Organization Details design.
  • User storage fix. User.get_user_active_trees now leaves out channels that belong to an organization, so a user's storage only counts channels they "own". This covers get_space_used, set_space_used, get_space_used_by_kind and the upload space checks.
  • Small performance tweak. Removed the unused select_related from Channel.get_resource_size (it was immediately followed by .values()).

References

Closes #6163.

Builds on #6132, which is merged into this branch because the organization pages it adds are where the new tab lives. Until #6132 merges, this diff includes its changes. The commit specific to this PR is the last one (Add organization storage sizes and exclude organization channels from user storage). I'll re-merge unstable once #6132 lands.

Also related: #6161 (channel migration), which is how channels get attached to organizations in practice.

Reviewer guidance

Things worth a close look:

  1. Quota side effect (intended, but worth agreeing on). User storage feeds check_space and the staged-upload checks. Once a user's channel belongs to an organization, its files stop counting against the user's quota, and organizations don't have quotas yet (out of scope per the issue). So uploads into organization channels aren't limited by a quota until quotas move to organizations.
  2. Total size semantics. The total is the sum of per-channel sizes. A file shared by two channels in the same organization is counted once per channel. This matches the design (the total equals the sum of the column). Deduping across channels would make the total smaller than the column sum.
  3. Visibility. The endpoint 404s for organizations the user can't view. For a public organization, non-members only see (and only get sizes for) channels they could already view.

Manual check:

  1. As an active member of an organization with channels, open My organizations → the organization → Channels tab.
  2. Each channel shows name, description and size; Total size below the table equals the sum.
  3. Clicking a channel name opens it in a new tab.
  4. An organization without channels shows "This organization has no channels yet."
  5. In Settings → Storage, attach one of your channels to an organization and confirm your used storage drops by that channel's files.

Screenshots still to be added.

Testing

  • Backend (TDD, failing first): OrganizationChannelsTestCase in tests/viewsets/test_organization.py (sizes and total, checksum dedup, deleted and other-organization channels excluded, empty channel, private-organization 404, public-organization visibility, auth required) and test_get_space_used_excludes_organization_channels in tests/test_files.py.
  • pytest on tests/viewsets, test_files.py, test_user.py, test_models.py, test_channel_model.py, test_storage_common.py: 714 passed, 5 skipped. The complete suite didn't finish within my local run's time limit, so CI is the full run.
  • Frontend (VTL): OrganizationChannelsTab.spec.js plus a Channels-tab case in OrganizationEditPage.spec.js. All channelList and shared/data suites pass (368 tests).
  • Gherkin: integration_testing/features/manage-organizations/view-organization-storage.feature.
  • pre-commit passes on all changed files.

AI usage

I used Claude Code (Claude Opus) to gather context from the issue, the design PDF and the overlapping PRs, and to draft the implementation, tests and this description. I decided how to interpret the user-storage requirement and the total-size semantics, reviewed the generated code against Studio's conventions, and ran the backend and frontend test suites and pre-commit locally.

🤖 Generated with Claude Code

nairaj2 and others added 7 commits September 8, 2026 11:49
… user storage

Organizations now expose the size of each of their channels, plus the total,
through a new organization/{id}/channels endpoint. The organization page gets a
Channels tab listing name, description and size, with the total below the
table. A channel's size counts each file checksum once.

User storage no longer counts channels that belong to an organization, since
that storage is now attributed to the organization. Quotas still belong to
users; moving them to organizations is out of scope.

Also drops an unused select_related from Channel.get_resource_size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@learning-equality-bot

Copy link
Copy Markdown
Contributor

👋 Hi @yasinelmi, thanks for contributing!

For the review process to begin, please verify that the following is satisfied:

  • Contribution is aligned with our contributing guidelines

  • Pull request description has correctly filled AI usage section & follows our AI guidance:

    AI guidance

    State explicitly whether you didn't use or used AI & how.

    If you used it, ensure that the PR is aligned with Using AI as well as our DEEP framework. DEEP asks you:

    • Disclose — Be open about when you've used AI for support.
    • Engage critically — Question what is generated. Review code for correctness and unnecessary complexity.
    • Edit — Review and refine AI output. Remove unnecessary code and verify it still works after your edits.
    • Process sharing — Explain how you used the AI so others can learn.

    Examples of good disclosures:

    "I used Claude Code to implement the component, prompting it to follow the pattern in ComponentX. I reviewed the generated code, removed unnecessary error handling, and verified the tests pass."

    "I brainstormed the approach with Gemini, then had it write failing tests for the feature. After reviewing the tests, I used Claude Code to generate the implementation. I refactored the output to reduce verbosity and ran the full test suite."

📢✨ Before we assign a reviewer:

  • @rtibblesbot will pre-review this pull request. Its comments are generated by an LLM, and should be evaluated accordingly.
  • We'll also invite community pre-review. See the community review guidance for both authors and reviewers.

Also check that issue requirements are satisfied & you ran pre-commit locally.

Pull requests that don't follow the guidelines will be closed.

Reviewer assignment can take up to 2 weeks.

@rtibbles
rtibbles requested a review from rtibblesbot October 6, 2026 18:37
@learning-equality-bot

Copy link
Copy Markdown
Contributor

📢✨ Before we assign a reviewer, we'll turn on @rtibblesbot to pre-review. Its comments are generated by an LLM, and should be evaluated accordingly.

@rtibblesbot

rtibblesbot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

🟡 Waiting for changes

Last updated: 2026-10-06 20:12 UTC

@rtibblesbot rtibblesbot left a comment

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.

PR #6291: Settings > Storage shows a stale total after a channel joins or leaves an organization. The Channels tab and endpoint work as designed.

CI passing. Manual QA covered the Channels tab (en/ar, keyboard, axe AA: 0 violations). It also covered the new-tab link, the empty state and the private-org 404. Populated rows were XHR-mocked, because nothing sets Channel.organization. A scratch backend test confirmed the stale total.

  • blocking: storage total not recalculated on organization change (models.py).
  • suggestion: organization-channel files count against no quota.
  • suggestion: a failed or 404 fetch shows "no channels yet".
  • suggestion: org size rules differ from user storage and get_resource_size.
  • suggestion: RTL direction, size wrapping and untruncated descriptions in the tab.
  • suggestion: two frontend test assertions are too weak.

Written by rtibblesbot, an LLM-based coding agent.

# Organization channels count towards the organization's storage, not the user's.
return (
self.editable_channels.exclude(deleted=True)
.filter(organization__isnull=True)

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.

blocking: Settings > Storage keeps showing the old total after a channel joins or leaves an organization.

  • The page shows the cached disk_space_used. Only calculate_user_storage refreshes it.
  • Channel.on_update (models.py:1477) recalculates editors' storage only when deleted changes.
  • Scratch test: after a channel joins an org, disk_space_used stays 275. The live value is 0.
  • The last scenario in view-organization-storage.feature fails.

Please recalculate editors' storage when organization_id changes, as for deleted. Tracking that field must not mark main_tree changed. Then make the test assert disk_space_used instead of patching calculate_user_storage.

return (
self.editable_channels.exclude(deleted=True)
.filter(organization__isnull=True)
.values(tree_id=F("main_tree__tree_id"))

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.

suggestion: Files in organization channels now count against no quota. This filter also feeds the upload quota checks. Each upload is still checked against the uploader's personal free space. #6163 scopes out quota changes. Is this intended? If so, please track it in the follow-up quota issue.

and their total size. A channel's size counts each file checksum once.
"""
organization = self.get_object()
file_sizes = (

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.

suggestion: Org channel sizes follow different rules from user storage and get_resource_size.

  • User storage excludes non-billable files (Perseus exports, null file_format). This subquery doesn't.
  • So a channel moved into an org adds more to the org total than it removes from the user's.
  • get_resource_size dedupes on (checksum, file_size) and caches. This dedupes on checksum and recomputes on every load.
  • A checksum shared by two org channels counts twice in the total. User storage counts it once.
  • No test pins either behavior.

Consider one shared helper on Channel, so the later quota migration has a single rule.

.distinct("checksum")
)
channels = (
Channel.filter_view_queryset(Channel.objects.all(), request.user)

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.

suggestion: Non-members of a public org see a partial sum labelled "Total size". It covers only the public channels. Is that intended?

channels.value = data.channels;
size.value = data.size;
})
.finally(() => {

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.

suggestion: A failed fetch shows "This organization has no channels yet." On rejection, channels stays empty and loading clears. Reproduced live: a non-member opening a private org's Channels tab gets a 404 and sees this message. Please expose an error ref and show a load-failure message instead.

v-else
:class="{ notranslate: colIndex === 1 }"
>
{{ content }}

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.

suggestion: Size values wrap mid-value. Long descriptions also render untruncated.

  • At 1280px, sizes wrap as "560 / MB". In Arabic they wrap onto 3 lines. Please add white-space: nowrap to the size cell.
  • A long description makes a very tall row. The Organization Storage (Read-Only Implementation) #6163 mockup truncates them. Please use KTextTruncator or a line clamp.

renderTab();

const link = await screen.findByRole('link', { name: /Alpha/ });
expect(link).toHaveAttribute('target', '_blank');

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.

suggestion: This test passes with a wrong channel id or a missing href. It asserts only target. The jest window.Urls proxy ignores its argument (jest_config/setup.js:69). Please stub window.Urls.channel and assert the href.

expect(within(rows[1]).getByText('10 MB')).toBeInTheDocument();
expect(within(rows[2]).getByText('560 MB')).toBeInTheDocument();
expect(
screen.getByText(organizationStrings.totalSize$({ size: '570 MB' })),

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.

nitpick: This passes wherever the total renders on the page. Please scope the query to the total element.


self.assertIn(
response.status_code,
(status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN),

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.

nitpick: This accepts a 401 the endpoint never returns. SessionAuthentication is listed first, so the response is always 403. Please pin HTTP_403_FORBIDDEN like the sibling tests (line 199).

expected_usage = baseline_usage + non_perseus_size
self.assertEqual(self.user.get_space_used(), expected_usage)

def test_get_space_used_excludes_organization_channels(self):

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.

praise: This goes through the public get_space_used() and fails if the organization__isnull filter is removed.

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.

Organization Storage (Read-Only Implementation)

4 participants