From b94a0da61dfb4aebcdfa6b8a5d493d914a4b1351 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Mon, 31 Aug 2026 09:50:20 +0300 Subject: [PATCH 1/2] Say which class a text came from, and which texts a class hides Two halves of the same gap, found when our pilot teacher asked whether he had to leave one class in order to see another's texts. He did not -- but nothing in the app told him that, because a student's classroom is a merged list with no provenance. - cohort_articles_for_user tags each text with the classes it came from. A text shared with two of the student's classes still appears once, naming both. The client shows the tag only to a student who has more than one class. - /cohort_text_overview reports the languages its texts are in again. Nothing stops a text in another language being shared with a class, and its students then never see it: they only ever get the texts in the language they are learning. Seven classes are in that state today, holding 32 texts between them, and 54 students have texts in their class they cannot open. The teacher is the one who can fix it, so the count goes to them rather than to the students. Co-Authored-By: Claude Opus 5 --- .../teacher_dashboard/article_management.py | 5 ++ zeeguu/core/model/user.py | 42 +++++++++--- .../test/test_cohort_article_attribution.py | 67 +++++++++++++++++++ 3 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 zeeguu/core/test/test_cohort_article_attribution.py diff --git a/zeeguu/api/endpoints/teacher_dashboard/article_management.py b/zeeguu/api/endpoints/teacher_dashboard/article_management.py index c89ef28a2..c7c6f83c7 100644 --- a/zeeguu/api/endpoints/teacher_dashboard/article_management.py +++ b/zeeguu/api/endpoints/teacher_dashboard/article_management.py @@ -212,6 +212,11 @@ def cohort_text_overview(cohort_id): "only_classroom_texts": bool(cohort.only_classroom_texts), }, "texts": CohortArticleMap.get_articles_info_for_cohort(cohort), + # Nothing stops a text in another language being shared with a + # class, and its students then never see it -- they only ever get + # the texts in the language they are learning. The teacher is the + # one who can fix that, so the counts go to them. + "texts_by_language": cohort.text_counts_by_language(), "student_count": len(cohort.get_students()), } ) diff --git a/zeeguu/core/model/user.py b/zeeguu/core/model/user.py index 32082e69b..ecfdc416c 100644 --- a/zeeguu/core/model/user.py +++ b/zeeguu/core/model/user.py @@ -551,25 +551,45 @@ def add_user_to_cohort(self, cohort, session): session.commit() def cohort_articles_for_user(self): + """The class texts this student can see, each tagged with its class. + + A student in more than one class gets one merged list, so each text + says which class it came from -- otherwise the list is a pile with no + provenance. The tag is only useful to a student with several classes; + the client drops it for everyone else. + """ from zeeguu.core.model import Cohort, CohortArticleMap, UserArticle all_articles = [] + classes_by_article = {} try: + # Filter articles by the user's learned language: a student only + # ever reads in the language they are currently learning, so a + # class taught in another one has nothing to show them. + user_language_id = ( + self.learned_language_id if self.learned_language else None + ) + for c in self.cohorts: cohort = Cohort.find(c.cohort_id) - # Get all articles from this cohort - cohort_articles = CohortArticleMap.get_articles_for_cohort(cohort) - - # Filter articles by the user's learned language - user_language_id = ( - self.learned_language_id if self.learned_language else None - ) - for article in cohort_articles: - if article.language_id == user_language_id: + for article in CohortArticleMap.get_articles_for_cohort(cohort): + if article.language_id != user_language_id: + continue + # A text shared with two of this student's classes appears + # once, tagged with both. + if article.id not in classes_by_article: all_articles.append(article) + classes_by_article[article.id] = [] + classes_by_article[article.id].append( + {"id": cohort.id, "name": cohort.name} + ) - # Use the standard helper for proper cache handling - return UserArticle.article_infos(self, all_articles, select_appropriate=False) + infos = UserArticle.article_infos( + self, all_articles, select_appropriate=False + ) + for info in infos: + info["from_classes"] = classes_by_article.get(info["id"], []) + return infos except NoResultFound as e: return [] diff --git a/zeeguu/core/test/test_cohort_article_attribution.py b/zeeguu/core/test/test_cohort_article_attribution.py new file mode 100644 index 000000000..5dab9116d --- /dev/null +++ b/zeeguu/core/test/test_cohort_article_attribution.py @@ -0,0 +1,67 @@ +from unittest import TestCase + +import zeeguu.core +from zeeguu.core.model.cohort_article_map import CohortArticleMap +from zeeguu.core.test.model_test_mixin import ModelTestMixIn +from zeeguu.core.test.rules.article_rule import ArticleRule +from zeeguu.core.test.rules.cohort_rule import CohortRule +from zeeguu.core.test.rules.language_rule import LanguageRule +from zeeguu.core.test.rules.user_rule import UserRule + +db_session = zeeguu.core.model.db.session + + +class CohortArticleAttributionTest(ModelTestMixIn, TestCase): + """cohort_articles_for_user says which class each text came from. + + A student in several classes gets one merged list; without the tag it is a + pile of texts with no way to tell whose lesson is whose. + """ + + def setUp(self): + super().setUp() + self.danish = LanguageRule().da + self.german = LanguageRule().de + self.student = UserRule().user + self.student.set_learned_language(self.danish.code, session=db_session) + + def _class_with(self, *articles): + cohort = CohortRule().cohort + for article in articles: + db_session.add(CohortArticleMap(cohort, article, None)) + self.student.add_user_to_cohort(cohort, db_session) + db_session.commit() + return cohort + + def _article_in(self, language): + article = ArticleRule().article + article.language = language + db_session.add(article) + db_session.commit() + return article + + def test_each_text_names_its_class(self): + cohort = self._class_with(self._article_in(self.danish)) + + infos = self.student.cohort_articles_for_user() + + self.assertEqual(1, len(infos)) + self.assertEqual([{"id": cohort.id, "name": cohort.name}], infos[0]["from_classes"]) + + def test_a_text_in_two_classes_appears_once_naming_both(self): + shared = self._article_in(self.danish) + first = self._class_with(shared) + second = self._class_with(shared) + + infos = self.student.cohort_articles_for_user() + + self.assertEqual(1, len(infos)) + self.assertEqual( + {first.id, second.id}, + {each["id"] for each in infos[0]["from_classes"]}, + ) + + def test_off_language_texts_are_still_left_out(self): + self._class_with(self._article_in(self.german)) + + self.assertEqual([], self.student.cohort_articles_for_user()) From b8394c988f87093fbc2ea98141f43611885b9f91 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Mon, 31 Aug 2026 13:46:52 +0300 Subject: [PATCH 2/2] Emit the cohort id as a number, like everywhere else get_cohort_info stringified it, alone among the endpoints that carry a cohort id -- Article's `shared_with` and the classroom's `from_classes` both send an int. So the same class arrived with two different identifiers depending on which endpoint you asked, and code that matched one against the other silently matched nothing. That was not hypothetical: the share dialog builds its {id, name} objects from /cohorts_info while a reloaded texts list gets them from /teacher_texts, so a class shared during a session and the same class after a refresh produced two separate filter chips. Co-Authored-By: Claude Opus 5 --- zeeguu/api/endpoints/teacher_dashboard/helpers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/zeeguu/api/endpoints/teacher_dashboard/helpers.py b/zeeguu/api/endpoints/teacher_dashboard/helpers.py index 1944c87c8..27d8ae7dd 100644 --- a/zeeguu/api/endpoints/teacher_dashboard/helpers.py +++ b/zeeguu/api/endpoints/teacher_dashboard/helpers.py @@ -66,7 +66,11 @@ def get_cohort_info(id): except sqlalchemy.orm.exc.NoResultFound: language_name = "None" dictionary = { - "id": str(id), + # An int, like every other cohort id we emit (Article's + # `shared_with`, the classroom's `from_classes`). It used to be + # stringified here alone, so the same class arrived with two + # different identifiers depending on which endpoint you asked. + "id": c.id, "name": name, "inv_code": inv_code, "max_students": max_students,