diff --git a/.gitignore b/.gitignore index 67ac19ec..9c640d03 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ server/test-reports/ *.prod.sql latest.dump* dump*.sql +*.dump diff --git a/client/src/components/modules/Module.vue b/client/src/components/modules/Module.vue index 29c67e1c..ddf87536 100644 --- a/client/src/components/modules/Module.vue +++ b/client/src/components/modules/Module.vue @@ -5,19 +5,20 @@ v-if="module.id" >
+

+ {{ module.metaTitle }} +

-

- {{ module.metaTitle }} -

- -
+
-

diff --git a/client/src/components/modules/SnapshotHeader.vue b/client/src/components/modules/SnapshotHeader.vue index ed5a0ca9..43e221cc 100644 --- a/client/src/components/modules/SnapshotHeader.vue +++ b/client/src/components/modules/SnapshotHeader.vue @@ -9,8 +9,6 @@

In diesem Snapshot sind {{ changesCount }} Anpassungen gespeichert:

    -
  • {{ hiddenObjectives }} Lernziele wurden ausgeblendet
  • -
  • {{ newObjectives }} Lernziele wurde erfasst
  • {{ hiddenContentBlocks }} Inhaltsblöcke wurden ausgeblendet
  • {{ newContentBlocks }} Inhaltsblock wurde erfasst
