Merged in feature/MS851-MigrateObjectives-Bugfixing (pull request #144)

Feature/MS851 MigrateObjectives Bugfixing
This commit is contained in:
Lorenz Padberg 2024-02-27 11:05:56 +00:00
commit 34d42f2d42
6 changed files with 203 additions and 134 deletions

View File

@ -2,6 +2,8 @@ import json
from logging import getLogger from logging import getLogger
from django.core.management import BaseCommand from django.core.management import BaseCommand
from django.db import models
from django.db.models import Case, IntegerField
from books.management.commands.migrate_objectives_to_content import create_text_in_content_block, \ from books.management.commands.migrate_objectives_to_content import create_text_in_content_block, \
create_content_block_snapshot_from_objective, \ create_content_block_snapshot_from_objective, \
@ -72,8 +74,7 @@ def migrate_snapshots():
f"{snapshot_counter} Snapshot id: {snapshot.id} Module: {module.title} {group_counter} groups {snapshot.creator} {snapshot.title}") f"{snapshot_counter} Snapshot id: {snapshot.id} Module: {module.title} {group_counter} groups {snapshot.creator} {snapshot.title}")
snapshot_counter += 1 snapshot_counter += 1
for objective_group_snapshot in snapshot.objective_groups.through.objects.filter( for objective_group_snapshot in get_objectives_group_snapshots_in_specific_order(module, snapshot):
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} " 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 count += 1
objective_group = objective_group_snapshot.objective_group objective_group = objective_group_snapshot.objective_group
@ -197,6 +198,22 @@ def get_default_content_block(objective_group_snapshot, module):
raise Exception("Content block does not exist ") raise Exception("Content block does not exist ")
def get_objectives_group_snapshots_in_specific_order(module: Module, snapshot: Snapshot):
# Create a specific order for the objective groups
# https://stackoverflow.com/questions/5966462/sort-queryset-by-values-in-list
# https://docs.djangoproject.com/en/5.0/ref/models/conditional-expressions/
order_of_objective_groups = ["language_communication", "society", "interdisciplinary"]
_whens = [models.When(objective_group__title=value, then=sort_index) for sort_index, value in enumerate(order_of_objective_groups)]
qs = snapshot.objective_groups.through.objects.filter(
objective_group__module=module,
snapshot=snapshot
).annotate(
_sort_index=Case(*_whens, default=models.Value(len(order_of_objective_groups)), output_field=IntegerField())
).order_by('_sort_index')
return qs
def get_visible_default_objectives(objective_group, module, snapshot): def get_visible_default_objectives(objective_group, module, snapshot):
default_objectives = Objective.objects.filter(group=objective_group, default_objectives = Objective.objects.filter(group=objective_group,
group__module=module, group__module=module,

View File

@ -3,6 +3,7 @@ from logging import getLogger
from django.core.management import BaseCommand from django.core.management import BaseCommand
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models
from books.models import Chapter, ObjectiveGroupSnapshot, Snapshot, ContentBlockSnapshot, ChapterSnapshot from books.models import Chapter, ObjectiveGroupSnapshot, Snapshot, ContentBlockSnapshot, ChapterSnapshot
from books.models import ContentBlock from books.models import ContentBlock
@ -11,16 +12,12 @@ from objectives.models import ObjectiveSnapshot, Objective, ObjectiveGroup
logger = getLogger(__name__) logger = getLogger(__name__)
class Command(BaseCommand): class Command(BaseCommand):
def handle(self, *args, **options): 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() migrate_objectives_to_content()
def migrate_objectives_to_content(): def migrate_objectives_to_content():
created_content_blocks = 0 created_content_blocks = 0
@ -34,7 +31,7 @@ def migrate_objectives_to_content():
try: try:
chapter = create_chapter_from_objective_group(module) chapter = create_chapter_from_objective_group(module)
for objective_group in module.objective_groups.all().order_by('title'): for objective_group in get_objectives_groups_in_specific_order(module):
default_objectives = list(objective_group.objectives.filter(owner__isnull=True, ) default_objectives = list(objective_group.objectives.filter(owner__isnull=True, )
.exclude(objectivesnapshot__isnull=False).order_by('order')) .exclude(objectivesnapshot__isnull=False).order_by('order'))
@ -47,26 +44,34 @@ def migrate_objectives_to_content():
else: else:
default_content_block = created_default_content_blocks[default_objectives_ids] default_content_block = created_default_content_blocks[default_objectives_ids]
# set visibility for objective_group for default content (Verlagsinhalte)
if objective_group.hidden_for.exists():
default_content_block.hidden_for.add(*objective_group.hidden_for.all())
default_content_block.save_revision().publish()
default_content_block.save()
custom_objectives_by_owner = get_objectives_by_owner(objective_group) custom_objectives_by_owner = get_objectives_by_owner(objective_group)
if default_objectives or custom_objectives_by_owner: if default_objectives or custom_objectives_by_owner:
contentblocks_by_merged_objectives_ids = {} contentblocks_by_merged_objectives_ids = {}
# cor custom objectives iterate over owners, # for custom objectives iterate over owners,
# - one ownsers custom objectives must not be changed by another owner # - one ownsers custom objectives must not be changed by another owner
# - visibility is set per class # - visibility is set per class
for owner, owner_objectives in custom_objectives_by_owner.items(): for owner, owner_objectives in custom_objectives_by_owner.items():
print(f"Owner: {owner}") print(f"Owner: {owner}")
print(f" Objectives: ") print(f" Objectives: ")
visible_default_objectives_by_class = filter_visible_objectives_by_class(default_objectives, owner) 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(): for school_class, default_objectives_for_class in visible_default_objectives_by_class.items():
custom_content_block = None custom_content_block = None
print(f" School class: {school_class}") print(f" School class: {school_class}")
# merge "Verlagsinhalte" and "benutzerdefinierte Inhalte" # merge "Verlagsinhalte" and "benutzerdefinierte Inhalte"
visible_owner_objectives = [objective for objective in owner_objectives if not objective.is_hidden_for_class(school_class)] 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 = default_objectives_for_class + visible_owner_objectives
merged_objectives_ids = tuple(objective.id for objective in merged_objectives) merged_objectives_ids = tuple(objective.id for objective in merged_objectives)
@ -105,16 +110,15 @@ def migrate_objectives_to_content():
default_content_block.save_revision().publish() default_content_block.save_revision().publish()
default_content_block.save() default_content_block.save()
# make custom content block visible for school class if it is not in hidden list
if not objective_group.hidden_for.filter(id=school_class.id).exists():
custom_content_block.visible_for.add(school_class) custom_content_block.visible_for.add(school_class)
else:
custom_content_block.hidden_for.add(school_class)
custom_content_block.save_revision().publish() custom_content_block.save_revision().publish()
custom_content_block.save() 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: except ValidationError as e:
print(f"Error with module {module}") print(f"Error with module {module}")
logger.error(e) logger.error(e)
@ -127,6 +131,24 @@ def migrate_objectives_to_content():
print(f"Faile module: {module}") print(f"Faile module: {module}")
def get_objectives_groups_in_specific_order(module):
# Create a specific order for the objective groups
# https://stackoverflow.com/questions/5966462/sort-queryset-by-values-in-list
# https://docs.djangoproject.com/en/5.0/ref/models/conditional-expressions/
order_of_objective_groups = ["language_communication", "society", "interdisciplinary"]
_whens = []
for sort_index, value in enumerate(order_of_objective_groups):
_whens.append(
models.When(title=value, then=sort_index)
)
qs = module.objective_groups.all().annotate(_sort_index=models.Case(*_whens,
output_field=models.IntegerField()
)
).order_by('_sort_index')
return qs
def create_default_content(objective_group, chapter): def create_default_content(objective_group, chapter):
"""Create Verlagsinhalt Lernziele""" """Create Verlagsinhalt Lernziele"""
print(f" Objective group: {objective_group}") print(f" Objective group: {objective_group}")
@ -156,7 +178,8 @@ def filter_visible_objectives_by_class(objectives, user):
def get_objectives_by_owner(objective_group, exclude_snapshots=True): 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 = objective_group.objectives.filter(owner__isnull=False, objectivesnapshot__isnull=True).order_by(
'order')
custom_objectives_by_owner = {} custom_objectives_by_owner = {}
for objective in custom_objectives: for objective in custom_objectives:
@ -242,6 +265,7 @@ def create_text_in_content_block(objectives, content_block, get_or_create=False)
content_block.save_revision().publish() content_block.save_revision().publish()
return content_block return content_block
def create_content_block_contents(objectives): def create_content_block_contents(objectives):
objectives = list(objectives) objectives = list(objectives)
objective_li = [f"<li>{objective.text}</li>" for objective in objectives if objective.text] objective_li = [f"<li>{objective.text}</li>" for objective in objectives if objective.text]
@ -254,6 +278,7 @@ def create_content_block_contents(objectives):
contents = json.dumps(texts) contents = json.dumps(texts)
return contents return contents
def analyze(): def analyze():
print(f""" print(f"""
OjectiveGroups: {ObjectiveGroup.objects.count()} OjectiveGroups: {ObjectiveGroup.objects.count()}

View File

@ -1,18 +1,8 @@
from django.test import RequestFactory from books.factories import ModuleFactory, ChapterFactory
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.management.commands.migrate_objectives_to_content import migrate_objectives_to_content
from books.models import Snapshot, ChapterSnapshot from books.tests.queries import MODULE_QUERY
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 core.tests.base_test import SkillboxTestCase
from objectives.factories import ObjectiveGroupFactory, ObjectiveFactory from objectives.factories import ObjectiveGroupFactory, ObjectiveFactory
from users.factories import SchoolClassFactory
from users.models import User, SchoolClass from users.models import User, SchoolClass
@ -40,27 +30,33 @@ class TestObjectivesMigration(SkillboxTestCase):
self.chapter = ChapterFactory(parent=self.module, slug='some-chapter', owner=self.admin) self.chapter = ChapterFactory(parent=self.module, slug='some-chapter', owner=self.admin)
ChapterFactory(parent=self.module, slug='some-other-chapter', owner=self.admin) ChapterFactory(parent=self.module, slug='some-other-chapter', owner=self.admin)
objective_group = ObjectiveGroupFactory(module=self.module, title='Gesellschaft') objective_group = ObjectiveGroupFactory(module=self.module, title='society')
second_objective_group = ObjectiveGroupFactory(module=self.module, title='Sprache & Kommunikation')
self.visible_objective = ObjectiveFactory(text='visible-objective', group=objective_group) self.visible_objective = ObjectiveFactory(text='visible-objective', group=objective_group)
self.hidden_objective = ObjectiveFactory(text='hidden-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.hidden_for.add(self.skillbox_class)
self.hidden_objective.save() self.hidden_objective.save()
self.custom_objective = ObjectiveFactory(text='custom-objective', group=objective_group, owner=self.teacher)
self.custom_objective.visible_for.add(self.skillbox_class) self.custom_objective.visible_for.add(self.skillbox_class)
self.custom_objective.save() self.custom_objective.save()
self.custom_hidden_objective = ObjectiveFactory(text='custom-hidden-objective', group=objective_group,
owner=self.teacher)
self.custom_hidden_objective.visible_for.remove(self.skillbox_class)
second_objective_group = ObjectiveGroupFactory(module=self.module, title='language_communication')
self.visible_objective = ObjectiveFactory(text='objective1', group=second_objective_group)
second_objective_group.hidden_for.add(self.skillbox_class) second_objective_group.hidden_for.add(self.skillbox_class)
second_objective_group.save() second_objective_group.save()
self.custom_hidden_objective.visible_for.remove(self.skillbox_class) third_objective_group = ObjectiveGroupFactory(module=self.module, title='interdisciplinary')
self.visible_objective_hidden_group_3 = ObjectiveFactory(text='objective1', group=third_objective_group)
self.hidden_objective_hidden_group_3 = ObjectiveFactory(text='objective2', group=third_objective_group,
owner=self.teacher)
self.hidden_objective_hidden_group_3.visible_for.add(self.skillbox_class)
self.hidden_objective_hidden_group_3.save()
third_objective_group.hidden_for.add(self.skillbox_class)
third_objective_group.save()
migrate_objectives_to_content() migrate_objectives_to_content()
@ -70,7 +66,7 @@ class TestObjectivesMigration(SkillboxTestCase):
}) })
module = result.data['module'] module = result.data['module']
chapter1 = module['chapters'][0] chapter1 = module['chapters'][0]
default_content, _, _ = chapter1['contentBlocks'] _, default_content, _, _, _ = chapter1['contentBlocks']
# default content block (Verlagsinhalte) exists but is hidden (since one objective is hidden for this class) # 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['title'], 'Gesellschaft')
@ -85,7 +81,7 @@ class TestObjectivesMigration(SkillboxTestCase):
}) })
module = result.data['module'] module = result.data['module']
chapter1 = module['chapters'][0] chapter1 = module['chapters'][0]
_, custom, _ = chapter1['contentBlocks'] _, _, custom, _, _ = chapter1['contentBlocks']
# default content block (Verlagsinhalte) exists but is hidden (since one objective is hidden for this class) # default content block (Verlagsinhalte) exists but is hidden (since one objective is hidden for this class)
self.assertEqual(custom['title'], 'Gesellschaft') self.assertEqual(custom['title'], 'Gesellschaft')
@ -101,11 +97,43 @@ class TestObjectivesMigration(SkillboxTestCase):
}) })
module = result.data['module'] module = result.data['module']
chapter1 = module['chapters'][0] chapter1 = module['chapters'][0]
_, _, hidden_custom = chapter1['contentBlocks'] hidden_default, _, _, _, _ = chapter1['contentBlocks']
# default content block (Verlagsinhalte) exists but is hidden (since one objective is hidden for this class) # default content block (Verlagsinhalte) exists but is hidden (since one objective is hidden for this class)
self.assertEqual(hidden_custom['title'], 'Sprache & Kommunikation') self.assertEqual(hidden_default['title'], 'Sprache & Kommunikation')
self.assertTrue(hidden_custom['originalCreator'] is None) self.assertTrue(hidden_default['originalCreator'] is None)
self.assertEqual(hidden_custom['hiddenFor'], []) self.assertEqual(hidden_default['hiddenFor'], [{'name': 'skillbox'}])
self.assertEqual(hidden_custom['visibleFor'], []) self.assertEqual(hidden_default['visibleFor'], [])
self.assertEqual(hidden_custom['contents'][0]['value']['text'], '<ul><li>objective1</li></ul>') self.assertEqual(hidden_default['contents'][0]['value']['text'], '<ul><li>objective1</li></ul>')
def test_objectives_order(self):
"""The correct oder of the objectives is:
- Sprache & Kommunikation
- Gesellschaft
- Übergeordnete Lernziele
"""
result = self.client.execute(MODULE_QUERY, variables={
'slug': self.module.slug
})
module = result.data['module']
chapter1 = module['chapters'][0]
titles = [content['title'] for content in chapter1['contentBlocks']]
self.assertEqual(titles, ['Sprache & Kommunikation', 'Gesellschaft', 'Gesellschaft', 'Überfachliche Lernziele',
'Überfachliche Lernziele'])
def test_objectives_migration_hidden_group_custom_content(self):
result = self.client.execute(MODULE_QUERY, variables={
'slug': self.module.slug
})
module = result.data['module']
chapter1 = module['chapters'][0]
_, _, _, _, hidden_custom_group = chapter1['contentBlocks']
# default content block (Verlagsinhalte) exists but is hidden (since one objective is hidden for this class)
self.assertEqual(hidden_custom_group['title'], 'Überfachliche Lernziele')
self.assertTrue(hidden_custom_group['originalCreator'] is not None)
self.assertEqual(hidden_custom_group['hiddenFor'], [{'name': 'skillbox'}])
self.assertEqual(hidden_custom_group['visibleFor'], [])
self.assertEqual(hidden_custom_group['contents'][0]['value']['text'],
'<ul><li>objective1</li><li>objective2</li></ul>')

