Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d0b294b
Add ability to send a notification email on review of community libra…
marcellamaki Jul 21, 2026
5e176a5
fix: dedupe ChannelVersion.included_licenses in API responses
AlexVelezLl Jul 31, 2026
9406e65
fix: preserve channel status filter when navigating back to channels …
AlexVelezLl Jul 31, 2026
7295cc7
fix: remove fixed width from community library status button
AlexVelezLl Jul 31, 2026
701a756
Merge pull request #6076 from AlexVelezLl/esocc-miscelaneous-fixes
rtibbles Aug 4, 2026
c4f699e
Merge pull request #6061 from AlexVelezLl/fix/channel-version-include…
rtibbles Aug 4, 2026
3fab968
Simplify strings and update the place in the workflow that the mail i…
marcellamaki Aug 4, 2026
1bc0c9a
Merge pull request #6050 from marcellamaki/send-notification-email
marcellamaki Aug 5, 2026
3dc898c
feat: expose Community Library channel versions without an account
rtibbles Aug 18, 2026
c880816
feat: show Community Library alongside Kolibri Library for signed-out…
rtibbles Aug 18, 2026
7962309
Merge pull request #6097 from rtibbles/public_community
marcellamaki Aug 18, 2026
088f126
fix: count subscription storage towards the upload quota
rtibbles Sep 21, 2026
9bbb172
Merge pull request #6157 from rtibbles/subscription_storage_quota
rtibbles Sep 21, 2026
d21ad67
fix: use Django's built-in password reset confirm flow
rtibbles Sep 24, 2026
fc30d83
Merge pull request #6175 from rtibbles/reset_confirm_fix
bjester Sep 24, 2026
a39f160
Merge hotfixes into unstable
rtibbles Sep 25, 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
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ describe('account store', () => {
const passwordData = {
new_password1: 'testing password',
new_password2: 'testing password',
token: 'testing token',
uidb64: 'testing uidb64',
};
return store.dispatch('account/setPassword', passwordData).then(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ export default {
sendPasswordResetLink(context, email) {
return client.post(window.Urls.auth_password_reset(), { email });
},
setPassword(context, { uidb64, token, new_password1, new_password2 }) {
setPassword(context, { uidb64, new_password1, new_password2 }) {
const data = {
new_password1,
new_password2,
};
return client.post(window.Urls.auth_password_reset_confirm(uidb64, token), data, {
// Django's PasswordResetConfirmView.reset_url_token
// https://docs.djangoproject.com/en/3.2/topics/auth/default/#django.contrib.auth.views.PasswordResetConfirmView
return client.post(window.Urls.auth_password_reset_confirm(uidb64, 'set-password'), data, {
headers: {
'Content-type': 'application/form-url-encode',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@
.community-library-status-button {
@extend %md-standard-func;
width: 9em;
padding: 4px;
color: v-bind('labelColor');
background-color: v-bind('color');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,11 +304,16 @@
fetchQueryParams: keywordSearchFetchQueryParams,
} = useKeywordSearch();

// channelStatusFilter is derived from the current channelType's options, so an
// existing status that's no longer valid for the new type already reads back as
// unset - only default it to the first option in that case.
watch(
channelTypeFilter,
() => {
const options = channelStatusOptions.value;
channelStatusFilter.value = options.length ? options[0].value : null;
if (!channelStatusFilter.value) {
const options = channelStatusOptions.value;
channelStatusFilter.value = options.length ? options[0].value : null;
}
},
{ immediate: true },
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ localVue.use(router);

const channelList = ['test', 'channel', 'table'];

function makeWrapper(store) {
router.replace({ name: RouteNames.CHANNELS });
function makeWrapper(store, query = {}) {
router.replace({ name: RouteNames.CHANNELS, query });

return mount(ChannelTable, {
router,
Expand Down Expand Up @@ -71,13 +71,32 @@ describe('channelTable', () => {

expect(router.currentRoute.query.keywords).toBe('keyword test');
});
it('changing channel type filter should reset channel status filter', async () => {
it('changing channel type filter should reset channel status filter when it is no longer valid', async () => {
wrapper.vm.channelTypeFilter = ChannelTypeFilter.COMMUNITY_LIBRARY;
wrapper.vm.channelStatusFilter = 'needsReview';
await wrapper.vm.$nextTick();
// Kolibri library channels have no "needs review" status
wrapper.vm.channelTypeFilter = ChannelTypeFilter.KOLIBRI_LIBRARY;
await wrapper.vm.$nextTick();
expect(wrapper.vm.channelStatusFilter).toBe('live');
});
it('changing channel type filter should keep the channel status filter when it is still valid', async () => {
wrapper.vm.channelTypeFilter = ChannelTypeFilter.COMMUNITY_LIBRARY;
wrapper.vm.channelStatusFilter = 'published';
await wrapper.vm.$nextTick();
// "published" is a valid status for unlisted channels too
wrapper.vm.channelTypeFilter = ChannelTypeFilter.UNLISTED;
await wrapper.vm.$nextTick();
expect(wrapper.vm.channelStatusFilter).toBe('live');
expect(wrapper.vm.channelStatusFilter).toBe('published');
});
it('should preserve a valid channel status filter already present in the URL on mount', () => {
// Simulates navigating back to this page with filters still in the URL
// (e.g. after opening a channel and hitting the browser back button).
const backNavWrapper = makeWrapper(store, {
channelType: ChannelTypeFilter.COMMUNITY_LIBRARY,
channelStatus: 'needsReview',
});
expect(backNavWrapper.vm.channelStatusFilter).toBe('needsReview');
});
});
describe('selection', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
@close="isAboutCommunityLibraryOpen = false"
/>
<div
v-if="loggedIn"
class="community-library-banner"
:style="{
backgroundColor: $themePalette.orange.v_100,
Expand Down Expand Up @@ -280,6 +281,8 @@
} = communityChannelsStrings;
const { copyChannelTokenAction$ } = commonStrings;

const loggedIn = computed(() => store.getters.loggedIn);

const availableLabels = ref(null);

const {
Expand Down Expand Up @@ -461,6 +464,7 @@
return {
windowIsSmall,
windowBreakpoint,
loggedIn,
tokenChannel,
loading,
loadError,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@
RouteNames.CATALOG_FAQ,
];

const COMMUNITY_LIBRARY_PAGES = [
RouteNames.COMMUNITY_LIBRARY_ITEMS,
RouteNames.COMMUNITY_LIBRARY_DETAILS,
];

const CHANNEL_SETS = 'channel_sets';
const ListTypeToAnalyticsLabel = {
[ChannelListTypes.EDITABLE]: 'EDITABLE',
Expand Down Expand Up @@ -118,8 +123,6 @@
...mapGetters('channelList', ['invitations']),

navigationTabs() {
if (!this.loggedIn) return [];

const tabs = [];

this.lists.forEach(listType => {
Expand Down Expand Up @@ -158,13 +161,15 @@
analyticsLabel: 'COMMUNITY_LIBRARY',
});

tabs.push({
id: CHANNEL_SETS,
label: this.$tr('channelSets'),
to: this.channelSetLink,
badgeValue: 0,
analyticsLabel: CHANNEL_SETS,
});
if (this.loggedIn) {
tabs.push({
id: CHANNEL_SETS,
label: this.$tr('channelSets'),
to: this.channelSetLink,
badgeValue: 0,
analyticsLabel: CHANNEL_SETS,
});
}

return tabs;
},
Expand All @@ -185,14 +190,20 @@
return this.$route.name === RouteNames.COMMUNITY_LIBRARY_ITEMS;
},
toolbarHeight() {
return this.loggedIn && !this.isFAQPage ? 112 : 64;
return this.libraryMode || this.isFAQPage ? 64 : 112;
},
contentOffset() {
return this.toolbarHeight + (this.offline ? 48 : 0);
},
lists() {
if (!this.loggedIn) {
return [];
}
return Object.values(ChannelListTypes).filter(l => l !== 'public');
},
anonymousPages() {
return this.libraryMode ? CATALOG_PAGES : [...CATALOG_PAGES, ...COMMUNITY_LIBRARY_PAGES];
},
invitationsByListCounts() {
const inviteMap = {};
Object.values(ChannelListTypes).forEach(type => {
Expand Down Expand Up @@ -220,10 +231,12 @@
},
watch: {
$route(route) {
if (route.name === RouteNames.CHANNELS_EDITABLE) {
this.loggedIn
? this.loadInvitationList()
: this.$router.replace({ name: RouteNames.CATALOG_ITEMS });
if (!this.loggedIn) {
if (!this.anonymousPages.includes(route.name)) {
this.$router.replace({ name: RouteNames.CATALOG_ITEMS });
}
} else if (route.name === RouteNames.CHANNELS_EDITABLE) {
this.loadInvitationList();
}
if (this.fullPageError) {
this.$store.dispatch('errors/clearError');
Expand All @@ -237,7 +250,7 @@
created() {
if (this.loggedIn) {
this.loadInvitationList();
} else if (!CATALOG_PAGES.includes(this.$route.name)) {
} else if (!this.anonymousPages.includes(this.$route.name)) {
this.$router.replace({ name: RouteNames.CATALOG_ITEMS });
}
},
Expand Down
74 changes: 70 additions & 4 deletions contentcuration/contentcuration/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@
from django.db.models.query_utils import DeferredAttribute
from django.db.models.sql import Query
from django.dispatch import receiver
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone
from django.utils import translation
from django.utils.translation import gettext as _
from django_cte import CTEManager
from django_cte import CTEQuerySet
Expand Down Expand Up @@ -97,7 +100,9 @@
from contentcuration.db.models.manager import CustomContentNodeTreeManager
from contentcuration.db.models.manager import CustomManager
from contentcuration.utils.cache import delete_public_channel_cache_keys
from contentcuration.utils.i18n import closest_supported_locale
from contentcuration.utils.parser import load_json_string
from contentcuration.utils.urls import canonical_url
from contentcuration.viewsets.sync.constants import ALL_CHANGES
from contentcuration.viewsets.sync.constants import ALL_TABLES
from contentcuration.viewsets.sync.constants import PUBLISHABLE_CHANGE_TABLES
Expand Down Expand Up @@ -475,11 +480,15 @@ def get_available_staged_space(self):
.aggregate(size=Sum("file_size"))["size"]
or 0
)
return float(max(self.disk_space - space_used, 0))
return float(max(self.get_effective_disk_space() - space_used, 0))

def get_available_space(self, active_files=None):
return float(
max(self.disk_space - self.get_space_used(active_files=active_files), 0)
max(
self.get_effective_disk_space()
- self.get_space_used(active_files=active_files),
0,
)
)

def get_user_active_trees(self):
Expand Down Expand Up @@ -1784,13 +1793,27 @@ def new_token(self):

@classmethod
def filter_view_queryset(cls, queryset, user):
live_in_community_library = Q(
Exists(
CommunityLibrarySubmission.objects.filter(
channel=OuterRef("channel"),
channel_version=OuterRef("version"),
status=community_library_submission.STATUS_LIVE,
)
)
)

if user.is_anonymous:
return queryset.none()
return queryset.filter(live_in_community_library)

if user.is_admin:
return queryset

return queryset.filter(Q(channel__viewers=user) | Q(channel__editors=user))
return queryset.filter(
live_in_community_library
| Q(channel__viewers=user)
| Q(channel__editors=user)
)

@classmethod
def filter_edit_queryset(cls, queryset, user):
Expand Down Expand Up @@ -3298,6 +3321,49 @@ def notify_update_to_channel_editors(self, exclude_user_id=None):

User.notify_users(editors, date=self.date_updated)

def send_resolution_email(self):
"""
Send an email to the submission author letting them know their
Community Library submission has been resolved (approved or
rejected).
"""
is_approved = self.status == community_library_submission.STATUS_APPROVED

channel_language = self.channel.language
locale_code = (
closest_supported_locale(channel_language.lang_code)
if channel_language
else None
) or settings.LANGUAGE_CODE
with translation.override(locale_code):
if is_approved:
subject_text = _("Your Community Library submission has been approved")
else:
subject_text = _("Your Community Library submission needs changes")

subject = render_to_string(
"registration/custom_email_subject.txt",
{"subject": subject_text},
)
subject = "".join(subject.splitlines())

message = render_to_string(
"community_library/submission_resolved_email.html",
{
"name": self.author.get_full_name(),
"channel": self.channel,
"channel_url": canonical_url(
reverse("channel", kwargs={"channel_id": self.channel.pk})
),
"approved": is_approved,
"feedback_notes": self.feedback_notes,
},
)

self.author.email_user(
subject, message, settings.DEFAULT_FROM_EMAIL, html_message=message
)

@classmethod
def filter_view_queryset(cls, queryset, user):
if user.is_anonymous:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!DOCTYPE html>
{% load i18n %}
{% get_current_language as LANGUAGE_CODE %}
{% get_current_language_bidi as LANGUAGE_BIDI %}
<html lang="{{ LANGUAGE_CODE }}" dir="{% if LANGUAGE_BIDI %}rtl{% else %}ltr{% endif %}">
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<p>{% blocktrans with name=name %}Hello {{ name }},{% endblocktrans %}</p>

<p><a href="{{ channel_url }}" target="_blank">{{ channel.name }}</a> ({{ channel_url }})</p>

{% if approved %}
<p>{% translate "Your submission has been approved and will be added to the Community Library soon." %}</p>
{% else %}
<p>{% translate "Your submission needs changes. Please review the notes below and resubmit after all feedback has been addressed." %}</p>
{% endif %}

{% if feedback_notes %}
<p>{% translate "Notes from the reviewer" %}: {{ feedback_notes }}</p>
{% endif %}

<p>
{% translate "Thanks for using Kolibri Studio!" %}
<br>
{% translate "The Learning Equality Team" %}
</p>
</body>
</html>
Loading
Loading