diff --git a/server/books/management/commands/analyze_snapshots.py b/server/books/management/commands/analyze_snapshots.py new file mode 100644 index 00000000..e6050a44 --- /dev/null +++ b/server/books/management/commands/analyze_snapshots.py @@ -0,0 +1,36 @@ +from django.core.management import BaseCommand +from django.db.models import Count +from django.db.models.functions import ExtractYear +from books.models import Snapshot, ObjectiveGroupSnapshot +from objectives.models import ObjectiveSnapshot + + +# Query to group by creator's email, count the snapshots, and order by the count + +class Command(BaseCommand): + def handle(self, *args, **options): + snapshots_grouped = (Snapshot.objects + .annotate(year=ExtractYear('created')) + .values('year', 'creator__email') + .annotate(count=Count('id')) + .order_by('year', '-count')) # Order by year and then by count (descending) + + # To access the results + for snapshot in snapshots_grouped: + modified_email = snapshot['creator__email'].split('@')[0] + '@skillbox.ch' + print(f"Year: {snapshot['year']}, Creator Email: {modified_email}, Count: {snapshot['count']}") + + hidden_objectives_counter = 0 + custom_objectives_counter = 0 + + for snapshot in Snapshot.objects.all(): + if snapshot.hidden_objectives.count() > 0: + hidden_objectives_counter += 1 + + if snapshot.custom_objectives.count() > 0: + custom_objectives_counter += 1 + + print(f"Hidden objectives: {hidden_objectives_counter}") + print(f"Custom objectives: {custom_objectives_counter}") + print(f"ObjectiveGroupSnapshot objectives: {ObjectiveGroupSnapshot.objects.count()}") + print(f"ObjectiveSnapshot objectives: {ObjectiveSnapshot.objects.count()}") diff --git a/server/books/management/commands/migrate_objective_snapshots.py b/server/books/management/commands/migrate_objective_snapshots.py new file mode 100644 index 00000000..ad3e3f97 --- /dev/null +++ b/server/books/management/commands/migrate_objective_snapshots.py @@ -0,0 +1,225 @@ +import json +from logging import getLogger + +from django.core.management import BaseCommand + +from books.management.commands.migrate_objectives_to_content import create_text_in_content_block, \ + create_content_block_snapshot_from_objective, \ + create_content_block_contents +from books.models import Chapter, ObjectiveGroupSnapshot, ContentBlockSnapshot, Snapshot, ChapterSnapshot +from books.models import ContentBlock +from books.models import Module +from objectives.models import Objective, ObjectiveSnapshot, ObjectiveGroup + +logger = getLogger(__name__) + + +class Command(BaseCommand): + def handle(self, *args, **options): + """ + This command must be run after the migration of objectives to content blocks. + Migrate objective snapshots to content blocks + - Verlagsinhalte - deafult content blocks are referced in the snapshot by foreign key + + Man muss unterscheiden zwischen, snapshots die nur Verlagslernziele sichtbar und unsichtbar machen. + + Und solchen die auch benutzerdefinierte Lernziele sichtbar und unsichtbar machen. + + Es gibt keine custom objective groups! + + Es gibt keine hidden custom objective_groups + + Case1: + - 100% Verlagslernziele, 100% Verlagsgruppe, nicht neue content blocks erstellen, nichts hidden... migration muss nichts machen. + + Case2: + - 100% Verlagslernziele, 100% Verlagsgruppe, hidden gruppe, nur verlagsinhalt gruppe verstecken + + Case3: + - Bestende gruppe mit benutzerdefinierten lernzielen, - custom content_block snapshot erstellen. + - Einzelne verslagslernziele sind versteckt. - custom content_block snapshot erstellen. + + --- user created setzen bei custom content snapshot. + + """ + prefix = "SNAP " + ContentBlock.objects.filter(title__startswith=prefix).delete() + Chapter.objects.filter(title__startswith=prefix).delete() + ContentBlockSnapshot.objects.filter(title__startswith=prefix).delete() + ChapterSnapshot.objects.filter(chapter__title__startswith=prefix).delete() + + analyze() + + migrate_snapshots() + + +def migrate_snapshots(): + count = 0 + + case1_count = 0 + case2_count = 0 + case3_count = 0 + + createed_content_blocks = 0 + visible_objectives_by_ids = {} + snapshot_counter = 0 + + for module in Module.objects.all(): + for snapshot in Snapshot.objects.filter(module=module): + group_counter = snapshot.objective_groups.through.objects.filter(objective_group__module=module, + snapshot=snapshot).count() + print( + f"{snapshot_counter} Snapshot id: {snapshot.id} Module: {module.title} {group_counter} groups {snapshot.creator} {snapshot.title}") + snapshot_counter += 1 + + for objective_group_snapshot in snapshot.objective_groups.through.objects.filter( + objective_group__module=module, snapshot=snapshot): + header = f"{count} {module.title:50} {objective_group_snapshot.objective_group.get_title_display():25} {str(snapshot.creator):40} {objective_group_snapshot.hidden} " + count += 1 + objective_group = objective_group_snapshot.objective_group + + hidden_default_objectives = snapshot.hidden_objectives.filter( + group=objective_group) + + visible_custom_objectives = snapshot.custom_objectives.filter(snapshot=snapshot, hidden=False, + group=objective_group_snapshot.objective_group) + group_is_hidden = objective_group_snapshot.hidden + + info = f"{hidden_default_objectives.count()} {visible_custom_objectives.count()}" + + if (not hidden_default_objectives and not visible_custom_objectives and not group_is_hidden): + # print(f"{info} Case 1 - skip") + case1_count += 1 + break + + if not hidden_default_objectives and not visible_custom_objectives and group_is_hidden: + print(header + f"Case 2 - {info} hide default content group") + case2_count += 1 + content_block = get_content_block_by_objective_group(objective_group_snapshot, + module, False, None) + snapshot.hidden_content_blocks.add(content_block.id) + snapshot.save() + + if hidden_default_objectives or visible_custom_objectives: + print(header + f"Case 3 - {info} create custom content blocks") + case3_count += 1 + + # Verlags Lernziele + visible_default_objectives = get_visible_default_objectives(objective_group, module, snapshot) + # Benutzerdefinierte Lernziele + visible_custom_objectives = list(snapshot.custom_objectives.filter(hidden=False)) + + visible_objectives = visible_default_objectives + visible_custom_objectives + + # filter for unique texts in objectives + # TODO: I don't know why there are duplicated objectives + objectives_by_texts = {} + for objective in visible_objectives: + if objective.text not in objectives_by_texts: + objectives_by_texts[objective.text] = objective + visible_objectives = list(objectives_by_texts.values()) + + if visible_objectives: + # make combinations of objectives unique by text, this prevents generation of many duplicated content blocks + visible_objectives_hash = hash( + [objective.text for objective in visible_objectives].__str__()) + + visible_objectives_by_ids[visible_objectives_hash] = visible_objectives + + for objectives in visible_objectives_by_ids.values(): + print("") + for objective in objectives: + print(f" Objective: {objective.group} {objective} owner:{objective.owner}") + print("-") + + print(f" visible_objectives_by_ids: {len(visible_objectives_by_ids.items())}") + + # create custom content blocks with the objectives + created_content_blocks = 0 + chapter = module.get_first_child() + if "Lernziele" not in chapter.title: + raise Exception(f"Chapter does not contain 'Lernziele' first title is {chapter.title}") + + # Owner des custom blocks festlegen + custom_content_block_snapshot = create_content_block_snapshot_from_objective( + objective_group, chapter, snapshot, + owner=snapshot.creator) + + # Hide default content block for this objective group, since custom content block is created + default_content_block = get_default_content_block(objective_group_snapshot, module) + if default_content_block: + print(f"Default content block: {default_content_block.title}") + snapshot.hidden_content_blocks.add(default_content_block.id) + + if list(visible_objectives_by_ids.values()): + objectives = list(visible_objectives_by_ids.values())[0] + create_text_in_content_block(objectives, custom_content_block_snapshot, get_or_create=True) + created_content_blocks += 1 + snapshot.save() + print() + print(f"Skipped {case1_count} Case 1") + print(f"Hidden default content groups {case2_count} Case 2") + print(f"Created new content {case3_count} Case 3") + + +def get_content_block_by_objective_group(objective_group_snapshot, module, user_created: bool, user): + # TODO: review qustion, is it necessary to filter for module + content_block = ContentBlock.objects.filter( + title__contains=objective_group_snapshot.objective_group.get_title_display(), + user_created=user_created, owner=user).descendant_of(module) + if content_block.count() > 1: + return content_block.first() + + return content_block.first() + + +def get_default_content_block(objective_group_snapshot, module): + default_objectives = Objective.objects.filter(group=objective_group_snapshot.objective_group, + group__module=module, + owner__isnull=True, + objectivesnapshot__isnull=True) + + default_content_block_contents = create_content_block_contents(default_objectives) + + contents = json.loads(default_content_block_contents) + contents[0].get("value").get("text") + chapter = Chapter.objects.filter(title__contains="Lernziele").descendant_of(module) + text_contents = contents[0].get("value").get("text") + group_title = objective_group_snapshot.objective_group.get_title_display() + content_blocks = ContentBlock.objects.filter(user_created=False).descendant_of(chapter.first()) + + for content_block in content_blocks: + print(content_block.title, group_title) + if group_title in content_block.title: + return content_block + + if not content_block.exists(): + raise Exception("Content block does not exist ") + + +def get_visible_default_objectives(objective_group, module, snapshot): + default_objectives = Objective.objects.filter(group=objective_group, + group__module=module, + owner__isnull=True, + objectivesnapshot__isnull=True) + hidden_default_objectives = snapshot.hidden_objectives.filter(group=objective_group) + visible_default_objectives = [objective for objective in default_objectives if + objective.id not in hidden_default_objectives.values_list("id", + flat=True)] + return visible_default_objectives + + +def analyze(): + print(f""" + OjectiveGroups: {ObjectiveGroup.objects.count()} + Objectives: {Objective.objects.count()} + + ObjectiveGroupSnapshots: {ObjectiveGroupSnapshot.objects.count()} + ObjectivesSnapshots: {ObjectiveSnapshot.objects.count()} + + + ObjectiveGroups: {ObjectiveGroup.objects.filter(objectivegroupsnapshot__isnull=True).count()} + Objectives: {Objective.objects.filter(objectivesnapshot__isnull=True).count()} + + Snapshot: {Snapshot.objects.count()} + """) diff --git a/server/books/management/commands/migrate_objectives_to_content.py b/server/books/management/commands/migrate_objectives_to_content.py new file mode 100644 index 00000000..9f5df387 --- /dev/null +++ b/server/books/management/commands/migrate_objectives_to_content.py @@ -0,0 +1,280 @@ +import json +from logging import getLogger + +from django.core.management import BaseCommand +from django.core.exceptions import ValidationError + +from books.models import Chapter, ObjectiveGroupSnapshot, Snapshot, ContentBlockSnapshot, ChapterSnapshot +from books.models import ContentBlock +from books.models import Module +from objectives.models import ObjectiveSnapshot, Objective, ObjectiveGroup + +logger = getLogger(__name__) + +class Command(BaseCommand): + def handle(self, *args, **options): + ContentBlock.objects.filter(title__startswith="TESTOBJECTIVE").delete() + ContentBlock.objects.filter(title__startswith="CUSTOM").delete() + Chapter.objects.filter(title__startswith="TESTOBJECTIVE").delete() + ContentBlock.objects.filter(title__startswith="XXX").delete() + Chapter.objects.filter(title__startswith="XXX").delete() + + migrate_objectives_to_content() + +def migrate_objectives_to_content(): + created_content_blocks = 0 + + failed_modules = [] + + # This dict stores all content blocks that have been created for a set of objectives + # In this way we can reuse content blocks for the same set of objectives + created_default_content_blocks = {} + + for module in Module.objects.all(): + try: + chapter = create_chapter_from_objective_group(module) + + for objective_group in module.objective_groups.all().order_by('title'): + default_objectives = list(objective_group.objectives.filter(owner__isnull=True, ) + .exclude(objectivesnapshot__isnull=False).order_by('order')) + + default_objectives_ids = tuple(objective.id for objective in default_objectives) + + # Create "Verlagsinhalte" content block if it does not exist yet + if default_objectives_ids not in created_default_content_blocks: + default_content_block = create_default_content(objective_group, chapter) + created_default_content_blocks[default_objectives_ids] = default_content_block + else: + default_content_block = created_default_content_blocks[default_objectives_ids] + + custom_objectives_by_owner = get_objectives_by_owner(objective_group) + + if default_objectives or custom_objectives_by_owner: + contentblocks_by_merged_objectives_ids = {} + + # cor custom objectives iterate over owners, + # - one ownsers custom objectives must not be changed by another owner + # - visibility is set per class + for owner, owner_objectives in custom_objectives_by_owner.items(): + print(f"Owner: {owner}") + print(f" Objectives: ") + + visible_default_objectives_by_class = filter_visible_objectives_by_class(default_objectives, owner) + + for school_class, default_objectives_for_class in visible_default_objectives_by_class.items(): + custom_content_block = None + + print(f" School class: {school_class}") + # merge "Verlagsinhalte" and "benutzerdefinierte Inhalte" + visible_owner_objectives = [objective for objective in owner_objectives if not objective.is_hidden_for_class(school_class)] + + merged_objectives = default_objectives_for_class + visible_owner_objectives + merged_objectives_ids = tuple(objective.id for objective in merged_objectives) + is_default_content = merged_objectives_ids == default_objectives_ids + + if is_default_content: + print(f" Objective: Reuse default content block") + # custom_content_block = default_content_block + + # Create content block if that set of objectives has not been created yet + if merged_objectives_ids not in contentblocks_by_merged_objectives_ids and not is_default_content: + for objective in merged_objectives: + print(f" Objective: {objective} {objective.owner}") + + if merged_objectives_ids: + custom_content_block = create_content_block_from_objective(objective_group, + chapter, + owner=owner, + ) + contentblocks_by_merged_objectives_ids[ + merged_objectives_ids] = custom_content_block + create_text_in_content_block(merged_objectives, custom_content_block) + created_content_blocks += 1 + else: + if not is_default_content: + print(f" Objective: Reuse content block") + custom_content_block = contentblocks_by_merged_objectives_ids[ + merged_objectives_ids] + else: + print(f" Objective: Reuse default content block") + + # set visibility + # hide default objectives if custom objectives exist + if custom_content_block: + default_content_block.hidden_for.add(school_class) + default_content_block.save_revision().publish() + default_content_block.save() + + custom_content_block.visible_for.add(school_class) + custom_content_block.save_revision().publish() + custom_content_block.save() + + if objective_group.hidden_for.filter(id=school_class.id).exists(): + default_content_block.hidden_for.add(school_class) + default_content_block.save_revision().publish() + default_content_block.save() + + + except ValidationError as e: + print(f"Error with module {module}") + logger.error(e) + failed_modules.append(module) + + print(f"Created {created_content_blocks} content blocks") + print(f"Failed modules: {len(failed_modules)}") + + for module in failed_modules: + print(f"Faile module: {module}") + + +def create_default_content(objective_group, chapter): + """Create Verlagsinhalt Lernziele""" + print(f" Objective group: {objective_group}") + print(" Default objectives:") + + default_objectives = list( + objective_group.objectives.filter(owner__isnull=True, objectivesnapshot__isnull=True).order_by('id')) + + default_content_block = create_content_block_from_objective(objective_group, chapter) + create_text_in_content_block(default_objectives, default_content_block) + + for objective in default_objectives: + print(f" Objective: {objective} {objective.owner}") + + return default_content_block + + +def filter_visible_objectives_by_class(objectives, user): + school_classes = user.school_classes.all() + visible_objectives = {} + for school_class in school_classes: + if school_class not in visible_objectives: + visible_objectives[school_class] = [] + objectives_vis = [objective for objective in objectives if not objective.is_hidden_for_class(school_class)] + visible_objectives[school_class].extend(objectives_vis) + return visible_objectives + + +def get_objectives_by_owner(objective_group, exclude_snapshots=True): + custom_objectives = objective_group.objectives.filter(owner__isnull=False, objectivesnapshot__isnull=True).order_by('order') + custom_objectives_by_owner = {} + + for objective in custom_objectives: + owner = objective.owner + + if owner not in custom_objectives_by_owner: + custom_objectives_by_owner[owner] = [] + + custom_objectives_by_owner[owner].append(objective) + + # add owners with hidden default objectives to custom objectives, needed for further processing + hidden_default_objectives = list( + objective_group.objectives.filter(owner__isnull=True, objectivesnapshot__isnull=True, hidden_for__isnull=False)) + + for hidden_default_objective in hidden_default_objectives: + for school_class in hidden_default_objective.hidden_for.all(): + for teacher in school_class.get_teachers(): + if teacher not in custom_objectives_by_owner: + custom_objectives_by_owner[teacher] = [] + + return custom_objectives_by_owner + + +def create_chapter_from_objective_group(module, prefix="XXX "): + chapter = Chapter(title=f"Lernziele") + + first_sibling = module.get_first_child() + if first_sibling is not None: + first_sibling.add_sibling(instance=chapter, pos='left') + + chapter.save_revision().publish() + chapter.save() + return chapter + + +def create_chapter_snapshot_from_objective_group(module, snapshot, prefix="XXX "): + chapter = Chapter.objects.filter(parent=module, title=f"Lernziele").first() + chapter_snapshot = ChapterSnapshot(title=f"Lernziele", snapshot=snapshot) + + first_sibling = module.get_first_child() + if first_sibling is not None: + first_sibling.add_sibling(instance=chapter_snapshot, pos='left') + return chapter + + +def create_content_block_from_objective(objective_group, chapter, owner=None): + content_block = ContentBlock( + title=f"{objective_group.get_title_display()}", + type="normal", + owner=owner, + user_created=owner is not None, + original_creator=owner + ) + chapter.add_child(instance=content_block) + return content_block + + +def create_content_block_snapshot_from_objective(objective_group, chapter, snapshot, owner=None): + content_block_snapshot = ContentBlockSnapshot( + title=f"{objective_group.get_title_display()}", + type="normal", + owner=owner, + user_created=owner is not None, + snapshot=snapshot + ) + + chapter.add_child(instance=content_block_snapshot) + return content_block_snapshot + + +def create_text_in_content_block(objectives, content_block, get_or_create=False): + objectives = list(objectives) + content_block.contents = create_content_block_contents(objectives) + + if get_or_create: + content_block_qs = ContentBlock.objects.filter(title=content_block.title, + owner=content_block.owner, + user_created=content_block.user_created, + contents=content_block.contents) + if content_block_qs.exists(): + content_block = content_block_qs.first() + + content_block.save_revision().publish() + return content_block + +def create_content_block_contents(objectives): + objectives = list(objectives) + objective_li = [f"
  • {objective.text}
  • " for objective in objectives if objective.text] + + texts = [{'type': 'text_block', + 'value': { + 'text': f"
      {''.join(str(i) for i in objective_li)}
    " + }}] + + contents = json.dumps(texts) + return contents + +def analyze(): + print(f""" + OjectiveGroups: {ObjectiveGroup.objects.count()} + Objectives: {Objective.objects.count()} + + ObjectiveGroupSnapshots: {ObjectiveGroupSnapshot.objects.count()} + ObjectivesSnapshots: {ObjectiveSnapshot.objects.filter(hidden=True).count()} + + + ObjectiveGroups: {ObjectiveGroup.objects.filter(objectivegroupsnapshot__isnull=True).count()} + Objectives: {Objective.objects.filter(objectivesnapshot__isnull=True).count()} + + Snapshot: {Snapshot.objects.count()} + """) + + +def clean_snapshots(): + """ utility function to clean up snapshots """ + emails = ["mia.teacher", "simone.gerber", "pascal.sigg", "dario.aebersold", "steph-teacher"] + for snapshot in Snapshot.objects.all(): + for email in emails: + if email in snapshot.creator.email: + print(f"Deleting snapshot of user {email}") + snapshot.delete() diff --git a/server/books/models/snapshot.py b/server/books/models/snapshot.py index cef603bb..3d2adfa3 100644 --- a/server/books/models/snapshot.py +++ b/server/books/models/snapshot.py @@ -54,9 +54,11 @@ class SnapshotManager(models.Manager): description_hidden=chapter.description_hidden_for.filter(id=school_class.id).exists() ) base_qs = ContentBlock.get_by_parent(chapter).filter(contentblocksnapshot__isnull=True) + # Verlagsinhalte for content_block in base_qs.filter(user_created=False): if content_block.hidden_for.filter(id=school_class.id).exists(): snapshot.hidden_content_blocks.add(content_block) + # Benutzerdefinierte Inhalte for content_block in base_qs.filter(Q(user_created=True) & Q(owner=user)): new_content_block = ContentBlockSnapshot( hidden=content_block.is_hidden_for_class(school_class), @@ -78,9 +80,11 @@ class SnapshotManager(models.Manager): hidden=objective_group.hidden_for.filter(id=school_class.id).exists(), ) base_qs = objective_group.objectives.filter(objectivesnapshot__isnull=True) + # Verlagslernziele for objective in base_qs.filter(owner__isnull=True): if objective.hidden_for.filter(id=school_class.id).exists(): snapshot.hidden_objectives.add(objective) + # Benutzerdefinierte Lernziele for objective in base_qs.filter(owner=user): ObjectiveSnapshot.objects.create( hidden=objective.is_hidden_for_class(school_class=school_class), @@ -152,10 +156,3 @@ class Snapshot(models.Model): chapter.title_hidden_for.add(selected_class) if chapter_snapshot.description_hidden: chapter.description_hidden_for.add(selected_class) - for objective_group_snapshot in self.objective_groups.through.objects.all(): - if objective_group_snapshot.hidden: - objective_group_snapshot.objective_group.hidden_for.add(selected_class) - for objective in self.hidden_objectives.all(): - objective.hidden_for.add(selected_class) - for custom_objective in self.custom_objectives.all(): - custom_objective.to_regular_objective(owner=user, school_class=selected_class) diff --git a/server/books/tests/queries.py b/server/books/tests/queries.py index ccfc63a3..e500bdd4 100644 --- a/server/books/tests/queries.py +++ b/server/books/tests/queries.py @@ -22,6 +22,7 @@ query ModulesQuery($slug: String, $id: ID) { contentBlocks { id title + userCreated originalCreator { id fullName @@ -32,7 +33,8 @@ query ModulesQuery($slug: String, $id: ID) { } hiddenFor { name - } + } + contents } } } diff --git a/server/books/tests/test_objectives_migration.py b/server/books/tests/test_objectives_migration.py new file mode 100644 index 00000000..785d15bc --- /dev/null +++ b/server/books/tests/test_objectives_migration.py @@ -0,0 +1,111 @@ +from django.test import RequestFactory +from graphene.test import Client +from graphql_relay import to_global_id, from_global_id + +from api.schema import schema +from api.utils import get_object +from books.factories import ModuleFactory, ChapterFactory, ContentBlockFactory +from books.management.commands.migrate_objective_snapshots import migrate_snapshots +from books.management.commands.migrate_objectives_to_content import migrate_objectives_to_content +from books.models import Snapshot, ChapterSnapshot +from books.tests.queries import MODULE_QUERY, SNAPSHOT_MODULE_QUERY, CREATE_SNAPSHOT_MUTATION, APPLY_SNAPSHOT_MUTATION, \ + MODULE_SNAPSHOTS_QUERY, SHARE_SNAPSHOT_MUTATION, UPDATE_SNAPSHOT_MUTATION, DELETE_SNAPSHOT_MUTATION +from core.tests.base_test import SkillboxTestCase +from objectives.factories import ObjectiveGroupFactory, ObjectiveFactory +from users.factories import SchoolClassFactory +from users.models import User, SchoolClass + + +class TestObjectivesMigration(SkillboxTestCase): + @property + def graphene_client(self): + return self.get_client() + + def setUp(self): + self.createDefault() + self.client = self.get_client() + # teacher will create snapshot + self.slug = 'some-module' + + self.module = ModuleFactory(slug=self.slug) + self.skillbox_class = SchoolClass.objects.get(name='skillbox') + self.teacher2 = User.objects.get(username='teacher2') + self.second_class_name = 'second_class' + self.second_class = SchoolClass.objects.get(name=self.second_class_name) + self.admin = User.objects.get(username='admin') + + # we make a snapshot S of the module M + # snapshot S looks like module M for school class X + # module M has a chapter + self.chapter = ChapterFactory(parent=self.module, slug='some-chapter', owner=self.admin) + ChapterFactory(parent=self.module, slug='some-other-chapter', owner=self.admin) + + objective_group = ObjectiveGroupFactory(module=self.module, title='Gesellschaft') + second_objective_group = ObjectiveGroupFactory(module=self.module, title='Sprache & Kommunikation') + + self.visible_objective = ObjectiveFactory(text='visible-objective', group=objective_group) + self.hidden_objective = ObjectiveFactory(text='hidden-objective', group=objective_group) + self.custom_objective = ObjectiveFactory(text='custom-objective', group=objective_group, owner=self.teacher) + self.custom_hidden_objective = ObjectiveFactory(text='custom-hidden-objective', group=objective_group, + owner=self.teacher) + + self.visible_objective = ObjectiveFactory(text='objective1', group=second_objective_group) + + self.hidden_objective.hidden_for.add(self.skillbox_class) + self.hidden_objective.save() + + self.custom_objective.visible_for.add(self.skillbox_class) + self.custom_objective.save() + + second_objective_group.hidden_for.add(self.skillbox_class) + second_objective_group.save() + + self.custom_hidden_objective.visible_for.remove(self.skillbox_class) + + migrate_objectives_to_content() + + def test_objectives_migration_hidden_default_content(self): + result = self.client.execute(MODULE_QUERY, variables={ + 'slug': self.module.slug + }) + module = result.data['module'] + chapter1 = module['chapters'][0] + default_content, _, _ = chapter1['contentBlocks'] + + # default content block (Verlagsinhalte) exists but is hidden (since one objective is hidden for this class) + self.assertEqual(default_content['title'], 'Gesellschaft') + self.assertEqual(default_content['originalCreator'], None) + self.assertEqual(default_content['contents'][0]['value']['text'], + '
    • visible-objective
    • hidden-objective
    ') + self.assertEqual(default_content['hiddenFor'], [{'name': 'skillbox'}]) + + def test_objectives_migration_hidden_default_content_creates_custom_content(self): + result = self.client.execute(MODULE_QUERY, variables={ + 'slug': self.module.slug + }) + module = result.data['module'] + chapter1 = module['chapters'][0] + _, custom, _ = chapter1['contentBlocks'] + + # default content block (Verlagsinhalte) exists but is hidden (since one objective is hidden for this class) + self.assertEqual(custom['title'], 'Gesellschaft') + self.assertTrue(custom['originalCreator'] is not None) + self.assertEqual(custom['hiddenFor'], []) + self.assertEqual(custom['visibleFor'], [{'name': 'skillbox'}]) + self.assertEqual(custom['contents'][0]['value']['text'], + '
    • visible-objective
    • custom-objective
    ') + + def test_objectives_migration_hidden_default_content_creates_hidden_content_block(self): + result = self.client.execute(MODULE_QUERY, variables={ + 'slug': self.module.slug + }) + module = result.data['module'] + chapter1 = module['chapters'][0] + _, _, hidden_custom = chapter1['contentBlocks'] + + # default content block (Verlagsinhalte) exists but is hidden (since one objective is hidden for this class) + self.assertEqual(hidden_custom['title'], 'Sprache & Kommunikation') + self.assertTrue(hidden_custom['originalCreator'] is None) + self.assertEqual(hidden_custom['hiddenFor'], []) + self.assertEqual(hidden_custom['visibleFor'], []) + self.assertEqual(hidden_custom['contents'][0]['value']['text'], '
    • objective1
    ') diff --git a/server/books/tests/test_snapshots.py b/server/books/tests/test_snapshots.py index aeda62c7..896fbaac 100644 --- a/server/books/tests/test_snapshots.py +++ b/server/books/tests/test_snapshots.py @@ -110,25 +110,6 @@ class CreateSnapshotTestCase(SkillboxTestCase): self.assertTrue( school_class_name in [school_class['name'] for school_class in custom_content_block.get('visibleFor')]) - - objectives = module['objectiveGroups'][0]['objectives'] - - self.assertEqual(len(objectives), 4) - - hidden_objective = [objective for objective in objectives if - objective['text'] == self.hidden_objective.text][0] - custom_objective = [objective for objective in objectives if - objective['text'] == self.custom_objective.text][0] - - # check if hidden objective is hidden for this school class - self.assertTrue( - school_class_name in [school_class['name'] for school_class in - hidden_objective.get('hiddenFor')]) - # check if the custom objective is visible for this school class - self.assertTrue( - school_class_name in [school_class['name'] for school_class in - custom_objective.get('visibleFor')]) - return module def _compare_content_blocks(self, content_blocks): diff --git a/server/books/tests/test_snapshots_migration.py b/server/books/tests/test_snapshots_migration.py new file mode 100644 index 00000000..492920a0 --- /dev/null +++ b/server/books/tests/test_snapshots_migration.py @@ -0,0 +1,195 @@ +from django.test import RequestFactory +from graphene.test import Client +from graphql_relay import to_global_id, from_global_id + +from api.schema import schema +from api.utils import get_object +from books.factories import ModuleFactory, ChapterFactory, ContentBlockFactory +from books.management.commands.migrate_objective_snapshots import migrate_snapshots +from books.management.commands.migrate_objectives_to_content import migrate_objectives_to_content +from books.models import Snapshot, ChapterSnapshot, ContentBlock +from books.tests.queries import MODULE_QUERY, SNAPSHOT_MODULE_QUERY, CREATE_SNAPSHOT_MUTATION, APPLY_SNAPSHOT_MUTATION, \ + MODULE_SNAPSHOTS_QUERY, SHARE_SNAPSHOT_MUTATION, UPDATE_SNAPSHOT_MUTATION, DELETE_SNAPSHOT_MUTATION +from core.tests.base_test import SkillboxTestCase +from objectives.factories import ObjectiveGroupFactory, ObjectiveFactory +from users.factories import SchoolClassFactory +from users.models import User, SchoolClass + + +class TestSnapshotMigration(SkillboxTestCase): + @property + def graphene_client(self): + return self.get_client() + + def setUp(self): + self.createDefault() + self.client = self.get_client() + # teacher will create snapshot + self.slug = 'some-module' + + self.module = ModuleFactory(slug=self.slug) + self.skillbox_class = SchoolClass.objects.get(name='skillbox') + self.teacher2 = User.objects.get(username='teacher2') + self.second_class_name = 'second_class' + self.second_class = SchoolClass.objects.get(name=self.second_class_name) + self.admin = User.objects.get(username='admin') + + # we make a snapshot S of the module M + # snapshot S looks like module M for school class X + # module M has a chapter + self.chapter = ChapterFactory(parent=self.module, slug='some-chapter', owner=self.admin) + ChapterFactory(parent=self.module, slug='some-other-chapter', owner=self.admin) + + objective_group = ObjectiveGroupFactory(module=self.module, title='Gesellschaft') + second_objective_group = ObjectiveGroupFactory(module=self.module, title='Sprache & Kommunikation') + + self.visible_objective = ObjectiveFactory(text='visible-objective', group=objective_group) + self.visible_objective_2 = ObjectiveFactory(text='hidden-objective', group=objective_group) + self.custom_objective = ObjectiveFactory(text='custom-objective', group=objective_group, owner=self.teacher) + self.custom_hidden_objective = ObjectiveFactory(text='custom-hidden-objective', group=objective_group, + owner=self.teacher) + + self.visible_objective = ObjectiveFactory(text='objective1', group=second_objective_group) + + self.custom_objective.visible_for.add(self.skillbox_class) + self.custom_objective.save() + + second_objective_group.hidden_for.add(self.skillbox_class) + second_objective_group.save() + + self.custom_hidden_objective.visible_for.remove(self.skillbox_class) + + self.snapshot1 = Snapshot.objects.create_snapshot(self.module, self.skillbox_class, self.teacher) + + migrate_objectives_to_content() + + migrate_snapshots() + + # Change visibility of objectives resp. content blocks, hide all + + for content_block in ContentBlock.objects.all().descendant_of(self.chapter): + if content_block.owner is None: + content_block.hidden_for.add(self.skillbox_class) + else: + content_block.visible_for.remove(self.skillbox_class) + content_block.save() + + def test_snapshot_migration_dfault_content_pre_apply(self): + result = self.client.execute(MODULE_QUERY, variables={ + 'slug': self.module.slug + }) + module = result.data['module'] + chapter1 = module['chapters'][0] + default_content, _, _ = chapter1['contentBlocks'] + + # default content block (Verlagsinhalte) exists but is hidden (since one objective is hidden for this class) + self.assertEqual(default_content['title'], 'Gesellschaft') + self.assertEqual(default_content['originalCreator'], None) + self.assertEqual(default_content['contents'][0]['value']['text'], + '
    • visible-objective
    • hidden-objective
    ') + self.assertEqual(default_content['hiddenFor'], [{'name': 'skillbox'}]) + + def test_snapshot_migration_hidden_default_content_creates_custom_content_pre_apply(self): + result = self.client.execute(MODULE_QUERY, variables={ + 'slug': self.module.slug + }) + module = result.data['module'] + chapter1 = module['chapters'][0] + _, custom, _ = chapter1['contentBlocks'] + + self.assertEqual(custom['title'], 'Gesellschaft') + self.assertTrue(custom['originalCreator'] is not None) + self.assertEqual(custom['hiddenFor'], []) + self.assertEqual(custom['visibleFor'], []) + self.assertEqual(custom['contents'][0]['value']['text'], + '
    • visible-objective
    • hidden-objective
    • custom-objective
    ') + + def test_snapshot_migration_hidden_content_block_pre_apply(self): + result = self.client.execute(MODULE_QUERY, variables={ + 'slug': self.module.slug + }) + module = result.data['module'] + chapter1 = module['chapters'][0] + _, _, hidden_custom = chapter1['contentBlocks'] + + # default content block (Verlagsinhalte) exists but is hidden (since one objective is hidden for this class) + self.assertEqual(hidden_custom['title'], 'Sprache & Kommunikation') + self.assertTrue(hidden_custom['originalCreator'] is None) + self.assertEqual(hidden_custom['hiddenFor'], [{'name': 'skillbox'}]) + self.assertEqual(hidden_custom['visibleFor'], []) + self.assertEqual(hidden_custom['contents'][0]['value']['text'], '
    • objective1
    ') + + def test_snapshot_migration_default_apply_snapshot(self): + # apply snapshot + self.snapshot1.apply(self.teacher, self.school_class) + result = self.client.execute(MODULE_QUERY, variables={ + 'slug': self.module.slug + }) + module = result.data['module'] + chapter1 = module['chapters'][0] + default_content, _, _, _ = chapter1['contentBlocks'] + + # default content block (Verlagsinhalte) exists but is hidden (since one objective is hidden for this class) + self.assertEqual(default_content['title'], 'Gesellschaft') + self.assertFalse(default_content['userCreated']) + self.assertEqual(default_content['originalCreator'], None) + self.assertEqual(default_content['contents'][0]['value']['text'], + '
    • visible-objective
    • hidden-objective
    ') + self.assertEqual(default_content['hiddenFor'], [{'name': 'skillbox'}]) + + def test_snapshot_migration_hidden_custom_content_apply_snapshot(self): + # custom content from bevore the snapshot must be hidden (visible for nobody) + self.snapshot1.apply(self.teacher, self.school_class) + + result = self.client.execute(MODULE_QUERY, variables={ + 'slug': self.module.slug + }) + module = result.data['module'] + chapter1 = module['chapters'][0] + _, custom, _, _ = chapter1['contentBlocks'] + + self.assertEqual(custom['title'], 'Gesellschaft') + self.assertTrue(custom['userCreated']) + self.assertTrue(custom['originalCreator'] is not None) + self.assertEqual(custom['hiddenFor'], []) + self.assertEqual(custom['visibleFor'], []) + self.assertEqual(custom['contents'][0]['value']['text'], + '
    • visible-objective
    • hidden-objective
    • custom-objective
    ') + + def test_snapshot_migration_hidden_content_block_apply_snapshot_2(self): + # custom content from bevore the snapshot must be hidden (visible for nobody) + self.snapshot1.apply(self.teacher, self.school_class) + + result = self.client.execute(MODULE_QUERY, variables={ + 'slug': self.module.slug + }) + module = result.data['module'] + chapter1 = module['chapters'][0] + _, _, hidden_custom, _ = chapter1['contentBlocks'] + + # default content block (Verlagsinhalte) exists but is hidden (since one objective is hidden for this class) + self.assertEqual(hidden_custom['title'], 'Sprache & Kommunikation') + self.assertFalse(hidden_custom['userCreated']) + self.assertTrue(hidden_custom['originalCreator'] is None) + self.assertEqual(hidden_custom['hiddenFor'], [{'name': 'skillbox'}]) + self.assertEqual(hidden_custom['visibleFor'], []) + self.assertEqual(hidden_custom['contents'][0]['value']['text'], '
    • objective1
    ') + + def test_snapshot_migration_visible_content_snapshot_new_content(self): + # the applicaiton of a snapshot hides old custom content, and creates new custom content for the visible stuff + self.snapshot1.apply(self.teacher, self.school_class) + + result = self.client.execute(MODULE_QUERY, variables={ + 'slug': self.module.slug + }) + module = result.data['module'] + chapter1 = module['chapters'][0] + _, _, _, new_content_block = chapter1['contentBlocks'] + + self.assertEqual(new_content_block['title'], 'Gesellschaft') + self.assertTrue(new_content_block['userCreated']) + self.assertTrue(new_content_block['originalCreator'] is None) + self.assertEqual(new_content_block['hiddenFor'], []) + self.assertEqual(new_content_block['visibleFor'], [{'name': 'skillbox'}]) + self.assertEqual(new_content_block['contents'][0]['value']['text'], + '
    • visible-objective
    • hidden-objective
    • custom-objective
    ') diff --git a/server/core/management/commands/reset_all_passwords.py b/server/core/management/commands/reset_all_passwords.py index 886a9266..f198fdc4 100644 --- a/server/core/management/commands/reset_all_passwords.py +++ b/server/core/management/commands/reset_all_passwords.py @@ -1,3 +1,6 @@ +from concurrent.futures import ThreadPoolExecutor +from datetime import timedelta, datetime + from django.contrib.auth import get_user_model from django.core.management import BaseCommand @@ -9,9 +12,32 @@ class Command(BaseCommand): self.stdout.write("If so, type \"YES\"") result = input() + user_model = get_user_model() if result == 'YES': - users = get_user_model().objects.all() - for user in users: - user.set_password('test') - user.save() + users = user_model.objects.all().order_by('email') + + with ThreadPoolExecutor(max_workers=5) as executor: + executor.map(process_user, users) + + +def process_user(usr): + # replace domain with id.skillbox.ch,to ensure unique emails + usr.email = usr.email.split('@')[0] + f'@skillbox.ch' + user_model = get_user_model() + + if user_model.objects.filter(email=usr.email).exists(): + usr.email = usr.email.split('@')[0] + f'@{usr.id}.skillbox.ch' + + usr.username = usr.email + print(usr.email) + + # make license valid for 1 year + now = datetime.now() + + if usr.license_expiry_date: + usr.license_expiry_date = now + timedelta(days=365) + + # set password to test + usr.set_password('test') + usr.save() diff --git a/server/core/settings.py b/server/core/settings.py index e7546779..a610c84b 100644 --- a/server/core/settings.py +++ b/server/core/settings.py @@ -158,6 +158,9 @@ AUTH_USER_MODEL = "users.User" WEAK_PASSWORDS = DEBUG if WEAK_PASSWORDS: AUTH_PASSWORD_VALIDATORS = [] + PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.MD5PasswordHasher", + ] else: AUTH_PASSWORD_VALIDATORS = [ { diff --git a/server/test_objectives_migrations.sh b/server/test_objectives_migrations.sh new file mode 100755 index 00000000..6a24a1d8 --- /dev/null +++ b/server/test_objectives_migrations.sh @@ -0,0 +1,12 @@ +pg_restore --verbose --clean --no-acl --no-owner -h localhost -U skillbox -d skillbox latest.dump +python manage.py migrate +python manage.py reset_all_passwords +pg_dump -Fc --no-acl -h localhost -U skillbox skillbox > latest-anonymized.dump +python manage.py migrate_objectives_to_content +pg_dump -Fc --no-acl -h localhost -U skillbox skillbox > latest-migrated-objectives.dump +python manage.py migrate_objective_snapshots +pg_dump -Fc --no-acl -h localhost -U skillbox skillbox > latest-migrated-objectives-and-snapshots.dump + + +#Use this command to restore the database from the dump: +#pg_restore --verbose --clean --no-acl --no-owner -h localhost -U skillbox -d skillbox latest-migrated-objectives.dump