View File

@ -40,8 +40,8 @@ class TestSnapshotMigration(SkillboxTestCase):
self.chapter = ChapterFactory(parent=self.module, slug='some-chapter', owner=self.admin) self.chapter = ChapterFactory(parent=self.module, slug='some-chapter', owner=self.admin)
ChapterFactory(parent=self.module, slug='some-other-chapter', owner=self.admin) ChapterFactory(parent=self.module, slug='some-other-chapter', owner=self.admin)
objective_group = ObjectiveGroupFactory(module=self.module, title='Gesellschaft') objective_group = ObjectiveGroupFactory(module=self.module, title='society')
second_objective_group = ObjectiveGroupFactory(module=self.module, title='Sprache & Kommunikation') second_objective_group = ObjectiveGroupFactory(module=self.module, title='language_communication')
self.visible_objective = ObjectiveFactory(text='visible-objective', group=objective_group) self.visible_objective = ObjectiveFactory(text='visible-objective', group=objective_group)
self.visible_objective_2 = ObjectiveFactory(text='hidden-objective', group=objective_group) self.visible_objective_2 = ObjectiveFactory(text='hidden-objective', group=objective_group)
@ -80,7 +80,7 @@ class TestSnapshotMigration(SkillboxTestCase):
}) })
module = result.data['module'] module = result.data['module']
chapter1 = module['chapters'][0] chapter1 = module['chapters'][0]
default_content, _, _ = chapter1['contentBlocks'] _, default_content, _ = chapter1['contentBlocks']
# default content block (Verlagsinhalte) exists but is hidden (since one objective is hidden for this class) # 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['title'], 'Gesellschaft')
@ -95,7 +95,7 @@ class TestSnapshotMigration(SkillboxTestCase):
}) })
module = result.data['module'] module = result.data['module']
chapter1 = module['chapters'][0] chapter1 = module['chapters'][0]
_, custom, _ = chapter1['contentBlocks'] _, _, custom = chapter1['contentBlocks']
self.assertEqual(custom['title'], 'Gesellschaft') self.assertEqual(custom['title'], 'Gesellschaft')
self.assertTrue(custom['originalCreator'] is not None) self.assertTrue(custom['originalCreator'] is not None)
@ -110,7 +110,7 @@ class TestSnapshotMigration(SkillboxTestCase):
}) })
module = result.data['module'] module = result.data['module']
chapter1 = module['chapters'][0] chapter1 = module['chapters'][0]
_, _, hidden_custom = chapter1['contentBlocks'] hidden_custom, _, _ = chapter1['contentBlocks']
# default content block (Verlagsinhalte) exists but is hidden (since one objective is hidden for this class) # default content block (Verlagsinhalte) exists but is hidden (since one objective is hidden for this class)
self.assertEqual(hidden_custom['title'], 'Sprache & Kommunikation') self.assertEqual(hidden_custom['title'], 'Sprache & Kommunikation')
@ -127,7 +127,7 @@ class TestSnapshotMigration(SkillboxTestCase):
}) })
module = result.data['module'] module = result.data['module']
chapter1 = module['chapters'][0] chapter1 = module['chapters'][0]
default_content, _, _, _ = chapter1['contentBlocks'] _, default_content, _, _ = chapter1['contentBlocks']
# default content block (Verlagsinhalte) exists but is hidden (since one objective is hidden for this class) # 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['title'], 'Gesellschaft')
@ -146,7 +146,7 @@ class TestSnapshotMigration(SkillboxTestCase):
}) })
module = result.data['module'] module = result.data['module']
chapter1 = module['chapters'][0] chapter1 = module['chapters'][0]
_, custom, _, _ = chapter1['contentBlocks'] _, _, custom, _ = chapter1['contentBlocks']
self.assertEqual(custom['title'], 'Gesellschaft') self.assertEqual(custom['title'], 'Gesellschaft')
self.assertTrue(custom['userCreated']) self.assertTrue(custom['userCreated'])
@ -165,7 +165,7 @@ class TestSnapshotMigration(SkillboxTestCase):
}) })
module = result.data['module'] module = result.data['module']
chapter1 = module['chapters'][0] chapter1 = module['chapters'][0]
_, _, hidden_custom, _ = chapter1['contentBlocks'] hidden_custom, _, _, _ = chapter1['contentBlocks']
# default content block (Verlagsinhalte) exists but is hidden (since one objective is hidden for this class) # default content block (Verlagsinhalte) exists but is hidden (since one objective is hidden for this class)
self.assertEqual(hidden_custom['title'], 'Sprache & Kommunikation') self.assertEqual(hidden_custom['title'], 'Sprache & Kommunikation')

View File

@ -9,7 +9,7 @@ from django.views.decorators.csrf import ensure_csrf_cookie
from graphene_django.views import GraphQLView from graphene_django.views import GraphQLView
from graphql import get_operation_ast, parse from graphql import get_operation_ast, parse
from sentry_sdk.api import start_transaction from sentry_sdk.api import start_transaction
from wagtail.admin.views.pages.listing import IndexView from wagtail.admin.views.pages import listing
logger = get_logger(__name__) logger = get_logger(__name__)
@ -67,4 +67,4 @@ def override_wagtailadmin_explore_default_ordering(request, parent_page_id):
# Display reordering handles by default for children of all Page types. # Display reordering handles by default for children of all Page types.
return HttpResponseRedirect(request.path_info + "?ordering=ord") return HttpResponseRedirect(request.path_info + "?ordering=ord")
return IndexView.as_view(request=request, parent_page_id=parent_page_id) return listing.IndexView.as_view()(request, parent_page_id)

View File

@ -8,6 +8,5 @@ export USE_AWS=False
export WAGTAILADMIN_BASE_URL=/ export WAGTAILADMIN_BASE_URL=/
export ALLOW_BETA_LOGIN=True export ALLOW_BETA_LOGIN=True
export THEME=my-kv #export THEME=my-kv
export APP_FLAVOR=my-kv #export APP_FLAVOR=my-kv