Просмотр исходного кода

Move settings from settings.py to database (#1241)

Rafał Pitoń 6 лет назад
Родитель
Сommit
fafa7d8750
119 измененных файлов с 2658 добавлено и 2121 удалено
  1. 0 28
      devproject/settings.py
  2. 4 1
      misago/admin/src/style/admin-conf.scss
  3. 1 3
      misago/categories/api.py
  4. 79 85
      misago/categories/tests/test_utils.py
  5. 5 5
      misago/categories/utils.py
  6. 1 3
      misago/categories/views.py
  7. 2 2
      misago/conf/admin/__init__.py
  8. 25 0
      misago/conf/admin/forms/captcha.py
  9. 105 0
      misago/conf/admin/forms/threads.py
  10. 88 1
      misago/conf/admin/forms/users.py
  11. 98 0
      misago/conf/admin/tests/test_blank_avatar_validation.py
  12. 2 2
      misago/conf/context_processors.py
  13. 2 119
      misago/conf/defaults.py
  14. 23 0
      misago/conf/migrations/0004_create_settings.py
  15. 7 0
      misago/conf/shortcuts.py
  16. 64 0
      misago/conftest.py
  17. 1 1
      misago/core/exceptionhandler.py
  18. 7 7
      misago/core/tests/test_mail.py
  19. 4 1
      misago/legal/admin/__init__.py
  20. 7 7
      misago/readtracker/categoriestracker.py
  21. 13 0
      misago/readtracker/cutoffdate.py
  22. 0 23
      misago/readtracker/dates.py
  23. 4 3
      misago/readtracker/management/commands/clearreadtracker.py
  24. 1 69
      misago/readtracker/migrations/0003_migrate_reads_to_posts.py
  25. 5 5
      misago/readtracker/poststracker.py
  26. 23 0
      misago/readtracker/tests/conftest.py
  27. 161 172
      misago/readtracker/tests/test_categoriestracker.py
  28. 0 64
      misago/readtracker/tests/test_clearreadtracker.py
  29. 52 0
      misago/readtracker/tests/test_clearreadtracker_command.py
  30. 41 0
      misago/readtracker/tests/test_cutoff_date.py
  31. 0 62
      misago/readtracker/tests/test_dates.py
  32. 66 58
      misago/readtracker/tests/test_poststracker.py
  33. 87 131
      misago/readtracker/tests/test_threadstracker.py
  34. 7 6
      misago/readtracker/threadstracker.py
  35. 1 1
      misago/static/misago/admin/index.css
  36. BIN
      misago/static/misago/img/attachment-403.png
  37. BIN
      misago/static/misago/img/attachment-404.png
  38. BIN
      misago/static/misago/img/blank-avatar.png
  39. BIN
      misago/static/misago/img/error-403.png
  40. BIN
      misago/static/misago/img/error-404.png
  41. 9 0
      misago/templates/misago/admin/conf/captcha_settings.html
  42. 47 4
      misago/templates/misago/admin/conf/threads_settings.html
  43. 53 1
      misago/templates/misago/admin/conf/users_settings.html
  44. 2 2
      misago/templates/misago/admin/dashboard/checks.html
  45. 7 7
      misago/templates/misago/navbar.html
  46. 4 1
      misago/threads/admin/__init__.py
  47. 5 4
      misago/threads/api/postendpoints/delete.py
  48. 6 1
      misago/threads/api/postendpoints/merge.py
  49. 6 1
      misago/threads/api/postendpoints/move.py
  50. 17 7
      misago/threads/api/postendpoints/patch_post.py
  51. 2 2
      misago/threads/api/postendpoints/read.py
  52. 17 17
      misago/threads/api/postingendpoint/attachments.py
  53. 10 9
      misago/threads/api/postingendpoint/floodprotection.py
  54. 5 1
      misago/threads/api/threadendpoints/delete.py
  55. 16 7
      misago/threads/api/threadendpoints/patch.py
  56. 5 5
      misago/threads/management/commands/clearattachments.py
  57. 6 6
      misago/threads/search.py
  58. 40 33
      misago/threads/serializers/moderation.py
  59. 8 8
      misago/threads/tests/test_anonymize_data.py
  60. 290 298
      misago/threads/tests/test_attachments_middleware.py
  61. 273 0
      misago/threads/tests/test_attachments_proxy.py
  62. 0 238
      misago/threads/tests/test_attachmentview.py
  63. 58 73
      misago/threads/tests/test_clearattachments.py
  64. 10 10
      misago/threads/tests/test_emailnotification_middleware.py
  65. 185 29
      misago/threads/tests/test_floodprotection_middleware.py
  66. 53 17
      misago/threads/tests/test_gotoviews.py
  67. 10 10
      misago/threads/tests/test_privatethread_start_api.py
  68. 4 2
      misago/threads/tests/test_thread_bulkpatch_api.py
  69. 2 2
      misago/threads/tests/test_thread_model.py
  70. 1 1
      misago/threads/tests/test_thread_postbulkdelete_api.py
  71. 4 2
      misago/threads/tests/test_thread_postbulkpatch_api.py
  72. 4 6
      misago/threads/tests/test_thread_postmerge_api.py
  73. 4 9
      misago/threads/tests/test_thread_postmove_api.py
  74. 4 6
      misago/threads/tests/test_thread_postsplit_api.py
  75. 4 6
      misago/threads/tests/test_threads_bulkdelete_api.py
  76. 4 6
      misago/threads/tests/test_threads_merge_api.py
  77. 10 10
      misago/threads/tests/test_threadslists.py
  78. 8 9
      misago/threads/tests/test_threadview.py
  79. 4 5
      misago/threads/viewmodels/posts.py
  80. 1 1
      misago/threads/viewmodels/thread.py
  81. 8 9
      misago/threads/viewmodels/threads.py
  82. 8 7
      misago/threads/views/attachment.py
  83. 5 4
      misago/threads/views/goto.py
  84. 5 3
      misago/users/activepostersranking.py
  85. 1 1
      misago/users/admin/forms.py
  86. 4 1
      misago/users/admin/tasks.py
  87. 1 1
      misago/users/admin/views/users.py
  88. 5 4
      misago/users/api/users.py
  89. 20 14
      misago/users/apps.py
  90. 3 5
      misago/users/datadownloads/__init__.py
  91. 2 4
      misago/users/management/commands/createsuperuser.py
  92. 7 6
      misago/users/management/commands/deleteinactiveusers.py
  93. 4 2
      misago/users/management/commands/deletemarkedusers.py
  94. 4 6
      misago/users/management/commands/prepareuserdatadownloads.py
  95. 6 5
      misago/users/management/commands/removeoldips.py
  96. 7 3
      misago/users/models/user.py
  97. 3 4
      misago/users/permissions/delete.py
  98. 1 1
      misago/users/serializers/options.py
  99. 4 5
      misago/users/signals.py
  100. 0 82
      misago/users/tests/test_activepostersranking.py
  101. 91 157
      misago/users/tests/test_audittrail.py
  102. 11 1
      misago/users/tests/test_datadownloads.py
  103. 14 13
      misago/users/tests/test_deleteinactiveusers.py
  104. 3 2
      misago/users/tests/test_deletemarkedusers.py
  105. 2 2
      misago/users/tests/test_prepareuserdatadownloads.py
  106. 6 6
      misago/users/tests/test_remove_old_ips_command.py
  107. 1 1
      misago/users/tests/test_search.py
  108. 98 0
      misago/users/tests/test_stop_forum_spam_validator.py
  109. 77 0
      misago/users/tests/test_top_posters_ranking.py
  110. 10 10
      misago/users/tests/test_user_changeemail_api.py
  111. 11 11
      misago/users/tests/test_user_changepassword_api.py
  112. 7 7
      misago/users/tests/test_user_model.py
  113. 3 4
      misago/users/tests/test_user_requestdatadownload_api.py
  114. 6 2
      misago/users/tests/test_users_api.py
  115. 22 15
      misago/users/validators.py
  116. 1 2
      misago/users/viewmodels/activeposters.py
  117. 6 2
      misago/users/viewmodels/followers.py
  118. 6 2
      misago/users/viewmodels/rankusers.py
  119. 1 2
      misago/users/viewmodels/threads.py

+ 0 - 28
devproject/settings.py

@@ -359,46 +359,18 @@ MISAGO_AVATARS_SIZES = [400, 200, 100]
 MISAGO_SEARCH_CONFIG = "simple"
 MISAGO_SEARCH_CONFIG = "simple"
 
 
 
 
-# Allow users to download their personal data
-# Enables users to learn what data about them is being held by the site without having
-# to contact site's administrators.
-
-MISAGO_ENABLE_DOWNLOAD_OWN_DATA = True
-
 # Path to the directory that Misago should use to prepare user data downloads.
 # Path to the directory that Misago should use to prepare user data downloads.
 # Should not be accessible from internet.
 # Should not be accessible from internet.
 
 
 MISAGO_USER_DATA_DOWNLOADS_WORKING_DIR = os.path.join(BASE_DIR, "userdata")
 MISAGO_USER_DATA_DOWNLOADS_WORKING_DIR = os.path.join(BASE_DIR, "userdata")
 
 
 
 
-# Allow users to delete their accounts
-# Lets users delete their own account on the site without having to contact site administrators.
-# This mechanism doesn't delete user posts, polls or attachments, but attempts to anonymize any
-# data about user left behind after user is deleted.
-
-MISAGO_ENABLE_DELETE_OWN_ACCOUNT = True
-
-
-# Automatically delete new user accounts that weren't activated in specified time
-# If you rely on admin review of new registrations, make this period long, disable
-# the "deleteinactiveusers" management command, or change this value to zero. Otherwise
-# keep it short to give users a chance to retry on their own after few days pass.
-
-MISAGO_DELETE_NEW_INACTIVE_USERS_OLDER_THAN_DAYS = 2
-
-
 # Path to directory containing avatar galleries
 # Path to directory containing avatar galleries
 # Those galleries can be loaded by running loadavatargallery command
 # Those galleries can be loaded by running loadavatargallery command
 
 
 MISAGO_AVATAR_GALLERY = os.path.join(BASE_DIR, "avatargallery")
 MISAGO_AVATAR_GALLERY = os.path.join(BASE_DIR, "avatargallery")
 
 
 
 
-# Specifies the number of days that IP addresses are stored in the database before removing.
-# Change this setting to None to never remove old IP addresses.
-
-MISAGO_IP_STORE_TIME = 50
-
-
 # Profile fields
 # Profile fields
 
 
 MISAGO_PROFILE_FIELDS = [
 MISAGO_PROFILE_FIELDS = [

+ 4 - 1
misago/admin/src/style/admin-conf.scss

@@ -16,6 +16,8 @@
   @extend .p-2;
   @extend .p-2;
   @extend .rounded;
   @extend .rounded;
 
 
+  line-height: $line-height-sm;
+
   color: $body-color;
   color: $body-color;
 
 
   &:hover,
   &:hover,
@@ -28,7 +30,8 @@
 
 
 // Reset settings group name size
 // Reset settings group name size
 .card-admin-settings-card h5 {
 .card-admin-settings-card h5 {
-  @extend .m-0;
+  @extend .mb-1;
 
 
   font-size: $font-size-base;
   font-size: $font-size-base;
+  line-height: $line-height-sm;
 }
 }

+ 1 - 3
misago/categories/api.py

@@ -7,7 +7,5 @@ from .utils import get_categories_tree
 
 
 class CategoryViewSet(viewsets.ViewSet):
 class CategoryViewSet(viewsets.ViewSet):
     def list(self, request):
     def list(self, request):
-        categories_tree = get_categories_tree(
-            request.user, request.user_acl, join_posters=True
-        )
+        categories_tree = get_categories_tree(request, join_posters=True)
         return Response(CategorySerializer(categories_tree, many=True).data)
         return Response(CategorySerializer(categories_tree, many=True).data)

+ 79 - 85
misago/categories/tests/test_utils.py

@@ -1,14 +1,55 @@
-from ...acl.useracl import get_user_acl
-from ...conftest import get_cache_versions
-from ...users.test import AuthenticatedUserTestCase
+from unittest.mock import Mock
+
+import pytest
+
 from ..models import Category
 from ..models import Category
 from ..utils import get_categories_tree, get_category_path
 from ..utils import get_categories_tree, get_category_path
 
 
-cache_versions = get_cache_versions()
-
 
 
-def get_patched_user_acl(user):
-    user_acl = get_user_acl(user, cache_versions)
+@pytest.fixture
+def categories_tree(root_category, default_category):
+    Category(name="Category A", slug="category-a").insert_at(
+        root_category, position="last-child", save=True
+    )
+    Category(name="Category E", slug="category-e").insert_at(
+        root_category, position="last-child", save=True
+    )
+
+    category_a = Category.objects.get(slug="category-a")
+
+    Category(name="Category B", slug="category-b").insert_at(
+        category_a, position="last-child", save=True
+    )
+
+    category_b = Category.objects.get(slug="category-b")
+
+    category_c = Category(name="Subcategory C", slug="subcategory-c").insert_at(
+        category_b, position="last-child", save=True
+    )
+    category_d = Category(name="Subcategory D", slug="subcategory-d").insert_at(
+        category_b, position="last-child", save=True
+    )
+
+    category_e = Category.objects.get(slug="category-e")
+    Category(name="Subcategory F", slug="subcategory-f").insert_at(
+        category_e, position="last-child", save=True
+    )
+
+    return {
+        "root": root_category,
+        "first": default_category,
+        "a": Category.objects.get(slug="category-a"),
+        "b": Category.objects.get(slug="category-b"),
+        "c": Category.objects.get(slug="subcategory-c"),
+        "d": Category.objects.get(slug="subcategory-d"),
+        "e": Category.objects.get(slug="category-e"),
+        "f": Category.objects.get(slug="subcategory-f"),
+    }
+
+
+@pytest.fixture
+def full_access_user_acl(categories_tree, user_acl):
+    user_acl = user_acl.copy()
     categories_acl = {"categories": {}, "visible_categories": []}
     categories_acl = {"categories": {}, "visible_categories": []}
     for category in Category.objects.all_categories():
     for category in Category.objects.all_categories():
         categories_acl["visible_categories"].append(category.id)
         categories_acl["visible_categories"].append(category.id)
@@ -17,81 +58,34 @@ def get_patched_user_acl(user):
     return user_acl
     return user_acl
 
 
 
 
-class CategoriesUtilsTests(AuthenticatedUserTestCase):
-    def setUp(self):
-        """
-        Create categories tree for test cases:
-
-        First category (created by migration)
-
-        Category A
-          + Category B
-            + Subcategory C
-            + Subcategory D
-
-        Category E
-          + Subcategory F
-        """
-        super().setUp()
-
-        self.root = Category.objects.root_category()
-        self.first_category = Category.objects.get(slug="first-category")
-
-        Category(name="Category A", slug="category-a").insert_at(
-            self.root, position="last-child", save=True
-        )
-        Category(name="Category E", slug="category-e").insert_at(
-            self.root, position="last-child", save=True
-        )
-
-        self.category_a = Category.objects.get(slug="category-a")
-
-        Category(name="Category B", slug="category-b").insert_at(
-            self.category_a, position="last-child", save=True
-        )
-
-        self.category_b = Category.objects.get(slug="category-b")
-
-        Category(name="Subcategory C", slug="subcategory-c").insert_at(
-            self.category_b, position="last-child", save=True
-        )
-        Category(name="Subcategory D", slug="subcategory-d").insert_at(
-            self.category_b, position="last-child", save=True
-        )
-
-        self.category_e = Category.objects.get(slug="category-e")
-        Category(name="Subcategory F", slug="subcategory-f").insert_at(
-            self.category_e, position="last-child", save=True
-        )
-
-        self.user_acl = get_patched_user_acl(self.user)
-
-    def test_root_categories_tree_no_parent(self):
-        """get_categories_tree returns all children of root nodes"""
-        categories_tree = get_categories_tree(self.user, self.user_acl)
-        self.assertEqual(len(categories_tree), 3)
-
-        self.assertEqual(
-            categories_tree[0], Category.objects.get(slug="first-category")
-        )
-        self.assertEqual(categories_tree[1], Category.objects.get(slug="category-a"))
-        self.assertEqual(categories_tree[2], Category.objects.get(slug="category-e"))
-
-    def test_root_categories_tree_with_parent(self):
-        """get_categories_tree returns all children of given node"""
-        categories_tree = get_categories_tree(self.user, self.user_acl, self.category_a)
-        self.assertEqual(len(categories_tree), 1)
-        self.assertEqual(categories_tree[0], Category.objects.get(slug="category-b"))
-
-    def test_root_categories_tree_with_leaf(self):
-        """get_categories_tree returns all children of given node"""
-        categories_tree = get_categories_tree(
-            self.user, self.user_acl, Category.objects.get(slug="subcategory-f")
-        )
-        self.assertEqual(len(categories_tree), 0)
-
-    def test_get_category_path(self):
-        """get_categories_tree returns all children of root nodes"""
-        for node in get_categories_tree(self.user, self.user_acl):
-            parent_nodes = len(get_category_path(node))
-            self.assertEqual(parent_nodes, node.level)
+@pytest.fixture
+def request_mock(user, full_access_user_acl, dynamic_settings):
+    return Mock(settings=dynamic_settings, user=user, user_acl=full_access_user_acl)
+
+
+def test_tree_getter_defaults_to_returning_top_level_categories(
+    request_mock, categories_tree
+):
+    assert get_categories_tree(request_mock) == [
+        categories_tree["first"],
+        categories_tree["a"],
+        categories_tree["e"],
+    ]
+
+
+def test_tree_getter_returns_category_subtree(request_mock, categories_tree):
+    assert get_categories_tree(request_mock, categories_tree["a"]) == [
+        categories_tree["b"]
+    ]
+
+
+def test_tree_getter_returns_empty_list_for_leaf_category(
+    request_mock, categories_tree
+):
+    assert get_categories_tree(request_mock, categories_tree["f"]) == []
+
+
+def test_path_getter_returns_path_to_category(request_mock, categories_tree):
+    for node in get_categories_tree(request_mock):
+        parent_nodes = len(get_category_path(node))
+        assert parent_nodes == node.level

+ 5 - 5
misago/categories/utils.py

@@ -4,9 +4,9 @@ from .models import Category
 
 
 
 
 def get_categories_tree(
 def get_categories_tree(
-    user, user_acl, parent=None, join_posters=False
+    request, parent=None, join_posters=False
 ):  # pylint: disable=too-many-branches
 ):  # pylint: disable=too-many-branches
-    if not user_acl["visible_categories"]:
+    if not request.user_acl["visible_categories"]:
         return []
         return []
 
 
     if parent:
     if parent:
@@ -14,7 +14,7 @@ def get_categories_tree(
     else:
     else:
         queryset = Category.objects.all_categories()
         queryset = Category.objects.all_categories()
 
 
-    queryset_with_acl = queryset.filter(id__in=user_acl["visible_categories"])
+    queryset_with_acl = queryset.filter(id__in=request.user_acl["visible_categories"])
     if join_posters:
     if join_posters:
         queryset_with_acl = queryset_with_acl.select_related("last_poster")
         queryset_with_acl = queryset_with_acl.select_related("last_poster")
 
 
@@ -33,8 +33,8 @@ def get_categories_tree(
         if category.parent_id and category.level > parent_level:
         if category.parent_id and category.level > parent_level:
             categories_dict[category.parent_id].subcategories.append(category)
             categories_dict[category.parent_id].subcategories.append(category)
 
 
-    add_acl_to_obj(user_acl, categories_list)
-    categoriestracker.make_read_aware(user, user_acl, categories_list)
+    add_acl_to_obj(request.user_acl, categories_list)
+    categoriestracker.make_read_aware(request, categories_list)
 
 
     for category in reversed(visible_categories):
     for category in reversed(visible_categories):
         if category.acl["can_browse"]:
         if category.acl["can_browse"]:

+ 1 - 3
misago/categories/views.py

@@ -6,9 +6,7 @@ from .utils import get_categories_tree
 
 
 
 
 def categories(request):
 def categories(request):
-    categories_tree = get_categories_tree(
-        request.user, request.user_acl, join_posters=True
-    )
+    categories_tree = get_categories_tree(request, join_posters=True)
 
 
     request.frontend_context.update(
     request.frontend_context.update(
         {
         {

+ 2 - 2
misago/conf/admin/__init__.py

@@ -53,7 +53,7 @@ class MisagoAdminExtension:
         site.add_node(
         site.add_node(
             name=_("Users"),
             name=_("Users"),
             description=_(
             description=_(
-                "Customize user accounts default behaviour and features availability."
+                "Customize user accounts default behavior and features availability."
             ),
             ),
             parent="settings",
             parent="settings",
             namespace="users",
             namespace="users",
@@ -77,7 +77,7 @@ class MisagoAdminExtension:
         )
         )
         site.add_node(
         site.add_node(
             name=_("Threads"),
             name=_("Threads"),
-            description=_("Those settings control threads and posts."),
+            description=_("Threads, posts, polls and attachments options."),
             parent="settings",
             parent="settings",
             namespace="threads",
             namespace="threads",
             after="analytics:index",
             after="analytics:index",

+ 25 - 0
misago/conf/admin/forms/captcha.py

@@ -1,6 +1,7 @@
 from django import forms
 from django import forms
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
+from ....admin.forms import YesNoSwitch
 from .base import ChangeSettingsForm
 from .base import ChangeSettingsForm
 
 
 
 
@@ -12,6 +13,8 @@ class ChangeCaptchaSettingsForm(ChangeSettingsForm):
         "qa_question",
         "qa_question",
         "qa_help_text",
         "qa_help_text",
         "qa_answers",
         "qa_answers",
+        "enable_stop_forum_spam",
+        "stop_forum_spam_confidence",
     ]
     ]
 
 
     captcha_type = forms.ChoiceField(
     captcha_type = forms.ChoiceField(
@@ -23,12 +26,14 @@ class ChangeCaptchaSettingsForm(ChangeSettingsForm):
         ],
         ],
         widget=forms.RadioSelect(),
         widget=forms.RadioSelect(),
     )
     )
+
     recaptcha_site_key = forms.CharField(
     recaptcha_site_key = forms.CharField(
         label=_("Site key"), max_length=100, required=False
         label=_("Site key"), max_length=100, required=False
     )
     )
     recaptcha_secret_key = forms.CharField(
     recaptcha_secret_key = forms.CharField(
         label=_("Secret key"), max_length=100, required=False
         label=_("Secret key"), max_length=100, required=False
     )
     )
+
     qa_question = forms.CharField(
     qa_question = forms.CharField(
         label=_("Test question"), max_length=100, required=False
         label=_("Test question"), max_length=100, required=False
     )
     )
@@ -43,6 +48,26 @@ class ChangeCaptchaSettingsForm(ChangeSettingsForm):
         required=False,
         required=False,
     )
     )
 
 
+    enable_stop_forum_spam = YesNoSwitch(
+        label=_("Validate new registrations against SFS database"),
+        help_text=_(
+            "Turning this option on will result in Misago validating new user's e-mail "
+            "and IP address against SFS database."
+        ),
+    )
+    stop_forum_spam_confidence = forms.IntegerField(
+        label=_("Minimum SFS confidence required"),
+        help_text=_(
+            "SFS compares user e-mail and IP address with database of known spammers "
+            "and assigns the confidence score in range of 0 to 100 that user is a "
+            "spammer themselves. If this score is equal or higher than specified, "
+            "Misago will block user from registering and ban their IP address "
+            "for 24 hours."
+        ),
+        min_value=0,
+        max_value=100,
+    )
+
     def clean(self):
     def clean(self):
         cleaned_data = super().clean()
         cleaned_data = super().clean()
 
 

+ 105 - 0
misago/conf/admin/forms/threads.py

@@ -6,12 +6,44 @@ from .base import ChangeSettingsForm
 
 
 class ChangeThreadsSettingsForm(ChangeSettingsForm):
 class ChangeThreadsSettingsForm(ChangeSettingsForm):
     settings = [
     settings = [
+        "attachment_403_image",
+        "attachment_404_image",
+        "daily_post_limit",
+        "hourly_post_limit",
+        "post_attachments_limit",
         "post_length_max",
         "post_length_max",
         "post_length_min",
         "post_length_min",
+        "readtracker_cutoff",
         "thread_title_length_max",
         "thread_title_length_max",
         "thread_title_length_min",
         "thread_title_length_min",
+        "unused_attachments_lifetime",
+        "threads_per_page",
+        "posts_per_page",
+        "posts_per_page_orphans",
+        "events_per_page",
     ]
     ]
 
 
+    daily_post_limit = forms.IntegerField(
+        label=_("Daily post limit per user"),
+        help_text=_(
+            "Daily limit of posts that may be posted by single user. "
+            "Fail-safe for situations when forum is flooded by spam bots. "
+            "Change to 0 to remove the limit."
+        ),
+        min_value=0,
+    )
+    hourly_post_limit = forms.IntegerField(
+        label=_("Hourly post limit per user"),
+        help_text=_(
+            "Hourly limit of posts that may be posted by single user. "
+            "Fail-safe for situations when forum is flooded by spam bots. "
+            "Change to 0 to remove the limit."
+        ),
+        min_value=0,
+    )
+    post_attachments_limit = forms.IntegerField(
+        label=_("Maximum number of attachments per post"), min_value=1
+    )
     post_length_max = forms.IntegerField(
     post_length_max = forms.IntegerField(
         label=_("Maximum allowed post length"), min_value=0
         label=_("Maximum allowed post length"), min_value=0
     )
     )
@@ -24,3 +56,76 @@ class ChangeThreadsSettingsForm(ChangeSettingsForm):
     thread_title_length_min = forms.IntegerField(
     thread_title_length_min = forms.IntegerField(
         label=_("Minimum required thread title length"), min_value=2, max_value=255
         label=_("Minimum required thread title length"), min_value=2, max_value=255
     )
     )
+    unused_attachments_lifetime = forms.IntegerField(
+        label=_("Unused attachments lifetime"),
+        help_text=_(
+            "Period of time (in hours) after which user-uploaded files that weren't "
+            "attached to any post are deleted from disk."
+        ),
+        min_value=1,
+    )
+
+    readtracker_cutoff = forms.IntegerField(
+        label=_("Read-tracker cutoff"),
+        help_text=_(
+            "Controls amount of data used by read-tracking system. All content older "
+            "than number of days specified in this setting is considered old and read, "
+            "even if the opposite is true for the user. Active forums can try lowering "
+            "this value while less active ones may wish to increase it instead. "
+        ),
+        min_value=1,
+    )
+
+    threads_per_page = forms.IntegerField(
+        label=_("Number of threads displayed on a single page"), min_value=10
+    )
+
+    posts_per_page = forms.IntegerField(
+        label=_("Number of posts displayed on a single page"), min_value=5
+    )
+    posts_per_page_orphans = forms.IntegerField(
+        label=_("Maximum orphans"),
+        help_text=_(
+            "If number of posts to be displayed on the last page is less or equal to "
+            "number specified in this setting, those posts will instead be displayed "
+            "on previous page, reducing the total number of pages in thread."
+        ),
+        min_value=0,
+    )
+    events_per_page = forms.IntegerField(
+        label=_("Maximum number of events displayed on a single page"), min_value=5
+    )
+
+    attachment_403_image = forms.ImageField(
+        label=_("Permission denied"),
+        help_text=_(
+            "Attachments proxy will display this image in place of default one "
+            "when user tries to access attachment they have no permission to see."
+        ),
+        required=False,
+    )
+    attachment_403_image_delete = forms.BooleanField(
+        label=_("Delete custom permission denied image"), required=False
+    )
+    attachment_404_image = forms.ImageField(
+        label=_("Not found"),
+        help_text=_(
+            "Attachments proxy will display this image in place of default one "
+            "when user tries to access attachment that doesn't exist."
+        ),
+        required=False,
+    )
+    attachment_404_image_delete = forms.BooleanField(
+        label=_("Delete custom not found image"), required=False
+    )
+
+    def clean(self):
+        cleaned_data = super().clean()
+        if cleaned_data.get("posts_per_page_orphans") > cleaned_data.get(
+            "posts_per_page"
+        ):
+            self.add_error(
+                "posts_per_page_orphans",
+                _("This value must be lower than number of posts per page."),
+            )
+        return cleaned_data

+ 88 - 1
misago/conf/admin/forms/users.py

@@ -2,6 +2,7 @@ from django import forms
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from ....admin.forms import YesNoSwitch
 from ....admin.forms import YesNoSwitch
+from ....users.validators import validate_username_content
 from ... import settings
 from ... import settings
 from .base import ChangeSettingsForm
 from .base import ChangeSettingsForm
 
 
@@ -19,6 +20,16 @@ class ChangeUsersSettingsForm(ChangeSettingsForm):
         "subscribe_start",
         "subscribe_start",
         "username_length_max",
         "username_length_max",
         "username_length_min",
         "username_length_min",
+        "anonymous_username",
+        "users_per_page",
+        "users_per_page_orphans",
+        "top_posters_ranking_length",
+        "top_posters_ranking_size",
+        "allow_data_downloads",
+        "data_downloads_expiration",
+        "allow_delete_own_account",
+        "new_inactive_accounts_delete",
+        "ip_storage_time",
     ]
     ]
 
 
     account_activation = forms.ChoiceField(
     account_activation = forms.ChoiceField(
@@ -31,6 +42,15 @@ class ChangeUsersSettingsForm(ChangeSettingsForm):
         ],
         ],
         widget=forms.RadioSelect(),
         widget=forms.RadioSelect(),
     )
     )
+    new_inactive_accounts_delete = forms.IntegerField(
+        label=_(
+            "Delete new inactive accounts if they weren't activated "
+            "within this number of days"
+        ),
+        help_text=_("Enter 0 to never delete inactive new accounts."),
+        min_value=0,
+    )
+
     username_length_min = forms.IntegerField(
     username_length_min = forms.IntegerField(
         label=_("Minimum allowed username length"), min_value=2, max_value=20
         label=_("Minimum allowed username length"), min_value=2, max_value=20
     )
     )
@@ -76,7 +96,7 @@ class ChangeUsersSettingsForm(ChangeSettingsForm):
         help_text=_(
         help_text=_(
             "Blank avatar is displayed in the interface when user's avatar is not "
             "Blank avatar is displayed in the interface when user's avatar is not "
             "available: when user was deleted or is guest. Uploaded image should be "
             "available: when user was deleted or is guest. Uploaded image should be "
-            "a square"
+            "a square."
         ),
         ),
         required=False,
         required=False,
     )
     )
@@ -113,6 +133,62 @@ class ChangeUsersSettingsForm(ChangeSettingsForm):
         widget=forms.RadioSelect(),
         widget=forms.RadioSelect(),
     )
     )
 
 
+    users_per_page = forms.IntegerField(
+        label=_("Number of users displayed on a single page"), min_value=4
+    )
+    users_per_page_orphans = forms.IntegerField(
+        label=_("Maximum orphans"),
+        help_text=_(
+            "If number of users to be displayed on the last page is less or equal to "
+            "number specified in this setting, those users will instead be displayed "
+            "on previous page, reducing the total number of pages on the list."
+        ),
+        min_value=0,
+    )
+
+    top_posters_ranking_length = forms.IntegerField(
+        label=_("Maximum age in days of posts that should count to the ranking"),
+        min_value=1,
+    )
+    top_posters_ranking_size = forms.IntegerField(
+        label=_("Maximum number of ranked users"), min_value=2
+    )
+
+    allow_data_downloads = YesNoSwitch(label=_("Allow users to download their data"))
+    data_downloads_expiration = forms.IntegerField(
+        label=_("Maximum age in hours of data downloads before they expire"),
+        help_text=_(
+            "Data downloads older than specified will have their files deleted and "
+            "will be marked as expired."
+        ),
+        min_value=1,
+    )
+
+    allow_delete_own_account = YesNoSwitch(
+        label=_("Allow users to delete their own accounts")
+    )
+
+    ip_storage_time = forms.IntegerField(
+        label=_("IP storage time"),
+        help_text=_(
+            "Number of days for which users IP addresses are stored in forum database. "
+            "Enter zero to store registered IP addresses forever. Deleting user "
+            "account always deletes the IP addresses associated with it."
+        ),
+        min_value=0,
+    )
+
+    anonymous_username = forms.CharField(
+        label=_("Anonymous username"),
+        help_text=_(
+            "This username is displayed instead of delete user's actual name "
+            "next to their content."
+        ),
+        min_length=1,
+        max_length=15,
+        validators=[validate_username_content],
+    )
+
     def clean_blank_avatar(self):
     def clean_blank_avatar(self):
         upload = self.cleaned_data.get("blank_avatar")
         upload = self.cleaned_data.get("blank_avatar")
         if not upload or upload == self.initial.get("blank_avatar"):
         if not upload or upload == self.initial.get("blank_avatar"):
@@ -129,3 +205,14 @@ class ChangeUsersSettingsForm(ChangeSettingsForm):
             )
             )
 
 
         return upload
         return upload
+
+    def clean(self):
+        cleaned_data = super().clean()
+        if cleaned_data.get("users_per_page_orphans") > cleaned_data.get(
+            "users_per_page"
+        ):
+            self.add_error(
+                "users_per_page_orphans",
+                _("This value must be lower than number of users per page."),
+            )
+        return cleaned_data

+ 98 - 0
misago/conf/admin/tests/test_blank_avatar_validation.py

@@ -0,0 +1,98 @@
+from io import BytesIO
+
+import pytest
+from PIL import Image
+
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.urls import reverse
+
+from ... import settings
+from ...models import Setting
+
+admin_link = reverse("misago:admin:settings:users:index")
+
+
+def create_image(width, height):
+    image = Image.new("RGBA", (width, height))
+    stream = BytesIO()
+    image.save(stream, "PNG")
+    stream.seek(0)
+    return SimpleUploadedFile("image.png", stream.read(), "image/jpeg")
+
+
+def submit_image(admin_client, image=""):
+    data = {
+        "account_activation": "user",
+        "username_length_min": 10,
+        "username_length_max": 10,
+        "anonymous_username": "Deleted",
+        "avatar_upload_limit": 2000,
+        "default_avatar": "gravatar",
+        "default_gravatar_fallback": "dynamic",
+        "signature_length_max": 100,
+        "blank_avatar": image,
+        "subscribe_start": "no",
+        "subscribe_reply": "no",
+        "users_per_page": 12,
+        "users_per_page_orphans": 4,
+        "top_posters_ranking_length": 10,
+        "top_posters_ranking_size": 10,
+        "allow_data_downloads": "no",
+        "data_downloads_expiration": 48,
+        "allow_delete_own_account": "no",
+        "new_inactive_accounts_delete": 0,
+        "ip_storage_time": 0,
+    }
+
+    return admin_client.post(admin_link, data)
+
+
+@pytest.fixture
+def setting(db):
+    return Setting.objects.get(setting="blank_avatar")
+
+
+@pytest.fixture
+def setting_with_value(admin_client, setting):
+    min_size = max(settings.MISAGO_AVATARS_SIZES)
+    image_file = create_image(min_size, min_size)
+    submit_image(admin_client, image_file)
+
+    setting.refresh_from_db()
+    return setting
+
+
+def test_uploaded_image_is_rejected_if_its_not_square(admin_client, setting):
+    image_file = create_image(100, 200)
+    submit_image(admin_client, image_file)
+
+    setting.refresh_from_db()
+    assert not setting.value
+
+
+def test_uploaded_image_is_rejected_if_its_smaller_than_max_avatar_size(
+    admin_client, setting
+):
+    min_size = max(settings.MISAGO_AVATARS_SIZES)
+    image_file = create_image(min_size - 1, min_size - 1)
+    submit_image(admin_client, image_file)
+
+    setting.refresh_from_db()
+    assert not setting.value
+
+
+def test_valid_blank_avatar_can_be_uploaded(admin_client, setting):
+    min_size = max(settings.MISAGO_AVATARS_SIZES)
+    image_file = create_image(min_size, min_size)
+    submit_image(admin_client, image_file)
+
+    setting.refresh_from_db()
+    assert setting.value
+
+
+def test_submitting_form_without_new_image_doesnt_unset_existing_image(
+    admin_client, setting_with_value
+):
+    submit_image(admin_client)
+    setting_with_value.refresh_from_db()
+    assert setting_with_value.value

+ 2 - 2
misago/conf/context_processors.py

@@ -52,8 +52,8 @@ def preload_settings_json(request):
         {
         {
             "BLANK_AVATAR_URL": request.settings.blank_avatar or BLANK_AVATAR_URL,
             "BLANK_AVATAR_URL": request.settings.blank_avatar or BLANK_AVATAR_URL,
             "CSRF_COOKIE_NAME": settings.CSRF_COOKIE_NAME,
             "CSRF_COOKIE_NAME": settings.CSRF_COOKIE_NAME,
-            "ENABLE_DELETE_OWN_ACCOUNT": settings.MISAGO_ENABLE_DELETE_OWN_ACCOUNT,
-            "ENABLE_DOWNLOAD_OWN_DATA": settings.MISAGO_ENABLE_DOWNLOAD_OWN_DATA,
+            "ENABLE_DELETE_OWN_ACCOUNT": request.settings.allow_delete_own_account,
+            "ENABLE_DOWNLOAD_OWN_DATA": request.settings.allow_data_downloads,
             "MISAGO_PATH": reverse("misago:index"),
             "MISAGO_PATH": reverse("misago:index"),
             "SETTINGS": preloaded_settings,
             "SETTINGS": preloaded_settings,
             "STATIC_URL": settings.STATIC_URL,
             "STATIC_URL": settings.STATIC_URL,

+ 2 - 119
misago/conf/defaults.py

@@ -25,44 +25,12 @@ MISAGO_ACL_EXTENSIONS = [
 ]
 ]
 
 
 
 
-# Anonymous name used to replace deleted user's name in places that are keeping it
-
-MISAGO_ANONYMOUS_USERNAME = "Ghost"
-
-
-# Allow users to download their personal data
-# Enables users to learn what data about them is being held by the site without having
-# to contact site's administrators.
-
-MISAGO_ENABLE_DOWNLOAD_OWN_DATA = True
-
-# Number of hours for which user data should be available for download.
-# When data download is marked as expired, data archive associated with it is deleted.
-
-MISAGO_USER_DATA_DOWNLOADS_EXPIRE_IN_HOURS = 48
-
 # Path to the directory that Misago should use to prepare user data downloads.
 # Path to the directory that Misago should use to prepare user data downloads.
 # Should not be accessible from internet.
 # Should not be accessible from internet.
 
 
 MISAGO_USER_DATA_DOWNLOADS_WORKING_DIR = None
 MISAGO_USER_DATA_DOWNLOADS_WORKING_DIR = None
 
 
 
 
-# Automatically delete new user accounts that weren't activated in specified time
-# If you rely on admin review of new registrations, make this period long, disable
-# the "deleteinactiveusers" management command, or change this value to zero. Otherwise
-# keep it short to give users a chance to retry on their own after few days pass.s
-
-MISAGO_DELETE_NEW_INACTIVE_USERS_OLDER_THAN_DAYS = 0
-
-
-# Allow users to delete their accounts
-# Lets users delete their own account on the site without having to contact site administrators.
-# This mechanism doesn't delete user posts, polls or attachments, but attempts to anonymize any
-# data about user left behind after user is deleted.
-
-MISAGO_ENABLE_DELETE_OWN_ACCOUNT = False
-
-
 # Custom markup extensions
 # Custom markup extensions
 
 
 MISAGO_MARKUP_EXTENSIONS = []
 MISAGO_MARKUP_EXTENSIONS = []
@@ -127,12 +95,6 @@ MISAGO_SEARCH_EXTENSIONS = [
 ]
 ]
 
 
 
 
-# Misago-admin specific date formats
-
-MISAGO_COMPACT_DATE_FORMAT_DAY_MONTH = "j M"
-MISAGO_COMPACT_DATE_FORMAT_DAY_MONTH_YEAR = "M 'y"
-
-
 # Additional registration validators
 # Additional registration validators
 # https://misago.readthedocs.io/en/latest/developers/validating_registrations.html
 # https://misago.readthedocs.io/en/latest/developers/validating_registrations.html
 
 
@@ -147,12 +109,6 @@ MISAGO_NEW_REGISTRATIONS_VALIDATORS = [
 MISAGO_PROFILE_FIELDS = []
 MISAGO_PROFILE_FIELDS = []
 
 
 
 
-# Stop Forum Spam settings
-
-MISAGO_USE_STOP_FORUM_SPAM = True
-MISAGO_STOP_FORUM_SPAM_MIN_CONFIDENCE = 80
-
-
 # Social Auth Backends Names Overrides
 # Social Auth Backends Names Overrides
 # This seeting may be used to customise auth backends names displayed in the UI
 # This seeting may be used to customise auth backends names displayed in the UI
 
 
@@ -186,19 +142,6 @@ MISAGO_ADMIN_SESSION_EXPIRATION = 60
 MISAGO_THREADS_ON_INDEX = True
 MISAGO_THREADS_ON_INDEX = True
 
 
 
 
-# Max age of notifications in days
-# Notifications older than this are deleted. On very active forums its better to keep this smaller.
-
-MISAGO_NOTIFICATIONS_MAX_AGE = 40
-
-
-# Fail-safe limits in case forum is raided by spambot
-# No user may exceed those limits, however you may disable them by changing them to 0.
-
-MISAGO_DIALY_POST_LIMIT = 600
-MISAGO_HOURLY_POST_LIMIT = 100
-
-
 # Function used for generating individual avatar for user
 # Function used for generating individual avatar for user
 
 
 MISAGO_DYNAMIC_AVATAR_DRAWER = "misago.users.avatars.dynamic.draw_default"
 MISAGO_DYNAMIC_AVATAR_DRAWER = "misago.users.avatars.dynamic.draw_default"
@@ -222,28 +165,6 @@ MISAGO_AVATARS_SIZES = [400, 200, 150, 100, 64, 50, 30]
 MISAGO_BLANK_AVATAR = "misago/img/blank-avatar.png"
 MISAGO_BLANK_AVATAR = "misago/img/blank-avatar.png"
 
 
 
 
-# Threads lists pagination settings
-
-MISAGO_THREADS_PER_PAGE = 25
-
-
-# Posts lists pagination settings
-
-MISAGO_POSTS_PER_PAGE = 18
-MISAGO_POSTS_TAIL = 6
-
-
-# Number of events displayed on single thread page
-# If there's more events than specified, oldest events will be trimmed
-
-MISAGO_EVENTS_PER_PAGE = 20
-
-
-# Number of attachments possible to assign to single post
-
-MISAGO_POST_ATTACHMENTS_LIMIT = 16
-
-
 # Max allowed size of image before Misago will generate thumbnail for it
 # Max allowed size of image before Misago will generate thumbnail for it
 
 
 MISAGO_ATTACHMENT_IMAGE_SIZE_LIMIT = (500, 500)
 MISAGO_ATTACHMENT_IMAGE_SIZE_LIMIT = (500, 500)
@@ -253,49 +174,11 @@ MISAGO_ATTACHMENT_IMAGE_SIZE_LIMIT = (500, 500)
 
 
 MISAGO_ATTACHMENT_SECRET_LENGTH = 64
 MISAGO_ATTACHMENT_SECRET_LENGTH = 64
 
 
-# How old (in minutes) should attachments unassociated with any be before they'll
-# automatically deleted by "clearattachments" task
-
-MISAGO_ATTACHMENT_ORPHANED_EXPIRE = 24 * 60
-
 
 
 # Names of files served when user requests file that doesn't exist or is unavailable
 # Names of files served when user requests file that doesn't exist or is unavailable
-# Those files will be sought within STATIC_ROOT directory
-
-MISAGO_404_IMAGE = "misago/img/error-404.png"
-MISAGO_403_IMAGE = "misago/img/error-403.png"
-
-
-# Controls max age in days of items that Misago has to process to make rankings
-# Used for active posters and most liked users lists
-# If your forum runs out of memory when trying to generate users rankings list
-# or you want those to be more dynamic, give this setting lower value
-# You don't have to be overzelous with this as user rankings are cached for 24h
-
-MISAGO_RANKING_LENGTH = 30
-
-# Controls max number of items displayed on ranked lists
-
-MISAGO_RANKING_SIZE = 50
-
-
-# Specifies the number of days that IP addresses are stored in the database before removing.
-# Change this setting to None to never remove old IP addresses.
-
-MISAGO_IP_STORE_TIME = None
-
-
-# Controls number of users displayed on single page
-
-MISAGO_USERS_PER_PAGE = 12
-
-
-# Controls amount of data used by readtracking system
-# Items older than number of days specified below are considered read
-# Depending on amount of new content being posted on your forum you may want
-# To decrease or increase this number to fine-tune readtracker performance
 
 
-MISAGO_READTRACKER_CUTOFF = 40
+MISAGO_ATTACHMENT_403_IMAGE = "misago/img/attachment-403.png"
+MISAGO_ATTACHMENT_404_IMAGE = "misago/img/attachment-404.png"
 
 
 
 
 # Available Moment.js locales
 # Available Moment.js locales

+ 23 - 0
misago/conf/migrations/0004_create_settings.py

@@ -14,10 +14,13 @@ default_settings = [
         "dry_value": 1536,
         "dry_value": 1536,
         "is_public": True,
         "is_public": True,
     },
     },
+    {"setting": "attachment_403_image", "python_type": "image"},
+    {"setting": "attachment_404_image", "python_type": "image"},
     {"setting": "blank_avatar", "python_type": "image"},
     {"setting": "blank_avatar", "python_type": "image"},
     {"setting": "captcha_type", "dry_value": "no", "is_public": True},
     {"setting": "captcha_type", "dry_value": "no", "is_public": True},
     {"setting": "default_avatar", "dry_value": "gravatar"},
     {"setting": "default_avatar", "dry_value": "gravatar"},
     {"setting": "default_gravatar_fallback", "dry_value": "dynamic"},
     {"setting": "default_gravatar_fallback", "dry_value": "dynamic"},
+    {"setting": "unused_attachments_lifetime", "python_type": "int", "dry_value": 24},
     {"setting": "email_footer"},
     {"setting": "email_footer"},
     {
     {
         "setting": "forum_address",
         "setting": "forum_address",
@@ -34,6 +37,9 @@ default_settings = [
     {"setting": "logo", "python_type": "image", "is_public": True},
     {"setting": "logo", "python_type": "image", "is_public": True},
     {"setting": "logo_small", "python_type": "image", "is_public": True},
     {"setting": "logo_small", "python_type": "image", "is_public": True},
     {"setting": "logo_text", "dry_value": "Misago", "is_public": True},
     {"setting": "logo_text", "dry_value": "Misago", "is_public": True},
+    {"setting": "daily_post_limit", "python_type": "int", "dry_value": 600},
+    {"setting": "hourly_post_limit", "python_type": "int", "dry_value": 100},
+    {"setting": "post_attachments_limit", "python_type": "int", "dry_value": 16},
     {
     {
         "setting": "post_length_max",
         "setting": "post_length_max",
         "python_type": "int",
         "python_type": "int",
@@ -46,6 +52,11 @@ default_settings = [
         "dry_value": 5,
         "dry_value": 5,
         "is_public": True,
         "is_public": True,
     },
     },
+    {"setting": "readtracker_cutoff", "python_type": "int", "dry_value": 40},
+    {"setting": "threads_per_page", "python_type": "int", "dry_value": 26},
+    {"setting": "posts_per_page", "python_type": "int", "dry_value": 18},
+    {"setting": "posts_per_page_orphans", "python_type": "int", "dry_value": 6},
+    {"setting": "events_per_page", "python_type": "int", "dry_value": 20},
     {"setting": "og_image", "python_type": "image"},
     {"setting": "og_image", "python_type": "image"},
     {
     {
         "setting": "og_image_avatar_on_profile",
         "setting": "og_image_avatar_on_profile",
@@ -80,6 +91,18 @@ default_settings = [
     },
     },
     {"setting": "username_length_min", "python_type": "int", "dry_value": 3},
     {"setting": "username_length_min", "python_type": "int", "dry_value": 3},
     {"setting": "username_length_max", "python_type": "int", "dry_value": 14},
     {"setting": "username_length_max", "python_type": "int", "dry_value": 14},
+    {"setting": "anonymous_username", "dry_value": "Deleted"},
+    {"setting": "enable_stop_forum_spam", "python_type": "bool", "dry_value": False},
+    {"setting": "stop_forum_spam_confidence", "python_type": "int", "dry_value": 80},
+    {"setting": "users_per_page", "python_type": "int", "dry_value": 12},
+    {"setting": "users_per_page_orphans", "python_type": "int", "dry_value": 4},
+    {"setting": "allow_data_downloads", "python_type": "bool", "dry_value": True},
+    {"setting": "data_downloads_expiration", "python_type": "int", "dry_value": 48},
+    {"setting": "allow_delete_own_account", "python_type": "bool", "dry_value": False},
+    {"setting": "top_posters_ranking_length", "python_type": "int", "dry_value": 30},
+    {"setting": "top_posters_ranking_size", "python_type": "int", "dry_value": 50},
+    {"setting": "new_inactive_accounts_delete", "python_type": "int", "dry_value": 0},
+    {"setting": "ip_storage_time", "python_type": "int", "dry_value": 90},
 ]
 ]
 
 
 removed_settings = ["forum_branding_display", "forum_branding_text"]
 removed_settings = ["forum_branding_display", "forum_branding_text"]

+ 7 - 0
misago/conf/shortcuts.py

@@ -0,0 +1,7 @@
+from ..cache.versions import get_cache_versions
+from .dynamicsettings import DynamicSettings
+
+
+def get_dynamic_settings():
+    cache_versions = get_cache_versions()
+    return DynamicSettings(cache_versions)

+ 64 - 0
misago/conftest.py

@@ -7,6 +7,7 @@ from .conf import SETTINGS_CACHE
 from .conf.dynamicsettings import DynamicSettings
 from .conf.dynamicsettings import DynamicSettings
 from .conf.staticsettings import StaticSettings
 from .conf.staticsettings import StaticSettings
 from .themes import THEME_CACHE
 from .themes import THEME_CACHE
+from .threads.test import post_thread
 from .users import BANS_CACHE
 from .users import BANS_CACHE
 from .users.models import AnonymousUser
 from .users.models import AnonymousUser
 from .users.test import create_test_superuser, create_test_user
 from .users.test import create_test_superuser, create_test_user
@@ -47,6 +48,11 @@ def anonymous_user():
 
 
 
 
 @pytest.fixture
 @pytest.fixture
+def anonymous_user_acl(anonymous_user, cache_versions):
+    return useracl.get_user_acl(anonymous_user, cache_versions)
+
+
+@pytest.fixture
 def user(db, user_password):
 def user(db, user_password):
     return create_test_user("User", "user@example.com", user_password)
     return create_test_user("User", "user@example.com", user_password)
 
 
@@ -108,6 +114,14 @@ def other_superuser(db, user_password):
 
 
 
 
 @pytest.fixture
 @pytest.fixture
+def user_client(mocker, client, user):
+    client.force_login(user)
+    session = client.session
+    session.save()
+    return client
+
+
+@pytest.fixture
 def admin_client(mocker, client, superuser):
 def admin_client(mocker, client, superuser):
     client.force_login(superuser)
     client.force_login(superuser)
     session = client.session
     session = client.session
@@ -133,3 +147,53 @@ def root_category(db):
 @pytest.fixture
 @pytest.fixture
 def default_category(db):
 def default_category(db):
     return Category.objects.get(slug="first-category")
     return Category.objects.get(slug="first-category")
+
+
+@pytest.fixture
+def thread(default_category):
+    return post_thread(default_category)
+
+
+@pytest.fixture
+def hidden_thread(default_category):
+    return post_thread(default_category, is_hidden=True)
+
+
+@pytest.fixture
+def unapproved_thread(default_category):
+    return post_thread(default_category, is_unapproved=True)
+
+
+@pytest.fixture
+def post(thread):
+    return thread.first_post
+
+
+@pytest.fixture
+def user_thread(default_category, user):
+    return post_thread(default_category, poster=user)
+
+
+@pytest.fixture
+def user_hidden_thread(default_category, user):
+    return post_thread(default_category, poster=user, is_hidden=True)
+
+
+@pytest.fixture
+def user_unapproved_thread(default_category, user):
+    return post_thread(default_category, poster=user, is_unapproved=True)
+
+
+@pytest.fixture
+def other_user_thread(default_category, other_user):
+    return post_thread(default_category, poster=other_user)
+
+
+@pytest.fixture
+def other_user_hidden_thread(default_category, other_user):
+    return post_thread(default_category, poster=other_user, is_hidden=True)
+
+
+@pytest.fixture
+def other_user_unapproved_thread(default_category, other_user):
+    return post_thread(default_category, poster=other_user, is_unapproved=True)

+ 1 - 1
misago/core/exceptionhandler.py

@@ -64,7 +64,7 @@ def handle_permission_denied_exception(request, exception):
 
 
 
 
 def handle_social_auth_exception(request, exception):
 def handle_social_auth_exception(request, exception):
-    social_logger.error(exception)
+    social_logger.error(exception, exc_info=exception)
     return errorpages.social_auth_failed(request, exception)
     return errorpages.social_auth_failed(request, exception)
 
 
 
 

+ 7 - 7
misago/core/tests/test_mail.py

@@ -22,6 +22,7 @@ class MailTests(TestCase):
                 user, "Misago Test Mail", "misago/emails/base", context={"settings": {}}
                 user, "Misago Test Mail", "misago/emails/base", context={"settings": {}}
             )
             )
 
 
+    @override_dynamic_settings(forum_address="http://test.com/")
     def test_mail_user(self):
     def test_mail_user(self):
         """mail_user sets message in backend"""
         """mail_user sets message in backend"""
         user = create_test_user("User", "user@example.com")
         user = create_test_user("User", "user@example.com")
@@ -29,13 +30,12 @@ class MailTests(TestCase):
         cache_versions = get_cache_versions()
         cache_versions = get_cache_versions()
         settings = DynamicSettings(cache_versions)
         settings = DynamicSettings(cache_versions)
 
 
-        with override_dynamic_settings(forum_address="http://test.com"):
-            mail_user(
-                user,
-                "Misago Test Mail",
-                "misago/emails/base",
-                context={"settings": settings},
-            )
+        mail_user(
+            user,
+            "Misago Test Mail",
+            "misago/emails/base",
+            context={"settings": settings},
+        )
 
 
         self.assertEqual(mail.outbox[0].subject, "Misago Test Mail")
         self.assertEqual(mail.outbox[0].subject, "Misago Test Mail")
 
 

+ 4 - 1
misago/legal/admin/__init__.py

@@ -32,5 +32,8 @@ class MisagoAdminExtension:
 
 
     def register_navigation_nodes(self, site):
     def register_navigation_nodes(self, site):
         site.add_node(
         site.add_node(
-            name=_("Legal agreements"), parent="settings", namespace="agreements"
+            name=_("Legal agreements"),
+            description=_("Set terms of service and privacy policy contents."),
+            parent="settings",
+            namespace="agreements",
         )
         )

+ 7 - 7
misago/readtracker/categoriestracker.py

@@ -1,9 +1,9 @@
 from ..threads.models import Post, Thread
 from ..threads.models import Post, Thread
 from ..threads.permissions import exclude_invisible_posts, exclude_invisible_threads
 from ..threads.permissions import exclude_invisible_posts, exclude_invisible_threads
-from .dates import get_cutoff_date
+from .cutoffdate import get_cutoff_date
 
 
 
 
-def make_read_aware(user, user_acl, categories):
+def make_read_aware(request, categories):
     if not categories:
     if not categories:
         return
         return
 
 
@@ -12,24 +12,24 @@ def make_read_aware(user, user_acl, categories):
 
 
     make_read(categories)
     make_read(categories)
 
 
-    if user.is_anonymous:
+    if request.user.is_anonymous:
         return
         return
 
 
     threads = Thread.objects.filter(category__in=categories)
     threads = Thread.objects.filter(category__in=categories)
-    threads = exclude_invisible_threads(user_acl, categories, threads)
+    threads = exclude_invisible_threads(request.user_acl, categories, threads)
 
 
     queryset = (
     queryset = (
         Post.objects.filter(
         Post.objects.filter(
             category__in=categories,
             category__in=categories,
             thread__in=threads,
             thread__in=threads,
-            posted_on__gt=get_cutoff_date(user),
+            posted_on__gt=get_cutoff_date(request.settings, request.user),
         )
         )
         .values_list("category", flat=True)
         .values_list("category", flat=True)
         .distinct()
         .distinct()
     )
     )
 
 
-    queryset = queryset.exclude(id__in=user.postread_set.values("post"))
-    queryset = exclude_invisible_posts(user_acl, categories, queryset)
+    queryset = queryset.exclude(id__in=request.user.postread_set.values("post"))
+    queryset = exclude_invisible_posts(request.user_acl, categories, queryset)
 
 
     unread_categories = list(queryset)
     unread_categories = list(queryset)
 
 

+ 13 - 0
misago/readtracker/cutoffdate.py

@@ -0,0 +1,13 @@
+from datetime import timedelta
+
+from django.utils import timezone
+
+from ..conf import settings
+
+
+def get_cutoff_date(settings, user=None):
+    cutoff_date = timezone.now() - timedelta(days=settings.readtracker_cutoff)
+
+    if user and user.is_authenticated and user.joined_on > cutoff_date:
+        return user.joined_on
+    return cutoff_date

+ 0 - 23
misago/readtracker/dates.py

@@ -1,23 +0,0 @@
-from datetime import timedelta
-
-from django.utils import timezone
-
-from ..conf import settings
-
-
-def get_cutoff_date(user=None):
-    cutoff_date = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
-
-    if user and user.is_authenticated and user.joined_on > cutoff_date:
-        return user.joined_on
-    return cutoff_date
-
-
-def is_date_tracked(date, user):
-    if not date:
-        return False
-
-    cutoff_date = get_cutoff_date()
-    if cutoff_date < user.joined_on:
-        cutoff_date = user.joined_on
-    return date > cutoff_date

+ 4 - 3
misago/readtracker/management/commands/clearreadtracker.py

@@ -1,6 +1,7 @@
 from django.core.management.base import BaseCommand
 from django.core.management.base import BaseCommand
 
 
-from ...dates import get_cutoff_date
+from ....conf.shortcuts import get_dynamic_settings
+from ...cutoffdate import get_cutoff_date
 from ...models import PostRead
 from ...models import PostRead
 
 
 
 
@@ -8,12 +9,12 @@ class Command(BaseCommand):
     help = "Deletes expired records from readtracker"
     help = "Deletes expired records from readtracker"
 
 
     def handle(self, *args, **options):
     def handle(self, *args, **options):
-        queryset = PostRead.objects.filter(last_read_on__lt=get_cutoff_date())
+        settings = get_dynamic_settings()
+        queryset = PostRead.objects.filter(last_read_on__lt=get_cutoff_date(settings))
         deleted_count = queryset.count()
         deleted_count = queryset.count()
 
 
         if deleted_count:
         if deleted_count:
             queryset.delete()
             queryset.delete()
-
             message = "\n\nDeleted %s expired entries" % deleted_count
             message = "\n\nDeleted %s expired entries" % deleted_count
         else:
         else:
             message = "\n\nNo expired entries were found"
             message = "\n\nNo expired entries were found"

+ 1 - 69
misago/readtracker/migrations/0003_migrate_reads_to_posts.py

@@ -1,77 +1,9 @@
 # Generated by Django 1.11.5 on 2017-10-07 14:49
 # Generated by Django 1.11.5 on 2017-10-07 14:49
-from datetime import timedelta
-
-from django.conf import settings
 from django.db import migrations
 from django.db import migrations
-from django.utils import timezone
-
-from ...conf import defaults
-
-try:
-    READS_CUTOFF = settings.MISAGO_READTRACKER_CUTOFF
-except AttributeError:
-    READS_CUTOFF = defaults.MISAGO_READTRACKER_CUTOFF
-
-
-def populate_posts_reads(apps, schema_editor):
-    reads_cutoff = timezone.now() - timedelta(days=READS_CUTOFF)
-
-    Post = apps.get_model("misago_threads", "Post")
-
-    CategoryRead = apps.get_model("misago_readtracker", "CategoryRead")
-    ThreadRead = apps.get_model("misago_readtracker", "ThreadRead")
-    PostRead = apps.get_model("misago_readtracker", "PostRead")
-
-    migrated_reads = {}
-
-    # read posts by category reads
-    queryset = CategoryRead.objects.select_related().iterator()
-    for category_read in queryset:
-        posts_queryset = Post.objects.filter(
-            category=category_read.category, posted_on__gte=reads_cutoff
-        )
-
-        for post in posts_queryset.iterator():
-            PostRead.objects.create(
-                user=category_read.user,
-                category=post.category,
-                thread=post.thread,
-                post=post,
-                last_read_on=post.posted_on,
-            )
-
-            if category_read.user.pk in migrated_reads:
-                migrated_reads[category_read.user.pk].append(post.pk)
-            else:
-                migrated_reads[category_read.user.pk] = [post.pk]
-
-    # read posts by thread reads
-    queryset = ThreadRead.objects.select_related().iterator()
-    for thread_read in queryset:
-        posts_queryset = Post.objects.filter(
-            thread=thread_read.thread, posted_on__gte=reads_cutoff
-        )
-
-        for post in posts_queryset.iterator():
-            if post.pk in migrated_reads.get(thread_read.user.pk, []):
-                continue
-
-            PostRead.objects.create(
-                user=thread_read.user,
-                category=post.category,
-                thread=post.thread,
-                post=post,
-                last_read_on=post.posted_on,
-            )
-
-
-def noop_reverse(apps, schema_editor):
-    PostRead = apps.get_model("misago_readtracker", "PostRead")
-    PostRead.objects.all().delete()
 
 
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [("misago_readtracker", "0002_postread")]
     dependencies = [("misago_readtracker", "0002_postread")]
 
 
-    operations = [migrations.RunPython(populate_posts_reads, noop_reverse)]
+    operations = []

+ 5 - 5
misago/readtracker/poststracker.py

@@ -1,7 +1,7 @@
-from .dates import get_cutoff_date
+from .cutoffdate import get_cutoff_date
 
 
 
 
-def make_read_aware(user, posts):
+def make_read_aware(request, posts):
     if not posts:
     if not posts:
         return
         return
 
 
@@ -10,10 +10,10 @@ def make_read_aware(user, posts):
 
 
     make_read(posts)
     make_read(posts)
 
 
-    if user.is_anonymous:
+    if request.user.is_anonymous:
         return
         return
 
 
-    cutoff_date = get_cutoff_date(user)
+    cutoff_date = get_cutoff_date(request.settings, request.user)
     unresolved_posts = {}
     unresolved_posts = {}
 
 
     for post in posts:
     for post in posts:
@@ -23,7 +23,7 @@ def make_read_aware(user, posts):
             unresolved_posts[post.pk] = post
             unresolved_posts[post.pk] = post
 
 
     if unresolved_posts:
     if unresolved_posts:
-        queryset = user.postread_set.filter(post__in=unresolved_posts)
+        queryset = request.user.postread_set.filter(post__in=unresolved_posts)
         for post_id in queryset.values_list("post_id", flat=True):
         for post_id in queryset.values_list("post_id", flat=True):
             unresolved_posts[post_id].is_read = True
             unresolved_posts[post_id].is_read = True
             unresolved_posts[post_id].is_new = False
             unresolved_posts[post_id].is_new = False

+ 23 - 0
misago/readtracker/tests/conftest.py

@@ -0,0 +1,23 @@
+from unittest.mock import Mock
+
+import pytest
+
+from ..poststracker import save_read
+
+
+@pytest.fixture
+def read_thread(user, thread):
+    save_read(user, thread.first_post)
+    return thread
+
+
+@pytest.fixture
+def anonymous_request_mock(dynamic_settings, anonymous_user, anonymous_user_acl):
+    return Mock(
+        settings=dynamic_settings, user=anonymous_user, user_acl=anonymous_user_acl
+    )
+
+
+@pytest.fixture
+def request_mock(dynamic_settings, user, user_acl):
+    return Mock(settings=dynamic_settings, user=user, user_acl=user_acl)

+ 161 - 172
misago/readtracker/tests/test_categoriestracker.py

@@ -1,194 +1,183 @@
 from datetime import timedelta
 from datetime import timedelta
 
 
-from django.test import TestCase
+import pytest
 from django.utils import timezone
 from django.utils import timezone
 
 
-from .. import categoriestracker, poststracker
-from ...acl.useracl import get_user_acl
-from ...categories.models import Category
-from ...conf import settings
-from ...conftest import get_cache_versions
-from ...threads import test
-from ...users.test import create_test_user
+from ...conf.test import override_dynamic_settings
+from ...threads.test import reply_thread
+from ..categoriestracker import make_read_aware
+from ..poststracker import save_read
 
 
-cache_versions = get_cache_versions()
 
 
+def remove_tracking(thread):
+    thread.started_on = timezone.now() - timedelta(days=4)
+    thread.save()
+    thread.first_post.posted_on = thread.started_on
+    thread.first_post.save()
 
 
-class AnonymousUser:
-    is_authenticated = False
-    is_anonymous = True
 
 
+@pytest.fixture
+def read_thread(user, thread):
+    save_read(user, thread.first_post)
+    return thread
 
 
-class CategoriesTrackerTests(TestCase):
-    def setUp(self):
-        self.user = create_test_user("User", "user@example.com")
-        self.user_acl = get_user_acl(self.user, cache_versions)
-        self.category = Category.objects.get(slug="first-category")
 
 
-    def test_falsy_value(self):
-        """passing falsy value to readtracker causes no errors"""
-        categoriestracker.make_read_aware(self.user, self.user_acl, None)
-        categoriestracker.make_read_aware(self.user, self.user_acl, False)
-        categoriestracker.make_read_aware(self.user, self.user_acl, [])
+def test_falsy_value_can_be_made_read_aware(request_mock):
+    make_read_aware(request_mock, None)
+    make_read_aware(request_mock, False)
 
 
-    def test_anon_thread_before_cutoff(self):
-        """non-tracked thread is marked as read for anonymous users"""
-        started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
-        test.post_thread(self.category, started_on=started_on)
 
 
-        categoriestracker.make_read_aware(AnonymousUser(), None, self.category)
-        self.assertTrue(self.category.is_read)
-        self.assertFalse(self.category.is_new)
+def test_empty_list_can_be_made_read_aware(request_mock):
+    make_read_aware(request_mock, [])
 
 
-    def test_anon_thread_after_cutoff(self):
-        """tracked thread is marked as read for anonymous users"""
-        test.post_thread(self.category, started_on=timezone.now())
 
 
-        categoriestracker.make_read_aware(AnonymousUser(), None, self.category)
-        self.assertTrue(self.category.is_read)
-        self.assertFalse(self.category.is_new)
+def test_empty_category_is_marked_as_read(request_mock, default_category):
+    make_read_aware(request_mock, default_category)
+    assert default_category.is_read
+    assert not default_category.is_new
 
 
-    def test_user_thread_before_cutoff(self):
-        """non-tracked thread is marked as read for authenticated users"""
-        started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
-        test.post_thread(self.category, started_on=started_on)
 
 
-        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
-        self.assertTrue(self.category.is_read)
-        self.assertFalse(self.category.is_new)
+def test_category_with_tracked_post_is_marked_as_read(
+    request_mock, post, default_category
+):
+    make_read_aware(request_mock, default_category)
+    assert not default_category.is_read
+    assert default_category.is_new
 
 
-    def test_user_unread_thread(self):
-        """tracked thread is marked as unread for authenticated users"""
-        test.post_thread(self.category, started_on=timezone.now())
 
 
-        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
-        self.assertFalse(self.category.is_read)
-        self.assertTrue(self.category.is_new)
+def test_category_with_post_older_than_user_is_marked_as_read(
+    request_mock, post, default_category
+):
+    post.posted_on = timezone.now() - timedelta(days=1)
+    post.save()
 
 
-    def test_user_created_after_thread(self):
-        """tracked thread older than user is marked as read"""
-        started_on = timezone.now() - timedelta(days=1)
-        test.post_thread(self.category, started_on=started_on)
+    make_read_aware(request_mock, default_category)
+    assert default_category.is_read
+    assert not default_category.is_new
 
 
-        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
-        self.assertTrue(self.category.is_read)
-        self.assertFalse(self.category.is_new)
 
 
-    def test_user_read_post(self):
-        """tracked thread with read post marked as read"""
-        thread = test.post_thread(self.category, started_on=timezone.now())
-
-        poststracker.save_read(self.user, thread.first_post)
-
-        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
-        self.assertTrue(self.category.is_read)
-        self.assertFalse(self.category.is_new)
-
-    def test_user_first_unread_last_read_post(self):
-        """tracked thread with unread first and last read post marked as unread"""
-        thread = test.post_thread(self.category, started_on=timezone.now())
-
-        post = test.reply_thread(thread, posted_on=timezone.now())
-        poststracker.save_read(self.user, post)
-
-        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
-        self.assertFalse(self.category.is_read)
-        self.assertTrue(self.category.is_new)
-
-    def test_user_first_read_post_unread_event(self):
-        """tracked thread with read first post and unread event"""
-        thread = test.post_thread(self.category, started_on=timezone.now())
-        poststracker.save_read(self.user, thread.first_post)
-
-        test.reply_thread(thread, posted_on=timezone.now(), is_event=True)
-
-        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
-        self.assertFalse(self.category.is_read)
-        self.assertTrue(self.category.is_new)
-
-    def test_user_hidden_event(self):
-        """tracked thread with unread first post and hidden event"""
-        thread = test.post_thread(self.category, started_on=timezone.now())
+@override_dynamic_settings(readtracker_cutoff=3)
+def test_category_with_post_older_than_cutoff_is_marked_as_read(
+    request_mock, user, post, default_category
+):
+    user.joined_on = timezone.now() - timedelta(days=5)
+    user.save()
 
 
-        test.reply_thread(
-            thread, posted_on=timezone.now(), is_event=True, is_hidden=True
-        )
-
-        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
-        self.assertFalse(self.category.is_read)
-        self.assertTrue(self.category.is_new)
-
-    def test_user_first_read_post_hidden_event(self):
-        """tracked thread with read first post and hidden event"""
-        thread = test.post_thread(self.category, started_on=timezone.now())
-        poststracker.save_read(self.user, thread.first_post)
-
-        test.reply_thread(
-            thread, posted_on=timezone.now(), is_event=True, is_hidden=True
-        )
-
-        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
-        self.assertTrue(self.category.is_read)
-        self.assertFalse(self.category.is_new)
-
-    def test_user_thread_before_cutoff_unread_post(self):
-        """non-tracked thread is marked as unread for anonymous users"""
-        started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
-        test.post_thread(self.category, started_on=started_on)
-
-        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
-        self.assertTrue(self.category.is_read)
-        self.assertFalse(self.category.is_new)
-
-    def test_user_first_read_post_unapproved_post(self):
-        """tracked thread with read first post and unapproved post"""
-        thread = test.post_thread(self.category, started_on=timezone.now())
-        poststracker.save_read(self.user, thread.first_post)
-
-        test.reply_thread(thread, posted_on=timezone.now(), is_unapproved=True)
-
-        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
-        self.assertTrue(self.category.is_read)
-        self.assertFalse(self.category.is_new)
-
-    def test_user_first_read_post_unapproved_own_post(self):
-        """tracked thread with read first post and unapproved own post"""
-        thread = test.post_thread(self.category, started_on=timezone.now())
-        poststracker.save_read(self.user, thread.first_post)
-
-        test.reply_thread(
-            thread, posted_on=timezone.now(), poster=self.user, is_unapproved=True
-        )
-
-        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
-        self.assertFalse(self.category.is_read)
-        self.assertTrue(self.category.is_new)
-
-    def test_user_unapproved_thread_unread_post(self):
-        """tracked unapproved thread"""
-        test.post_thread(self.category, started_on=timezone.now(), is_unapproved=True)
-
-        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
-        self.assertTrue(self.category.is_read)
-        self.assertFalse(self.category.is_new)
-
-    def test_user_unapproved_own_thread_unread_post(self):
-        """tracked unapproved but visible thread"""
-        test.post_thread(
-            self.category,
-            poster=self.user,
-            started_on=timezone.now(),
-            is_unapproved=True,
-        )
-
-        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
-        self.assertFalse(self.category.is_read)
-        self.assertTrue(self.category.is_new)
-
-    def test_user_hidden_thread_unread_post(self):
-        """tracked hidden thread"""
-        test.post_thread(self.category, started_on=timezone.now(), is_hidden=True)
-
-        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
-        self.assertTrue(self.category.is_read)
-        self.assertFalse(self.category.is_new)
+    post.posted_on = timezone.now() - timedelta(days=4)
+    post.save()
+
+    make_read_aware(request_mock, default_category)
+    assert default_category.is_read
+    assert not default_category.is_new
+
+
+def test_category_with_read_post_is_marked_as_read(
+    request_mock, user, post, default_category
+):
+    save_read(user, post)
+    make_read_aware(request_mock, default_category)
+    assert default_category.is_read
+    assert not default_category.is_new
+
+
+def test_category_with_post_in_thread_older_than_user_is_marked_as_unread(
+    request_mock, thread, default_category
+):
+    remove_tracking(thread)
+    reply_thread(thread)
+    make_read_aware(request_mock, default_category)
+    assert not default_category.is_read
+    assert default_category.is_new
+
+
+def test_category_with_post_in_invisible_thread_is_marked_as_read(
+    request_mock, hidden_thread, default_category
+):
+    reply_thread(hidden_thread)
+    make_read_aware(request_mock, default_category)
+    assert default_category.is_read
+    assert not default_category.is_new
+
+
+def test_category_with_read_post_in_thread_older_than_user_is_marked_as_read(
+    request_mock, user, thread, default_category
+):
+    remove_tracking(thread)
+    post = reply_thread(thread)
+    save_read(user, post)
+    make_read_aware(request_mock, default_category)
+    assert default_category.is_read
+    assert not default_category.is_new
+
+
+def test_category_with_read_post_in_read_thread_is_marked_as_read(
+    request_mock, user, read_thread, default_category
+):
+    post = reply_thread(read_thread)
+    save_read(user, post)
+    make_read_aware(request_mock, default_category)
+    assert default_category.is_read
+    assert not default_category.is_new
+
+
+def test_category_with_invisible_post_in_read_thread_is_marked_as_read(
+    request_mock, user, read_thread, default_category
+):
+    reply_thread(read_thread, is_unapproved=True)
+    make_read_aware(request_mock, default_category)
+    assert default_category.is_read
+    assert not default_category.is_new
+
+
+def test_category_with_new_event_in_read_thread_is_marked_as_unread(
+    request_mock, read_thread, default_category
+):
+    reply_thread(read_thread, is_event=True)
+    make_read_aware(request_mock, default_category)
+    assert not default_category.is_read
+    assert default_category.is_new
+
+
+def test_category_with_hidden_event_in_read_thread_is_marked_as_read(
+    request_mock, read_thread, default_category
+):
+    reply_thread(read_thread, is_hidden=True, is_event=True)
+    make_read_aware(request_mock, default_category)
+    assert default_category.is_read
+    assert not default_category.is_new
+
+
+def test_category_with_hidden_event_visible_to_user_in_read_thread_is_marked_as_unread(
+    request_mock, read_thread, default_category
+):
+    request_mock.user_acl["categories"][default_category.id]["can_hide_events"] = 1
+    reply_thread(read_thread, is_hidden=True, is_event=True)
+    make_read_aware(request_mock, default_category)
+    assert not default_category.is_read
+    assert default_category.is_new
+
+
+def test_empty_category_is_marked_as_read_for_anonymous_user(
+    anonymous_request_mock, default_category
+):
+    make_read_aware(anonymous_request_mock, default_category)
+    assert default_category.is_read
+    assert not default_category.is_new
+
+
+def test_category_with_tracked_thread_is_marked_as_read_for_anonymous_user(
+    anonymous_request_mock, thread, default_category
+):
+    make_read_aware(anonymous_request_mock, default_category)
+    assert default_category.is_read
+    assert not default_category.is_new
+
+
+@override_dynamic_settings(readtracker_cutoff=3)
+def test_category_with_non_tracked_thread_is_marked_as_read_for_anonymous_user(
+    anonymous_request_mock, thread, default_category
+):
+    remove_tracking(thread)
+    make_read_aware(anonymous_request_mock, default_category)
+    assert default_category.is_read
+    assert not default_category.is_new

+ 0 - 64
misago/readtracker/tests/test_clearreadtracker.py

@@ -1,64 +0,0 @@
-from datetime import timedelta
-from io import StringIO
-
-from django.core.management import call_command
-from django.test import TestCase
-from django.utils import timezone
-
-from ...categories.models import Category
-from ...conf import settings
-from ...threads import test
-from ...users.test import create_test_user
-from ..management.commands import clearreadtracker
-from ..models import PostRead
-
-
-class ClearReadTrackerTests(TestCase):
-    def setUp(self):
-        self.user = create_test_user("User", "user@example.com")
-        self.other_user = create_test_user("OtherUser", "otheruser@example.com")
-
-        self.category = Category.objects.get(slug="first-category")
-
-    def test_no_deleted(self):
-        """command works when there are no attachments"""
-        command = clearreadtracker.Command()
-
-        out = StringIO()
-        call_command(command, stdout=out)
-        command_output = out.getvalue().strip()
-
-        self.assertEqual(command_output, "No expired entries were found")
-
-    def test_delete_expired_entries(self):
-        """test deletes one expired tracker entry, but spares the other"""
-        thread = test.post_thread(self.category)
-
-        existing = PostRead.objects.create(
-            user=self.user,
-            category=self.category,
-            thread=thread,
-            post=thread.first_post,
-            last_read_on=timezone.now()
-            - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF / 4),
-        )
-        deleted = PostRead.objects.create(
-            user=self.other_user,
-            category=self.category,
-            thread=thread,
-            post=thread.first_post,
-            last_read_on=timezone.now()
-            - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF * 2),
-        )
-
-        command = clearreadtracker.Command()
-
-        out = StringIO()
-        call_command(command, stdout=out)
-        command_output = out.getvalue().strip()
-
-        self.assertEqual(command_output, "Deleted 1 expired entries")
-
-        PostRead.objects.get(pk=existing.pk)
-        with self.assertRaises(PostRead.DoesNotExist):
-            PostRead.objects.get(pk=deleted.pk)

+ 52 - 0
misago/readtracker/tests/test_clearreadtracker_command.py

@@ -0,0 +1,52 @@
+from datetime import timedelta
+from io import StringIO
+
+from django.core import management
+from django.utils import timezone
+
+from ...conf.test import override_dynamic_settings
+from ..management.commands import clearreadtracker
+from ..models import PostRead
+
+
+def call_command():
+    command = clearreadtracker.Command()
+
+    out = StringIO()
+    management.call_command(command, stdout=out)
+    return out.getvalue().strip().splitlines()[-1].strip()
+
+
+def test_command_works_if_there_are_no_read_tracker_entries(db):
+    command_output = call_command()
+    assert command_output == "No expired entries were found"
+
+
+@override_dynamic_settings(readtracker_cutoff=5)
+def test_recent_read_tracker_entry_is_not_cleared(user, post):
+    existing = PostRead.objects.create(
+        user=user,
+        category=post.category,
+        thread=post.thread,
+        post=post,
+        last_read_on=timezone.now(),
+    )
+
+    command_output = call_command()
+    assert command_output == "No expired entries were found"
+    assert PostRead.objects.exists()
+
+
+@override_dynamic_settings(readtracker_cutoff=5)
+def test_old_read_tracker_entry_is_cleared(user, post):
+    existing = PostRead.objects.create(
+        user=user,
+        category=post.category,
+        thread=post.thread,
+        post=post,
+        last_read_on=timezone.now() - timedelta(days=10),
+    )
+
+    command_output = call_command()
+    assert command_output == "Deleted 1 expired entries"
+    assert not PostRead.objects.exists()

+ 41 - 0
misago/readtracker/tests/test_cutoff_date.py

@@ -0,0 +1,41 @@
+from datetime import timedelta
+from unittest.mock import Mock
+
+from django.utils import timezone
+
+from ...conf.test import override_dynamic_settings
+from ..cutoffdate import get_cutoff_date
+
+
+def test_cutoff_date_for_no_user_is_calculated_from_setting(dynamic_settings):
+    cutoff_date = get_cutoff_date(dynamic_settings)
+    valid_cutoff_date = timezone.now() - timedelta(
+        days=dynamic_settings.readtracker_cutoff
+    )
+    assert cutoff_date < valid_cutoff_date
+
+
+def test_cutoff_date_for_recently_joined_user_is_their_join_date(dynamic_settings):
+    user = Mock(is_authenticated=True, joined_on=timezone.now())
+    cutoff_date = get_cutoff_date(dynamic_settings, user)
+    assert cutoff_date == user.joined_on
+
+
+@override_dynamic_settings(readtracker_cutoff=5)
+def test_cutoff_date_for_old_user_is_calculated_from_setting(dynamic_settings):
+    user = Mock(is_authenticated=True, joined_on=timezone.now() - timedelta(days=6))
+    cutoff_date = get_cutoff_date(dynamic_settings, user)
+    valid_cutoff_date = timezone.now() - timedelta(
+        days=dynamic_settings.readtracker_cutoff
+    )
+    assert cutoff_date < valid_cutoff_date
+    assert cutoff_date > user.joined_on
+
+
+def test_cutoff_date_for_anonymous_user_is_calculated_from_setting(dynamic_settings):
+    user = Mock(is_authenticated=False)
+    cutoff_date = get_cutoff_date(dynamic_settings, user)
+    valid_cutoff_date = timezone.now() - timedelta(
+        days=dynamic_settings.readtracker_cutoff
+    )
+    assert cutoff_date < valid_cutoff_date

+ 0 - 62
misago/readtracker/tests/test_dates.py

@@ -1,62 +0,0 @@
-from datetime import timedelta
-
-from django.test import TestCase
-from django.utils import timezone
-
-from ...conf import settings
-from ..dates import get_cutoff_date, is_date_tracked
-
-
-class MockUser:
-    is_authenticated = True
-
-    def __init__(self):
-        self.joined_on = timezone.now()
-
-
-class MockAnonymousUser:
-    is_authenticated = False
-
-
-class ReadTrackerDatesTests(TestCase):
-    def test_get_cutoff_date_no_user(self):
-        """get_cutoff_date utility works without user argument"""
-        valid_cutoff_date = timezone.now() - timedelta(
-            days=settings.MISAGO_READTRACKER_CUTOFF
-        )
-        returned_cutoff_date = get_cutoff_date()
-
-        self.assertTrue(returned_cutoff_date > valid_cutoff_date)
-
-    def test_get_cutoff_date_user(self):
-        """get_cutoff_date utility works with user argument"""
-        user = MockUser()
-
-        valid_cutoff_date = timezone.now() - timedelta(
-            days=settings.MISAGO_READTRACKER_CUTOFF
-        )
-        returned_cutoff_date = get_cutoff_date(user)
-
-        self.assertTrue(returned_cutoff_date > valid_cutoff_date)
-        self.assertEqual(returned_cutoff_date, user.joined_on)
-
-    def test_get_cutoff_date_anonymous_user(self):
-        """passing anonymous user to get_cutoff_date has no effect"""
-        user = MockAnonymousUser()
-
-        valid_cutoff_date = timezone.now() - timedelta(
-            days=settings.MISAGO_READTRACKER_CUTOFF
-        )
-        returned_cutoff_date = get_cutoff_date(user)
-
-        self.assertTrue(returned_cutoff_date > valid_cutoff_date)
-
-    def test_is_date_tracked(self):
-        """is_date_tracked validates dates"""
-        self.assertFalse(is_date_tracked(None, MockUser()))
-
-        past_date = timezone.now() - timedelta(minutes=10)
-        self.assertFalse(is_date_tracked(past_date, MockUser()))
-
-        future_date = timezone.now() + timedelta(minutes=10)
-        self.assertTrue(is_date_tracked(future_date, MockUser()))

+ 66 - 58
misago/readtracker/tests/test_poststracker.py

@@ -1,81 +1,89 @@
 from datetime import timedelta
 from datetime import timedelta
 
 
-from django.test import TestCase
+import pytest
 from django.utils import timezone
 from django.utils import timezone
 
 
-from ...categories.models import Category
-from ...conf import settings
-from ...threads import test
-from ...users.test import create_test_user
+from ...conf.test import override_dynamic_settings
 from ..poststracker import make_read_aware, save_read
 from ..poststracker import make_read_aware, save_read
 
 
 
 
-class AnonymousUser:
-    is_authenticated = False
-    is_anonymous = True
+def test_falsy_value_can_be_made_read_aware(request_mock):
+    make_read_aware(request_mock, None)
+    make_read_aware(request_mock, False)
 
 
 
 
-class PostsTrackerTests(TestCase):
-    def setUp(self):
-        self.user = create_test_user("User", "user@example.com")
-        self.category = Category.objects.get(slug="first-category")
-        self.thread = test.post_thread(self.category)
+def test_empty_list_can_be_made_read_aware(request_mock):
+    make_read_aware(request_mock, [])
 
 
-    def test_falsy_value(self):
-        """passing falsy value to readtracker causes no errors"""
-        make_read_aware(self.user, None)
-        make_read_aware(self.user, False)
-        make_read_aware(self.user, [])
 
 
-    def test_anon_post_before_cutoff(self):
-        """non-tracked post is marked as read for anonymous users"""
-        posted_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
-        post = test.reply_thread(self.thread, posted_on=posted_on)
+@pytest.fixture
+def read_post(user, post):
+    save_read(user, post)
+    return post
 
 
-        make_read_aware(AnonymousUser(), post)
-        self.assertTrue(post.is_read)
-        self.assertFalse(post.is_new)
 
 
-    def test_anon_post_after_cutoff(self):
-        """tracked post is marked as read for anonymous users"""
-        post = test.reply_thread(self.thread, posted_on=timezone.now())
+def test_tracked_post_is_marked_as_not_read_and_new(request_mock, post):
+    make_read_aware(request_mock, post)
+    assert not post.is_read
+    assert post.is_new
 
 
-        make_read_aware(AnonymousUser(), post)
-        self.assertTrue(post.is_read)
-        self.assertFalse(post.is_new)
 
 
-    def test_user_post_before_cutoff(self):
-        """untracked post is marked as read for authenticated users"""
-        posted_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
-        post = test.reply_thread(self.thread, posted_on=posted_on)
+@override_dynamic_settings(readtracker_cutoff=3)
+def test_not_tracked_post_is_marked_as_read_and_not_new(request_mock, post):
+    post.posted_on = timezone.now() - timedelta(days=4)
+    post.save()
 
 
-        make_read_aware(self.user, post)
-        self.assertTrue(post.is_read)
-        self.assertFalse(post.is_new)
+    make_read_aware(request_mock, post)
+    assert post.is_read
+    assert not post.is_new
 
 
-    def test_user_unread_post(self):
-        """tracked post is marked as unread for authenticated users"""
-        post = test.reply_thread(self.thread, posted_on=timezone.now())
 
 
-        make_read_aware(self.user, post)
-        self.assertFalse(post.is_read)
-        self.assertTrue(post.is_new)
+def test_tracked_read_post_is_marked_as_read_and_not_new(request_mock, read_post):
+    make_read_aware(request_mock, read_post)
+    assert read_post.is_read
+    assert not read_post.is_new
 
 
-    def test_user_created_after_post(self):
-        """tracked post older than user is marked as read"""
-        posted_on = timezone.now() - timedelta(days=1)
-        post = test.reply_thread(self.thread, posted_on=posted_on)
 
 
-        make_read_aware(self.user, post)
-        self.assertTrue(post.is_read)
-        self.assertFalse(post.is_new)
+@override_dynamic_settings(readtracker_cutoff=3)
+def test_not_tracked_read_post_is_marked_as_read_and_not_new(request_mock, read_post):
+    read_post.posted_on = timezone.now() - timedelta(days=4)
+    read_post.save()
 
 
-    def test_user_read_post(self):
-        """tracked post is marked as read for authenticated users with read entry"""
-        post = test.reply_thread(self.thread, posted_on=timezone.now())
+    make_read_aware(request_mock, read_post)
+    assert read_post.is_read
+    assert not read_post.is_new
 
 
-        save_read(self.user, post)
-        make_read_aware(self.user, post)
 
 
-        self.assertTrue(post.is_read)
-        self.assertFalse(post.is_new)
+def test_iterable_of_posts_can_be_made_read_aware(request_mock, post):
+    make_read_aware(request_mock, [post])
+    assert not post.is_read
+    assert post.is_new
+
+
+def test_tracked_post_read_by_other_user_is_marked_as_not_read_and_new(
+    request_mock, other_user, post
+):
+    save_read(other_user, post)
+    make_read_aware(request_mock, post)
+    assert not post.is_read
+    assert post.is_new
+
+
+def test_tracked_post_is_marked_as_read_and_not_new_for_anonymous_user(
+    anonymous_request_mock, post
+):
+    make_read_aware(anonymous_request_mock, post)
+    assert post.is_read
+    assert not post.is_new
+
+
+@override_dynamic_settings(readtracker_cutoff=3)
+def test_not_tracked_post_is_marked_as_read_and_not_new_for_anonymous_user(
+    anonymous_request_mock, post
+):
+    post.posted_on = timezone.now() - timedelta(days=4)
+    post.save()
+
+    make_read_aware(anonymous_request_mock, post)
+    assert post.is_read
+    assert not post.is_new

+ 87 - 131
misago/readtracker/tests/test_threadstracker.py

@@ -1,168 +1,124 @@
 from datetime import timedelta
 from datetime import timedelta
 
 
-from django.test import TestCase
 from django.utils import timezone
 from django.utils import timezone
 
 
-from .. import poststracker, threadstracker
-from ...acl.objectacl import add_acl_to_obj
-from ...acl.useracl import get_user_acl
-from ...categories.models import Category
-from ...conf import settings
-from ...conftest import get_cache_versions
-from ...threads import test
-from ...users.test import create_test_user
+from ...conf.test import override_dynamic_settings
+from ...threads.test import reply_thread
+from ..threadstracker import make_read_aware
 
 
-cache_versions = get_cache_versions()
 
 
+def remove_tracking(thread):
+    thread.started_on = timezone.now() - timedelta(days=4)
+    thread.save()
+    thread.first_post.posted_on = thread.started_on
+    thread.first_post.save()
 
 
-class AnonymousUser:
-    is_authenticated = False
-    is_anonymous = True
 
 
+def test_falsy_value_can_be_made_read_aware(request_mock):
+    make_read_aware(request_mock, None)
+    make_read_aware(request_mock, False)
 
 
-class ThreadsTrackerTests(TestCase):
-    def setUp(self):
-        self.user = create_test_user("User", "user@example.com")
-        self.user_acl = get_user_acl(self.user, cache_versions)
-        self.category = Category.objects.get(slug="first-category")
 
 
-        add_acl_to_obj(self.user_acl, self.category)
+def test_empty_list_can_be_made_read_aware(request_mock):
+    make_read_aware(request_mock, [])
 
 
-    def test_falsy_value(self):
-        """passing falsy value to readtracker causes no errors"""
-        threadstracker.make_read_aware(self.user, self.user_acl, None)
-        threadstracker.make_read_aware(self.user, self.user_acl, False)
-        threadstracker.make_read_aware(self.user, self.user_acl, [])
 
 
-    def test_anon_thread_before_cutoff(self):
-        """non-tracked thread is marked as read for anonymous users"""
-        started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
-        thread = test.post_thread(self.category, started_on=started_on)
+def test_read_thread_is_marked_as_read(request_mock, read_thread):
+    make_read_aware(request_mock, read_thread)
+    assert read_thread.is_read
+    assert not read_thread.is_new
 
 
-        threadstracker.make_read_aware(AnonymousUser(), None, thread)
-        self.assertTrue(thread.is_read)
-        self.assertFalse(thread.is_new)
 
 
-    def test_anon_thread_after_cutoff(self):
-        """tracked thread is marked as read for anonymous users"""
-        thread = test.post_thread(self.category, started_on=timezone.now())
+def test_read_thread_with_hidden_post_marked_as_unread(request_mock, read_thread):
+    reply_thread(read_thread, is_hidden=True)
+    make_read_aware(request_mock, read_thread)
+    assert not read_thread.is_read
+    assert read_thread.is_new
 
 
-        threadstracker.make_read_aware(AnonymousUser(), None, thread)
-        self.assertTrue(thread.is_read)
-        self.assertFalse(thread.is_new)
 
 
-    def test_user_thread_before_cutoff(self):
-        """non-tracked thread is marked as read for authenticated users"""
-        started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
-        thread = test.post_thread(self.category, started_on=started_on)
+def test_read_thread_with_invisible_post_marked_as_read(request_mock, read_thread):
+    reply_thread(read_thread, is_unapproved=True)
+    make_read_aware(request_mock, read_thread)
+    assert read_thread.is_read
+    assert not read_thread.is_new
 
 
-        threadstracker.make_read_aware(self.user, self.user_acl, thread)
-        self.assertTrue(thread.is_read)
-        self.assertFalse(thread.is_new)
 
 
-    def test_user_unread_thread(self):
-        """tracked thread is marked as unread for authenticated users"""
-        thread = test.post_thread(self.category, started_on=timezone.now())
+def test_read_thread_with_unread_post_marked_as_unread(request_mock, read_thread):
+    reply_thread(read_thread)
+    make_read_aware(request_mock, read_thread)
+    assert not read_thread.is_read
+    assert read_thread.is_new
 
 
-        threadstracker.make_read_aware(self.user, self.user_acl, thread)
-        self.assertFalse(thread.is_read)
-        self.assertTrue(thread.is_new)
 
 
-    def test_user_created_after_thread(self):
-        """tracked thread older than user is marked as read"""
-        started_on = timezone.now() - timedelta(days=1)
-        thread = test.post_thread(self.category, started_on=started_on)
+def test_untracked_thread_with_tracked_post_is_marked_as_unread(request_mock, thread):
+    remove_tracking(thread)
+    reply_thread(thread)
+    make_read_aware(request_mock, thread)
+    assert not thread.is_read
+    assert thread.is_new
 
 
-        threadstracker.make_read_aware(self.user, self.user_acl, thread)
-        self.assertTrue(thread.is_read)
-        self.assertFalse(thread.is_new)
 
 
-    def test_user_read_post(self):
-        """tracked thread with read post marked as read"""
-        thread = test.post_thread(self.category, started_on=timezone.now())
+def test_tracked_thread_is_marked_as_unread(request_mock, thread):
+    make_read_aware(request_mock, thread)
+    assert not thread.is_read
+    assert thread.is_new
 
 
-        poststracker.save_read(self.user, thread.first_post)
 
 
-        threadstracker.make_read_aware(self.user, self.user_acl, thread)
-        self.assertTrue(thread.is_read)
-        self.assertFalse(thread.is_new)
+def test_thread_with_post_older_than_user_is_marked_as_read(request_mock, thread, user):
+    remove_tracking(thread)
+    make_read_aware(request_mock, thread)
+    assert thread.is_read
+    assert not thread.is_new
 
 
-    def test_user_first_unread_last_read_post(self):
-        """tracked thread with unread first and last read post marked as unread"""
-        thread = test.post_thread(self.category, started_on=timezone.now())
 
 
-        post = test.reply_thread(thread, posted_on=timezone.now())
-        poststracker.save_read(self.user, post)
+@override_dynamic_settings(readtracker_cutoff=3)
+def test_non_tracked_thread_is_marked_as_read(request_mock, thread, user):
+    user.joined_on = timezone.now() - timedelta(days=10)
+    user.save()
 
 
-        threadstracker.make_read_aware(self.user, self.user_acl, thread)
-        self.assertFalse(thread.is_read)
-        self.assertTrue(thread.is_new)
+    remove_tracking(thread)
+    make_read_aware(request_mock, thread)
+    assert thread.is_read
+    assert not thread.is_new
 
 
-    def test_user_first_read_post_unread_event(self):
-        """tracked thread with read first post and unread event"""
-        thread = test.post_thread(self.category, started_on=timezone.now())
-        poststracker.save_read(self.user, thread.first_post)
 
 
-        test.reply_thread(thread, posted_on=timezone.now(), is_event=True)
+def test_read_thread_with_new_event_is_marked_as_unread(request_mock, read_thread):
+    reply_thread(read_thread, is_event=True)
+    make_read_aware(request_mock, read_thread)
+    assert not read_thread.is_read
+    assert read_thread.is_new
 
 
-        threadstracker.make_read_aware(self.user, self.user_acl, thread)
-        self.assertFalse(thread.is_read)
-        self.assertTrue(thread.is_new)
 
 
-    def test_user_hidden_event(self):
-        """tracked thread with unread first post and hidden event"""
-        thread = test.post_thread(self.category, started_on=timezone.now())
+def test_read_thread_with_hidden_event_is_marked_as_read(request_mock, read_thread):
+    reply_thread(read_thread, is_hidden=True, is_event=True)
+    make_read_aware(request_mock, read_thread)
+    assert read_thread.is_read
+    assert not read_thread.is_new
 
 
-        test.reply_thread(
-            thread, posted_on=timezone.now(), is_event=True, is_hidden=True
-        )
 
 
-        threadstracker.make_read_aware(self.user, self.user_acl, thread)
-        self.assertFalse(thread.is_read)
-        self.assertTrue(thread.is_new)
-
-    def test_user_first_read_post_hidden_event(self):
-        """tracked thread with read first post and hidden event"""
-        thread = test.post_thread(self.category, started_on=timezone.now())
-        poststracker.save_read(self.user, thread.first_post)
+def test_read_thread_with_hidden_event_visible_to_user_is_marked_as_unread(
+    request_mock, read_thread, default_category
+):
+    request_mock.user_acl["categories"][default_category.id]["can_hide_events"] = 1
+    reply_thread(read_thread, is_hidden=True, is_event=True)
+    make_read_aware(request_mock, read_thread)
+    assert not read_thread.is_read
+    assert read_thread.is_new
 
 
-        test.reply_thread(
-            thread, posted_on=timezone.now(), is_event=True, is_hidden=True
-        )
-
-        threadstracker.make_read_aware(self.user, self.user_acl, thread)
-        self.assertTrue(thread.is_read)
-        self.assertFalse(thread.is_new)
 
 
-    def test_user_thread_before_cutoff_unread_post(self):
-        """non-tracked thread is marked as unread for anonymous users"""
-        started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
-        thread = test.post_thread(self.category, started_on=started_on)
+def test_tracked_thread_is_marked_as_read_for_anonymous_user(
+    anonymous_request_mock, thread
+):
+    make_read_aware(anonymous_request_mock, thread)
+    assert thread.is_read
+    assert not thread.is_new
 
 
-        threadstracker.make_read_aware(self.user, self.user_acl, thread)
-        self.assertTrue(thread.is_read)
-        self.assertFalse(thread.is_new)
 
 
-    def test_user_first_read_post_unapproved_post(self):
-        """tracked thread with read first post and unapproved post"""
-        thread = test.post_thread(self.category, started_on=timezone.now())
-        poststracker.save_read(self.user, thread.first_post)
-
-        test.reply_thread(thread, posted_on=timezone.now(), is_unapproved=True)
-
-        threadstracker.make_read_aware(self.user, self.user_acl, thread)
-        self.assertTrue(thread.is_read)
-        self.assertFalse(thread.is_new)
-
-    def test_user_first_read_post_unapproved_own_post(self):
-        """tracked thread with read first post and unapproved own post"""
-        thread = test.post_thread(self.category, started_on=timezone.now())
-        poststracker.save_read(self.user, thread.first_post)
-
-        test.reply_thread(
-            thread, posted_on=timezone.now(), poster=self.user, is_unapproved=True
-        )
-
-        threadstracker.make_read_aware(self.user, self.user_acl, thread)
-        self.assertFalse(thread.is_read)
-        self.assertTrue(thread.is_new)
+@override_dynamic_settings(readtracker_cutoff=3)
+def test_non_tracked_thread_is_marked_as_read_for_anonymous_user(
+    anonymous_request_mock, thread
+):
+    remove_tracking(thread)
+    make_read_aware(anonymous_request_mock, thread)
+    assert thread.is_read
+    assert not thread.is_new

+ 7 - 6
misago/readtracker/threadstracker.py

@@ -1,9 +1,9 @@
 from ..threads.models import Post
 from ..threads.models import Post
 from ..threads.permissions import exclude_invisible_posts
 from ..threads.permissions import exclude_invisible_posts
-from .dates import get_cutoff_date
+from .cutoffdate import get_cutoff_date
 
 
 
 
-def make_read_aware(user, user_acl, threads):
+def make_read_aware(request, threads):
     if not threads:
     if not threads:
         return
         return
 
 
@@ -12,19 +12,20 @@ def make_read_aware(user, user_acl, threads):
 
 
     make_read(threads)
     make_read(threads)
 
 
-    if user.is_anonymous:
+    if request.user.is_anonymous:
         return
         return
 
 
     categories = [t.category for t in threads]
     categories = [t.category for t in threads]
+    cutoff_date = get_cutoff_date(request.settings, request.user)
 
 
     queryset = (
     queryset = (
-        Post.objects.filter(thread__in=threads, posted_on__gt=get_cutoff_date(user))
+        Post.objects.filter(thread__in=threads, posted_on__gt=cutoff_date)
         .values_list("thread", flat=True)
         .values_list("thread", flat=True)
         .distinct()
         .distinct()
     )
     )
 
 
-    queryset = queryset.exclude(id__in=user.postread_set.values("post"))
-    queryset = exclude_invisible_posts(user_acl, categories, queryset)
+    queryset = queryset.exclude(id__in=request.user.postread_set.values("post"))
+    queryset = exclude_invisible_posts(request.user_acl, categories, queryset)
 
 
     unread_threads = list(queryset)
     unread_threads = list(queryset)
 
 

+ 1 - 1
misago/static/misago/admin/index.css

@@ -1,2 +1,2 @@
-:root{--blue: #0052cc;--indigo: #6610f2;--purple: #6554c0;--pink: #e83e8c;--red: #ff5630;--orange: #ffab00;--yellow: #ffc107;--green: #36b37e;--teal: #20c997;--cyan: #00b8d9;--white: #fff;--gray: #a5adba;--gray-dark: #505f79;--primary: #6554c0;--secondary: #a5adba;--success: #36b37e;--info: #00b8d9;--warning: #ffc107;--danger: #ff5630;--light: #ebecf0;--dark: #505f79;--breakpoint-xs: 0;--breakpoint-sm: 576px;--breakpoint-md: 768px;--breakpoint-lg: 992px;--breakpoint-xl: 1200px;--font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace}*,*::before,*::after{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(9,30,66,0)}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#172b4d;text-align:left;background-color:#f4f5f7}[tabindex="-1"]:focus{outline:0 !important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[title],abbr[data-original-title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul,dl{margin-top:0;margin-bottom:1rem}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#6b778c;text-decoration:none;background-color:transparent}a:hover{color:#172b4d;text-decoration:underline}a:not([href]):not([tabindex]){color:inherit;text-decoration:none}a:not([href]):not([tabindex]):hover,a:not([href]):not([tabindex]):focus{color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus{outline:0}pre,code,kbd,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#a5adba;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}input,button,select,optgroup,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}select{word-wrap:normal}button,[type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button}button:not(:disabled),[type="button"]:not(:disabled),[type="reset"]:not(:disabled),[type="submit"]:not(:disabled){cursor:pointer}button::-moz-focus-inner,[type="button"]::-moz-focus-inner,[type="reset"]::-moz-focus-inner,[type="submit"]::-moz-focus-inner{padding:0;border-style:none}input[type="radio"],input[type="checkbox"]{box-sizing:border-box;padding:0}input[type="date"],input[type="time"],input[type="datetime-local"],input[type="month"]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type="number"]::-webkit-inner-spin-button,[type="number"]::-webkit-outer-spin-button{height:auto}[type="search"]{outline-offset:-2px;-webkit-appearance:none}[type="search"]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none !important}h1,h2,h3,h4,h5,h6,.h1,.h2,.h3,.h4,.h5,.h6{margin-bottom:.5rem;font-weight:500;line-height:1.2}h1,.h1{font-size:2.5rem}h2,.h2{font-size:2rem}h3,.h3{font-size:1.75rem}h4,.h4{font-size:1.5rem}h5,.h5{font-size:1.25rem}h6,.h6{font-size:1rem}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:6rem;font-weight:300;line-height:1.2}.display-2{font-size:5.5rem;font-weight:300;line-height:1.2}.display-3{font-size:4.5rem;font-weight:300;line-height:1.2}.display-4{font-size:3.5rem;font-weight:300;line-height:1.2}hr{margin-top:1rem;margin-bottom:1rem;border:0;border-top:1px solid rgba(9,30,66,0.1)}small,.small{font-size:80%;font-weight:400}mark,.mark{padding:.2em;background-color:#fcf8e3}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:90%;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote-footer{display:block;font-size:80%;color:#a5adba}.blockquote-footer::before{content:"\2014\00A0"}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#f4f5f7;border:1px solid #dfe1e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:90%;color:#a5adba}code{font-size:87.5%;color:#e83e8c;word-break:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:87.5%;color:#fff;background-color:#172b4d;border-radius:.2rem}kbd kbd{padding:0;font-size:100%;font-weight:700}pre{display:block;font-size:87.5%;color:#172b4d}pre code{font-size:inherit;color:inherit;word-break:normal}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width: 576px){.container{max-width:540px}}@media (min-width: 768px){.container{max-width:720px}}@media (min-width: 992px){.container{max-width:960px}}@media (min-width: 1200px){.container{max-width:1140px}}.container-fluid{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.no-gutters{margin-right:0;margin-left:0}.no-gutters>.col,.no-gutters>[class*="col-"]{padding-right:0;padding-left:0}.col-1,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-10,.col-11,.col-12,.col,.col-auto,.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm,.col-sm-auto,.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12,.col-md,.col-md-auto,.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg,.col-lg-auto,.col-xl-1,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl,.col-xl-auto{position:relative;width:100%;padding-right:15px;padding-left:15px}.col{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-1{-ms-flex:0 0 8.33333%;flex:0 0 8.33333%;max-width:8.33333%}.col-2{-ms-flex:0 0 16.66667%;flex:0 0 16.66667%;max-width:16.66667%}.col-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-4{-ms-flex:0 0 33.33333%;flex:0 0 33.33333%;max-width:33.33333%}.col-5{-ms-flex:0 0 41.66667%;flex:0 0 41.66667%;max-width:41.66667%}.col-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-7{-ms-flex:0 0 58.33333%;flex:0 0 58.33333%;max-width:58.33333%}.col-8{-ms-flex:0 0 66.66667%;flex:0 0 66.66667%;max-width:66.66667%}.col-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-10{-ms-flex:0 0 83.33333%;flex:0 0 83.33333%;max-width:83.33333%}.col-11{-ms-flex:0 0 91.66667%;flex:0 0 91.66667%;max-width:91.66667%}.col-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-first{-ms-flex-order:-1;order:-1}.order-last{-ms-flex-order:13;order:13}.order-0{-ms-flex-order:0;order:0}.order-1{-ms-flex-order:1;order:1}.order-2{-ms-flex-order:2;order:2}.order-3{-ms-flex-order:3;order:3}.order-4{-ms-flex-order:4;order:4}.order-5{-ms-flex-order:5;order:5}.order-6{-ms-flex-order:6;order:6}.order-7{-ms-flex-order:7;order:7}.order-8{-ms-flex-order:8;order:8}.order-9{-ms-flex-order:9;order:9}.order-10{-ms-flex-order:10;order:10}.order-11{-ms-flex-order:11;order:11}.order-12{-ms-flex-order:12;order:12}.offset-1{margin-left:8.33333%}.offset-2{margin-left:16.66667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333%}.offset-5{margin-left:41.66667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333%}.offset-8{margin-left:66.66667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333%}.offset-11{margin-left:91.66667%}@media (min-width: 576px){.col-sm{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-sm-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-sm-1{-ms-flex:0 0 8.33333%;flex:0 0 8.33333%;max-width:8.33333%}.col-sm-2{-ms-flex:0 0 16.66667%;flex:0 0 16.66667%;max-width:16.66667%}.col-sm-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-sm-4{-ms-flex:0 0 33.33333%;flex:0 0 33.33333%;max-width:33.33333%}.col-sm-5{-ms-flex:0 0 41.66667%;flex:0 0 41.66667%;max-width:41.66667%}.col-sm-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-sm-7{-ms-flex:0 0 58.33333%;flex:0 0 58.33333%;max-width:58.33333%}.col-sm-8{-ms-flex:0 0 66.66667%;flex:0 0 66.66667%;max-width:66.66667%}.col-sm-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-sm-10{-ms-flex:0 0 83.33333%;flex:0 0 83.33333%;max-width:83.33333%}.col-sm-11{-ms-flex:0 0 91.66667%;flex:0 0 91.66667%;max-width:91.66667%}.col-sm-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-sm-first{-ms-flex-order:-1;order:-1}.order-sm-last{-ms-flex-order:13;order:13}.order-sm-0{-ms-flex-order:0;order:0}.order-sm-1{-ms-flex-order:1;order:1}.order-sm-2{-ms-flex-order:2;order:2}.order-sm-3{-ms-flex-order:3;order:3}.order-sm-4{-ms-flex-order:4;order:4}.order-sm-5{-ms-flex-order:5;order:5}.order-sm-6{-ms-flex-order:6;order:6}.order-sm-7{-ms-flex-order:7;order:7}.order-sm-8{-ms-flex-order:8;order:8}.order-sm-9{-ms-flex-order:9;order:9}.order-sm-10{-ms-flex-order:10;order:10}.order-sm-11{-ms-flex-order:11;order:11}.order-sm-12{-ms-flex-order:12;order:12}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333%}.offset-sm-2{margin-left:16.66667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333%}.offset-sm-5{margin-left:41.66667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333%}.offset-sm-8{margin-left:66.66667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333%}.offset-sm-11{margin-left:91.66667%}}@media (min-width: 768px){.col-md{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-md-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-md-1{-ms-flex:0 0 8.33333%;flex:0 0 8.33333%;max-width:8.33333%}.col-md-2{-ms-flex:0 0 16.66667%;flex:0 0 16.66667%;max-width:16.66667%}.col-md-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-md-4{-ms-flex:0 0 33.33333%;flex:0 0 33.33333%;max-width:33.33333%}.col-md-5{-ms-flex:0 0 41.66667%;flex:0 0 41.66667%;max-width:41.66667%}.col-md-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-md-7{-ms-flex:0 0 58.33333%;flex:0 0 58.33333%;max-width:58.33333%}.col-md-8{-ms-flex:0 0 66.66667%;flex:0 0 66.66667%;max-width:66.66667%}.col-md-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-md-10{-ms-flex:0 0 83.33333%;flex:0 0 83.33333%;max-width:83.33333%}.col-md-11{-ms-flex:0 0 91.66667%;flex:0 0 91.66667%;max-width:91.66667%}.col-md-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-md-first{-ms-flex-order:-1;order:-1}.order-md-last{-ms-flex-order:13;order:13}.order-md-0{-ms-flex-order:0;order:0}.order-md-1{-ms-flex-order:1;order:1}.order-md-2{-ms-flex-order:2;order:2}.order-md-3{-ms-flex-order:3;order:3}.order-md-4{-ms-flex-order:4;order:4}.order-md-5{-ms-flex-order:5;order:5}.order-md-6{-ms-flex-order:6;order:6}.order-md-7{-ms-flex-order:7;order:7}.order-md-8{-ms-flex-order:8;order:8}.order-md-9{-ms-flex-order:9;order:9}.order-md-10{-ms-flex-order:10;order:10}.order-md-11{-ms-flex-order:11;order:11}.order-md-12{-ms-flex-order:12;order:12}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333%}.offset-md-2{margin-left:16.66667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333%}.offset-md-5{margin-left:41.66667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333%}.offset-md-8{margin-left:66.66667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333%}.offset-md-11{margin-left:91.66667%}}@media (min-width: 992px){.col-lg{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-lg-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-lg-1{-ms-flex:0 0 8.33333%;flex:0 0 8.33333%;max-width:8.33333%}.col-lg-2{-ms-flex:0 0 16.66667%;flex:0 0 16.66667%;max-width:16.66667%}.col-lg-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-lg-4{-ms-flex:0 0 33.33333%;flex:0 0 33.33333%;max-width:33.33333%}.col-lg-5{-ms-flex:0 0 41.66667%;flex:0 0 41.66667%;max-width:41.66667%}.col-lg-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-lg-7{-ms-flex:0 0 58.33333%;flex:0 0 58.33333%;max-width:58.33333%}.col-lg-8{-ms-flex:0 0 66.66667%;flex:0 0 66.66667%;max-width:66.66667%}.col-lg-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-lg-10{-ms-flex:0 0 83.33333%;flex:0 0 83.33333%;max-width:83.33333%}.col-lg-11{-ms-flex:0 0 91.66667%;flex:0 0 91.66667%;max-width:91.66667%}.col-lg-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-lg-first{-ms-flex-order:-1;order:-1}.order-lg-last{-ms-flex-order:13;order:13}.order-lg-0{-ms-flex-order:0;order:0}.order-lg-1{-ms-flex-order:1;order:1}.order-lg-2{-ms-flex-order:2;order:2}.order-lg-3{-ms-flex-order:3;order:3}.order-lg-4{-ms-flex-order:4;order:4}.order-lg-5{-ms-flex-order:5;order:5}.order-lg-6{-ms-flex-order:6;order:6}.order-lg-7{-ms-flex-order:7;order:7}.order-lg-8{-ms-flex-order:8;order:8}.order-lg-9{-ms-flex-order:9;order:9}.order-lg-10{-ms-flex-order:10;order:10}.order-lg-11{-ms-flex-order:11;order:11}.order-lg-12{-ms-flex-order:12;order:12}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333%}.offset-lg-2{margin-left:16.66667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333%}.offset-lg-5{margin-left:41.66667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333%}.offset-lg-8{margin-left:66.66667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333%}.offset-lg-11{margin-left:91.66667%}}@media (min-width: 1200px){.col-xl{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-xl-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-xl-1{-ms-flex:0 0 8.33333%;flex:0 0 8.33333%;max-width:8.33333%}.col-xl-2{-ms-flex:0 0 16.66667%;flex:0 0 16.66667%;max-width:16.66667%}.col-xl-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-xl-4{-ms-flex:0 0 33.33333%;flex:0 0 33.33333%;max-width:33.33333%}.col-xl-5{-ms-flex:0 0 41.66667%;flex:0 0 41.66667%;max-width:41.66667%}.col-xl-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-xl-7{-ms-flex:0 0 58.33333%;flex:0 0 58.33333%;max-width:58.33333%}.col-xl-8{-ms-flex:0 0 66.66667%;flex:0 0 66.66667%;max-width:66.66667%}.col-xl-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-xl-10{-ms-flex:0 0 83.33333%;flex:0 0 83.33333%;max-width:83.33333%}.col-xl-11{-ms-flex:0 0 91.66667%;flex:0 0 91.66667%;max-width:91.66667%}.col-xl-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-xl-first{-ms-flex-order:-1;order:-1}.order-xl-last{-ms-flex-order:13;order:13}.order-xl-0{-ms-flex-order:0;order:0}.order-xl-1{-ms-flex-order:1;order:1}.order-xl-2{-ms-flex-order:2;order:2}.order-xl-3{-ms-flex-order:3;order:3}.order-xl-4{-ms-flex-order:4;order:4}.order-xl-5{-ms-flex-order:5;order:5}.order-xl-6{-ms-flex-order:6;order:6}.order-xl-7{-ms-flex-order:7;order:7}.order-xl-8{-ms-flex-order:8;order:8}.order-xl-9{-ms-flex-order:9;order:9}.order-xl-10{-ms-flex-order:10;order:10}.order-xl-11{-ms-flex-order:11;order:11}.order-xl-12{-ms-flex-order:12;order:12}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333%}.offset-xl-2{margin-left:16.66667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333%}.offset-xl-5{margin-left:41.66667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333%}.offset-xl-8{margin-left:66.66667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333%}.offset-xl-11{margin-left:91.66667%}}.table{width:100%;margin-bottom:1rem;color:#172b4d}.table th,.table td{padding:.75rem;vertical-align:top;border-top:1px solid #dfe1e6}.table thead th{vertical-align:bottom;border-bottom:2px solid #dfe1e6}.table tbody+tbody{border-top:2px solid #dfe1e6}.table-sm th,.table-sm td{padding:.3rem}.table-bordered{border:1px solid #dfe1e6}.table-bordered th,.table-bordered td{border:1px solid #dfe1e6}.table-bordered thead th,.table-bordered thead td{border-bottom-width:2px}.table-borderless th,.table-borderless td,.table-borderless thead th,.table-borderless tbody+tbody{border:0}.table-striped tbody tr:nth-of-type(odd){background-color:rgba(9,30,66,0.05)}.table-hover tbody tr:hover{color:#172b4d;background-color:rgba(9,30,66,0.075)}.table-primary,.table-primary>th,.table-primary>td{background-color:#d4cfed}.table-primary th,.table-primary td,.table-primary thead th,.table-primary tbody+tbody{border-color:#afa6de}.table-hover .table-primary:hover{background-color:#c3bce6}.table-hover .table-primary:hover>td,.table-hover .table-primary:hover>th{background-color:#c3bce6}.table-secondary,.table-secondary>th,.table-secondary>td{background-color:#e6e8ec}.table-secondary th,.table-secondary td,.table-secondary thead th,.table-secondary tbody+tbody{border-color:#d0d4db}.table-hover .table-secondary:hover{background-color:#d8dbe1}.table-hover .table-secondary:hover>td,.table-hover .table-secondary:hover>th{background-color:#d8dbe1}.table-success,.table-success>th,.table-success>td{background-color:#c7eadb}.table-success th,.table-success td,.table-success thead th,.table-success tbody+tbody{border-color:#96d7bc}.table-hover .table-success:hover{background-color:#b4e3cf}.table-hover .table-success:hover>td,.table-hover .table-success:hover>th{background-color:#b4e3cf}.table-info,.table-info>th,.table-info>td{background-color:#b8ebf4}.table-info th,.table-info td,.table-info thead th,.table-info tbody+tbody{border-color:#7adaeb}.table-hover .table-info:hover{background-color:#a2e5f1}.table-hover .table-info:hover>td,.table-hover .table-info:hover>th{background-color:#a2e5f1}.table-warning,.table-warning>th,.table-warning>td{background-color:#ffeeba}.table-warning th,.table-warning td,.table-warning thead th,.table-warning tbody+tbody{border-color:#ffdf7e}.table-hover .table-warning:hover{background-color:#ffe8a1}.table-hover .table-warning:hover>td,.table-hover .table-warning:hover>th{background-color:#ffe8a1}.table-danger,.table-danger>th,.table-danger>td{background-color:#ffd0c5}.table-danger th,.table-danger td,.table-danger thead th,.table-danger tbody+tbody{border-color:#ffa793}.table-hover .table-danger:hover{background-color:#ffbbac}.table-hover .table-danger:hover>td,.table-hover .table-danger:hover>th{background-color:#ffbbac}.table-light,.table-light>th,.table-light>td{background-color:#f9fafb}.table-light th,.table-light td,.table-light thead th,.table-light tbody+tbody{border-color:#f5f5f7}.table-hover .table-light:hover{background-color:#eaedf1}.table-hover .table-light:hover>td,.table-hover .table-light:hover>th{background-color:#eaedf1}.table-dark,.table-dark>th,.table-dark>td{background-color:#ced2d9}.table-dark th,.table-dark td,.table-dark thead th,.table-dark tbody+tbody{border-color:#a4acb9}.table-hover .table-dark:hover{background-color:#c0c5ce}.table-hover .table-dark:hover>td,.table-hover .table-dark:hover>th{background-color:#c0c5ce}.table-active,.table-active>th,.table-active>td{background-color:rgba(9,30,66,0.075)}.table-hover .table-active:hover{background-color:rgba(6,20,44,0.075)}.table-hover .table-active:hover>td,.table-hover .table-active:hover>th{background-color:rgba(6,20,44,0.075)}.table .thead-dark th{color:#fff;background-color:#505f79;border-color:#5f7190}.table .thead-light th{color:#6b778c;background-color:rgba(0,0,0,0);border-color:#dfe1e6}.table-dark{color:#fff;background-color:#505f79}.table-dark th,.table-dark td,.table-dark thead th{border-color:#5f7190}.table-dark.table-bordered{border:0}.table-dark.table-striped tbody tr:nth-of-type(odd){background-color:rgba(255,255,255,0.05)}.table-dark.table-hover tbody tr:hover{color:#fff;background-color:rgba(255,255,255,0.075)}@media (max-width: 575.98px){.table-responsive-sm{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-sm>.table-bordered{border:0}}@media (max-width: 767.98px){.table-responsive-md{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-md>.table-bordered{border:0}}@media (max-width: 991.98px){.table-responsive-lg{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-lg>.table-bordered{border:0}}@media (max-width: 1199.98px){.table-responsive-xl{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-xl>.table-bordered{border:0}}.table-responsive{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive>.table-bordered{border:0}.form-control{display:block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#6b778c;background-color:#fff;background-clip:padding-box;border:1px solid #c1c7d0;border-radius:.25rem;transition:border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.form-control{transition:none}}.form-control::-ms-expand{background-color:transparent;border:0}.form-control:focus{color:#6b778c;background-color:#fff;border-color:#1851b2;outline:0;box-shadow:0 0 0 .2rem rgba(0,82,204,0.15)}.form-control::-webkit-input-placeholder{color:#a5adba;opacity:1}.form-control::-moz-placeholder{color:#a5adba;opacity:1}.form-control:-ms-input-placeholder{color:#a5adba;opacity:1}.form-control::-ms-input-placeholder{color:#a5adba;opacity:1}.form-control::placeholder{color:#a5adba;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#ebecf0;opacity:1}select.form-control:focus::-ms-value{color:#6b778c;background-color:#fff}.form-control-file,.form-control-range{display:block;width:100%}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem;line-height:1.5}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem;line-height:1.5}.form-control-plaintext{display:block;width:100%;padding-top:.375rem;padding-bottom:.375rem;margin-bottom:0;line-height:1.5;color:#172b4d;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-sm,.form-control-plaintext.form-control-lg{padding-right:0;padding-left:0}.form-control-sm{height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.form-control-lg{height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}select.form-control[size],select.form-control[multiple]{height:auto}textarea.form-control{height:auto}.form-group{margin-bottom:1rem}.form-text{display:block;margin-top:.25rem}.form-row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-5px;margin-left:-5px}.form-row>.col,.form-row>[class*="col-"]{padding-right:5px;padding-left:5px}.form-check{position:relative;display:block;padding-left:1.25rem}.form-check-input{position:absolute;margin-top:.3rem;margin-left:-1.25rem}.form-check-input:disabled ~ .form-check-label{color:#a5adba}.form-check-label{margin-bottom:0}.form-check-inline{display:-ms-inline-flexbox;display:inline-flex;-ms-flex-align:center;align-items:center;padding-left:0;margin-right:.75rem}.form-check-inline .form-check-input{position:static;margin-top:0;margin-right:.3125rem;margin-left:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#36b37e}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;line-height:1.5;color:#fff;background-color:rgba(54,179,126,0.9);border-radius:.25rem}.was-validated .form-control:valid,.form-control.is-valid{border-color:#36b37e;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2336b37e' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:center right calc(.375em + .1875rem);background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.was-validated .form-control:valid:focus,.form-control.is-valid:focus{border-color:#36b37e;box-shadow:0 0 0 .2rem rgba(54,179,126,0.25)}.was-validated .form-control:valid ~ .valid-feedback,.was-validated .form-control:valid ~ .valid-tooltip,.form-control.is-valid ~ .valid-feedback,.form-control.is-valid ~ .valid-tooltip{display:block}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.was-validated .custom-select:valid,.custom-select.is-valid{border-color:#36b37e;padding-right:calc((1em + .75rem) * 3 / 4 + 1.75rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23505f79' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2336b37e' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem)}.was-validated .custom-select:valid:focus,.custom-select.is-valid:focus{border-color:#36b37e;box-shadow:0 0 0 .2rem rgba(54,179,126,0.25)}.was-validated .custom-select:valid ~ .valid-feedback,.was-validated .custom-select:valid ~ .valid-tooltip,.custom-select.is-valid ~ .valid-feedback,.custom-select.is-valid ~ .valid-tooltip{display:block}.was-validated .form-control-file:valid ~ .valid-feedback,.was-validated .form-control-file:valid ~ .valid-tooltip,.form-control-file.is-valid ~ .valid-feedback,.form-control-file.is-valid ~ .valid-tooltip{display:block}.was-validated .form-check-input:valid ~ .form-check-label,.form-check-input.is-valid ~ .form-check-label{color:#36b37e}.was-validated .form-check-input:valid ~ .valid-feedback,.was-validated .form-check-input:valid ~ .valid-tooltip,.form-check-input.is-valid ~ .valid-feedback,.form-check-input.is-valid ~ .valid-tooltip{display:block}.was-validated .custom-control-input:valid ~ .custom-control-label,.custom-control-input.is-valid ~ .custom-control-label{color:#36b37e}.was-validated .custom-control-input:valid ~ .custom-control-label::before,.custom-control-input.is-valid ~ .custom-control-label::before{border-color:#36b37e}.was-validated .custom-control-input:valid ~ .valid-feedback,.was-validated .custom-control-input:valid ~ .valid-tooltip,.custom-control-input.is-valid ~ .valid-feedback,.custom-control-input.is-valid ~ .valid-tooltip{display:block}.was-validated .custom-control-input:valid:checked ~ .custom-control-label::before,.custom-control-input.is-valid:checked ~ .custom-control-label::before{border-color:#51cb97;background-color:#51cb97}.was-validated .custom-control-input:valid:focus ~ .custom-control-label::before,.custom-control-input.is-valid:focus ~ .custom-control-label::before{box-shadow:0 0 0 .2rem rgba(54,179,126,0.25)}.was-validated .custom-control-input:valid:focus:not(:checked) ~ .custom-control-label::before,.custom-control-input.is-valid:focus:not(:checked) ~ .custom-control-label::before{border-color:#36b37e}.was-validated .custom-file-input:valid ~ .custom-file-label,.custom-file-input.is-valid ~ .custom-file-label{border-color:#36b37e}.was-validated .custom-file-input:valid ~ .valid-feedback,.was-validated .custom-file-input:valid ~ .valid-tooltip,.custom-file-input.is-valid ~ .valid-feedback,.custom-file-input.is-valid ~ .valid-tooltip{display:block}.was-validated .custom-file-input:valid:focus ~ .custom-file-label,.custom-file-input.is-valid:focus ~ .custom-file-label{border-color:#36b37e;box-shadow:0 0 0 .2rem rgba(54,179,126,0.25)}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#ff5630}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;line-height:1.5;color:#fff;background-color:rgba(255,86,48,0.9);border-radius:.25rem}.was-validated .form-control:invalid,.form-control.is-invalid{border-color:#ff5630;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23ff5630' viewBox='-2 -2 7 7'%3e%3cpath stroke='%23ff5630' d='M0 0l3 3m0-3L0 3'/%3e%3ccircle r='.5'/%3e%3ccircle cx='3' r='.5'/%3e%3ccircle cy='3' r='.5'/%3e%3ccircle cx='3' cy='3' r='.5'/%3e%3c/svg%3E");background-repeat:no-repeat;background-position:center right calc(.375em + .1875rem);background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.was-validated .form-control:invalid:focus,.form-control.is-invalid:focus{border-color:#ff5630;box-shadow:0 0 0 .2rem rgba(255,86,48,0.25)}.was-validated .form-control:invalid ~ .invalid-feedback,.was-validated .form-control:invalid ~ .invalid-tooltip,.form-control.is-invalid ~ .invalid-feedback,.form-control.is-invalid ~ .invalid-tooltip{display:block}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.was-validated .custom-select:invalid,.custom-select.is-invalid{border-color:#ff5630;padding-right:calc((1em + .75rem) * 3 / 4 + 1.75rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23505f79' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23ff5630' viewBox='-2 -2 7 7'%3e%3cpath stroke='%23ff5630' d='M0 0l3 3m0-3L0 3'/%3e%3ccircle r='.5'/%3e%3ccircle cx='3' r='.5'/%3e%3ccircle cy='3' r='.5'/%3e%3ccircle cx='3' cy='3' r='.5'/%3e%3c/svg%3E") #fff no-repeat center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem)}.was-validated .custom-select:invalid:focus,.custom-select.is-invalid:focus{border-color:#ff5630;box-shadow:0 0 0 .2rem rgba(255,86,48,0.25)}.was-validated .custom-select:invalid ~ .invalid-feedback,.was-validated .custom-select:invalid ~ .invalid-tooltip,.custom-select.is-invalid ~ .invalid-feedback,.custom-select.is-invalid ~ .invalid-tooltip{display:block}.was-validated .form-control-file:invalid ~ .invalid-feedback,.was-validated .form-control-file:invalid ~ .invalid-tooltip,.form-control-file.is-invalid ~ .invalid-feedback,.form-control-file.is-invalid ~ .invalid-tooltip{display:block}.was-validated .form-check-input:invalid ~ .form-check-label,.form-check-input.is-invalid ~ .form-check-label{color:#ff5630}.was-validated .form-check-input:invalid ~ .invalid-feedback,.was-validated .form-check-input:invalid ~ .invalid-tooltip,.form-check-input.is-invalid ~ .invalid-feedback,.form-check-input.is-invalid ~ .invalid-tooltip{display:block}.was-validated .custom-control-input:invalid ~ .custom-control-label,.custom-control-input.is-invalid ~ .custom-control-label{color:#ff5630}.was-validated .custom-control-input:invalid ~ .custom-control-label::before,.custom-control-input.is-invalid ~ .custom-control-label::before{border-color:#ff5630}.was-validated .custom-control-input:invalid ~ .invalid-feedback,.was-validated .custom-control-input:invalid ~ .invalid-tooltip,.custom-control-input.is-invalid ~ .invalid-feedback,.custom-control-input.is-invalid ~ .invalid-tooltip{display:block}.was-validated .custom-control-input:invalid:checked ~ .custom-control-label::before,.custom-control-input.is-invalid:checked ~ .custom-control-label::before{border-color:#ff8063;background-color:#ff8063}.was-validated .custom-control-input:invalid:focus ~ .custom-control-label::before,.custom-control-input.is-invalid:focus ~ .custom-control-label::before{box-shadow:0 0 0 .2rem rgba(255,86,48,0.25)}.was-validated .custom-control-input:invalid:focus:not(:checked) ~ .custom-control-label::before,.custom-control-input.is-invalid:focus:not(:checked) ~ .custom-control-label::before{border-color:#ff5630}.was-validated .custom-file-input:invalid ~ .custom-file-label,.custom-file-input.is-invalid ~ .custom-file-label{border-color:#ff5630}.was-validated .custom-file-input:invalid ~ .invalid-feedback,.was-validated .custom-file-input:invalid ~ .invalid-tooltip,.custom-file-input.is-invalid ~ .invalid-feedback,.custom-file-input.is-invalid ~ .invalid-tooltip{display:block}.was-validated .custom-file-input:invalid:focus ~ .custom-file-label,.custom-file-input.is-invalid:focus ~ .custom-file-label{border-color:#ff5630;box-shadow:0 0 0 .2rem rgba(255,86,48,0.25)}.form-inline{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center}.form-inline .form-check{width:100%}@media (min-width: 576px){.form-inline label{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;margin-bottom:0}.form-inline .form-group{display:-ms-flexbox;display:flex;-ms-flex:0 0 auto;flex:0 0 auto;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center;margin-bottom:0}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-plaintext{display:inline-block}.form-inline .input-group,.form-inline .custom-select{width:auto}.form-inline .form-check{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:auto;padding-left:0}.form-inline .form-check-input{position:relative;-ms-flex-negative:0;flex-shrink:0;margin-top:0;margin-right:.25rem;margin-left:0}.form-inline .custom-control{-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center}.form-inline .custom-control-label{margin-bottom:0}}.btn{display:inline-block;font-weight:400;color:#172b4d;text-align:center;vertical-align:middle;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;line-height:1.5;border-radius:.25rem;transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.btn{transition:none}}.btn:hover{color:#172b4d;text-decoration:none}.btn:focus,.btn.focus{outline:0;box-shadow:0 0 0 .2rem rgba(0,82,204,0.15)}.btn.disabled,.btn:disabled{opacity:.65}a.btn.disabled,fieldset:disabled a.btn{pointer-events:none}.btn-primary{color:#fff;background-color:#6554c0;border-color:#6554c0}.btn-primary:hover{color:#fff;background-color:#5140ae;border-color:#4d3da4}.btn-primary:focus,.btn-primary.focus{box-shadow:0 0 0 .2rem rgba(124,110,201,0.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#6554c0;border-color:#6554c0}.btn-primary:not(:disabled):not(.disabled):active,.btn-primary:not(:disabled):not(.disabled).active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#4d3da4;border-color:#49399b}.btn-primary:not(:disabled):not(.disabled):active:focus,.btn-primary:not(:disabled):not(.disabled).active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(124,110,201,0.5)}.btn-secondary{color:#172b4d;background-color:#a5adba;border-color:#a5adba}.btn-secondary:hover{color:#172b4d;background-color:#8f99a9;border-color:#8893a4}.btn-secondary:focus,.btn-secondary.focus{box-shadow:0 0 0 .2rem rgba(144,154,170,0.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#172b4d;background-color:#a5adba;border-color:#a5adba}.btn-secondary:not(:disabled):not(.disabled):active,.btn-secondary:not(:disabled):not(.disabled).active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#8893a4;border-color:#818c9e}.btn-secondary:not(:disabled):not(.disabled):active:focus,.btn-secondary:not(:disabled):not(.disabled).active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(144,154,170,0.5)}.btn-success{color:#fff;background-color:#36b37e;border-color:#36b37e}.btn-success:hover{color:#fff;background-color:#2d9669;border-color:#2a8c62}.btn-success:focus,.btn-success.focus{box-shadow:0 0 0 .2rem rgba(84,190,145,0.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#36b37e;border-color:#36b37e}.btn-success:not(:disabled):not(.disabled):active,.btn-success:not(:disabled):not(.disabled).active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#2a8c62;border-color:#27825c}.btn-success:not(:disabled):not(.disabled):active:focus,.btn-success:not(:disabled):not(.disabled).active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(84,190,145,0.5)}.btn-info{color:#fff;background-color:#00b8d9;border-color:#00b8d9}.btn-info:hover{color:#fff;background-color:#0098b3;border-color:#008da6}.btn-info:focus,.btn-info.focus{box-shadow:0 0 0 .2rem rgba(38,195,223,0.5)}.btn-info.disabled,.btn-info:disabled{color:#fff;background-color:#00b8d9;border-color:#00b8d9}.btn-info:not(:disabled):not(.disabled):active,.btn-info:not(:disabled):not(.disabled).active,.show>.btn-info.dropdown-toggle{color:#fff;background-color:#008da6;border-color:#008299}.btn-info:not(:disabled):not(.disabled):active:focus,.btn-info:not(:disabled):not(.disabled).active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(38,195,223,0.5)}.btn-warning{color:#172b4d;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#172b4d;background-color:#e0a800;border-color:#d39e00}.btn-warning:focus,.btn-warning.focus{box-shadow:0 0 0 .2rem rgba(220,171,18,0.5)}.btn-warning.disabled,.btn-warning:disabled{color:#172b4d;background-color:#ffc107;border-color:#ffc107}.btn-warning:not(:disabled):not(.disabled):active,.btn-warning:not(:disabled):not(.disabled).active,.show>.btn-warning.dropdown-toggle{color:#172b4d;background-color:#d39e00;border-color:#c69500}.btn-warning:not(:disabled):not(.disabled):active:focus,.btn-warning:not(:disabled):not(.disabled).active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(220,171,18,0.5)}.btn-danger{color:#fff;background-color:#ff5630;border-color:#ff5630}.btn-danger:hover{color:#fff;background-color:#ff370a;border-color:#fc2e00}.btn-danger:focus,.btn-danger.focus{box-shadow:0 0 0 .2rem rgba(255,111,79,0.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#ff5630;border-color:#ff5630}.btn-danger:not(:disabled):not(.disabled):active,.btn-danger:not(:disabled):not(.disabled).active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#fc2e00;border-color:#ef2c00}.btn-danger:not(:disabled):not(.disabled):active:focus,.btn-danger:not(:disabled):not(.disabled).active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,111,79,0.5)}.btn-light,.navbar .btn-user,.card-admin-table .btn-thumbnail{color:#172b4d;background-color:#ebecf0;border-color:#ebecf0}.btn-light:hover,.navbar .btn-user:hover,.card-admin-table .btn-thumbnail:hover{color:#172b4d;background-color:#d5d7e0;border-color:#ced0da}.btn-light:focus,.navbar .btn-user:focus,.card-admin-table .btn-thumbnail:focus,.btn-light.focus,.navbar .focus.btn-user,.card-admin-table .focus.btn-thumbnail{box-shadow:0 0 0 .2rem rgba(203,207,216,0.5)}.btn-light.disabled,.navbar .disabled.btn-user,.card-admin-table .disabled.btn-thumbnail,.btn-light:disabled,.navbar .btn-user:disabled,.card-admin-table .btn-thumbnail:disabled{color:#172b4d;background-color:#ebecf0;border-color:#ebecf0}.btn-light:not(:disabled):not(.disabled):active,.navbar .btn-user:not(:disabled):not(.disabled):active,.card-admin-table .btn-thumbnail:not(:disabled):not(.disabled):active,.btn-light:not(:disabled):not(.disabled).active,.navbar .btn-user:not(:disabled):not(.disabled).active,.card-admin-table .btn-thumbnail:not(:disabled):not(.disabled).active,.show>.btn-light.dropdown-toggle,.navbar .show>.dropdown-toggle.btn-user,.card-admin-table .show>.dropdown-toggle.btn-thumbnail{color:#172b4d;background-color:#ced0da;border-color:#c7c9d5}.btn-light:not(:disabled):not(.disabled):active:focus,.navbar .btn-user:not(:disabled):not(.disabled):active:focus,.card-admin-table .btn-thumbnail:not(:disabled):not(.disabled):active:focus,.btn-light:not(:disabled):not(.disabled).active:focus,.navbar .btn-user:not(:disabled):not(.disabled).active:focus,.card-admin-table .btn-thumbnail:not(:disabled):not(.disabled).active:focus,.show>.btn-light.dropdown-toggle:focus,.navbar .show>.dropdown-toggle.btn-user:focus,.card-admin-table .show>.dropdown-toggle.btn-thumbnail:focus{box-shadow:0 0 0 .2rem rgba(203,207,216,0.5)}.btn-dark{color:#fff;background-color:#505f79;border-color:#505f79}.btn-dark:hover{color:#fff;background-color:#414d62;border-color:#3c475a}.btn-dark:focus,.btn-dark.focus{box-shadow:0 0 0 .2rem rgba(106,119,141,0.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#505f79;border-color:#505f79}.btn-dark:not(:disabled):not(.disabled):active,.btn-dark:not(:disabled):not(.disabled).active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#3c475a;border-color:#374153}.btn-dark:not(:disabled):not(.disabled):active:focus,.btn-dark:not(:disabled):not(.disabled).active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(106,119,141,0.5)}.btn-outline-primary{color:#6554c0;border-color:#6554c0}.btn-outline-primary:hover{color:#fff;background-color:#6554c0;border-color:#6554c0}.btn-outline-primary:focus,.btn-outline-primary.focus{box-shadow:0 0 0 .2rem rgba(101,84,192,0.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#6554c0;background-color:transparent}.btn-outline-primary:not(:disabled):not(.disabled):active,.btn-outline-primary:not(:disabled):not(.disabled).active,.show>.btn-outline-primary.dropdown-toggle{color:#fff;background-color:#6554c0;border-color:#6554c0}.btn-outline-primary:not(:disabled):not(.disabled):active:focus,.btn-outline-primary:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(101,84,192,0.5)}.btn-outline-secondary{color:#a5adba;border-color:#a5adba}.btn-outline-secondary:hover{color:#172b4d;background-color:#a5adba;border-color:#a5adba}.btn-outline-secondary:focus,.btn-outline-secondary.focus{box-shadow:0 0 0 .2rem rgba(165,173,186,0.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#a5adba;background-color:transparent}.btn-outline-secondary:not(:disabled):not(.disabled):active,.btn-outline-secondary:not(:disabled):not(.disabled).active,.show>.btn-outline-secondary.dropdown-toggle{color:#172b4d;background-color:#a5adba;border-color:#a5adba}.btn-outline-secondary:not(:disabled):not(.disabled):active:focus,.btn-outline-secondary:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(165,173,186,0.5)}.btn-outline-success{color:#36b37e;border-color:#36b37e}.btn-outline-success:hover{color:#fff;background-color:#36b37e;border-color:#36b37e}.btn-outline-success:focus,.btn-outline-success.focus{box-shadow:0 0 0 .2rem rgba(54,179,126,0.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#36b37e;background-color:transparent}.btn-outline-success:not(:disabled):not(.disabled):active,.btn-outline-success:not(:disabled):not(.disabled).active,.show>.btn-outline-success.dropdown-toggle{color:#fff;background-color:#36b37e;border-color:#36b37e}.btn-outline-success:not(:disabled):not(.disabled):active:focus,.btn-outline-success:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(54,179,126,0.5)}.btn-outline-info{color:#00b8d9;border-color:#00b8d9}.btn-outline-info:hover{color:#fff;background-color:#00b8d9;border-color:#00b8d9}.btn-outline-info:focus,.btn-outline-info.focus{box-shadow:0 0 0 .2rem rgba(0,184,217,0.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#00b8d9;background-color:transparent}.btn-outline-info:not(:disabled):not(.disabled):active,.btn-outline-info:not(:disabled):not(.disabled).active,.show>.btn-outline-info.dropdown-toggle{color:#fff;background-color:#00b8d9;border-color:#00b8d9}.btn-outline-info:not(:disabled):not(.disabled):active:focus,.btn-outline-info:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(0,184,217,0.5)}.btn-outline-warning{color:#ffc107;border-color:#ffc107}.btn-outline-warning:hover{color:#172b4d;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning:focus,.btn-outline-warning.focus{box-shadow:0 0 0 .2rem rgba(255,193,7,0.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-warning:not(:disabled):not(.disabled):active,.btn-outline-warning:not(:disabled):not(.disabled).active,.show>.btn-outline-warning.dropdown-toggle{color:#172b4d;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning:not(:disabled):not(.disabled):active:focus,.btn-outline-warning:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,0.5)}.btn-outline-danger{color:#ff5630;border-color:#ff5630}.btn-outline-danger:hover{color:#fff;background-color:#ff5630;border-color:#ff5630}.btn-outline-danger:focus,.btn-outline-danger.focus{box-shadow:0 0 0 .2rem rgba(255,86,48,0.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#ff5630;background-color:transparent}.btn-outline-danger:not(:disabled):not(.disabled):active,.btn-outline-danger:not(:disabled):not(.disabled).active,.show>.btn-outline-danger.dropdown-toggle{color:#fff;background-color:#ff5630;border-color:#ff5630}.btn-outline-danger:not(:disabled):not(.disabled):active:focus,.btn-outline-danger:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,86,48,0.5)}.btn-outline-light{color:#ebecf0;border-color:#ebecf0}.btn-outline-light:hover{color:#172b4d;background-color:#ebecf0;border-color:#ebecf0}.btn-outline-light:focus,.btn-outline-light.focus{box-shadow:0 0 0 .2rem rgba(235,236,240,0.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#ebecf0;background-color:transparent}.btn-outline-light:not(:disabled):not(.disabled):active,.btn-outline-light:not(:disabled):not(.disabled).active,.show>.btn-outline-light.dropdown-toggle{color:#172b4d;background-color:#ebecf0;border-color:#ebecf0}.btn-outline-light:not(:disabled):not(.disabled):active:focus,.btn-outline-light:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(235,236,240,0.5)}.btn-outline-dark{color:#505f79;border-color:#505f79}.btn-outline-dark:hover{color:#fff;background-color:#505f79;border-color:#505f79}.btn-outline-dark:focus,.btn-outline-dark.focus{box-shadow:0 0 0 .2rem rgba(80,95,121,0.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#505f79;background-color:transparent}.btn-outline-dark:not(:disabled):not(.disabled):active,.btn-outline-dark:not(:disabled):not(.disabled).active,.show>.btn-outline-dark.dropdown-toggle{color:#fff;background-color:#505f79;border-color:#505f79}.btn-outline-dark:not(:disabled):not(.disabled):active:focus,.btn-outline-dark:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(80,95,121,0.5)}.btn-link{font-weight:400;color:#6b778c;text-decoration:none}.btn-link:hover{color:#172b4d;text-decoration:underline}.btn-link:focus,.btn-link.focus{text-decoration:underline;box-shadow:none}.btn-link:disabled,.btn-link.disabled{color:#a5adba;pointer-events:none}.btn-lg,.btn-group-lg>.btn{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.btn-sm,.btn-group-sm>.btn{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.btn-block,.card-admin-table .btn-thumbnail{display:block;width:100%}.btn-block+.btn-block,.card-admin-table .btn-thumbnail+.btn-block,.card-admin-table .btn-block+.btn-thumbnail,.card-admin-table .btn-thumbnail+.btn-thumbnail{margin-top:.5rem}input[type="submit"].btn-block,.card-admin-table input.btn-thumbnail[type="submit"],input[type="reset"].btn-block,.card-admin-table input.btn-thumbnail[type="reset"],input[type="button"].btn-block,.card-admin-table input.btn-thumbnail[type="button"]{width:100%}.fade{transition:opacity 0.15s linear}@media (prefers-reduced-motion: reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{position:relative;height:0;overflow:hidden;transition:height 0.35s ease}@media (prefers-reduced-motion: reduce){.collapsing{transition:none}}.dropup,.dropright,.dropdown,.dropleft{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:10rem;padding:.5rem 0;margin:.125rem 0 0;font-size:1rem;color:#172b4d;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(9,30,66,0.15);border-radius:.25rem}.dropdown-menu-left{right:auto;left:0}.dropdown-menu-right{right:0;left:auto}@media (min-width: 576px){.dropdown-menu-sm-left{right:auto;left:0}.dropdown-menu-sm-right{right:0;left:auto}}@media (min-width: 768px){.dropdown-menu-md-left{right:auto;left:0}.dropdown-menu-md-right{right:0;left:auto}}@media (min-width: 992px){.dropdown-menu-lg-left{right:auto;left:0}.dropdown-menu-lg-right{right:0;left:auto}}@media (min-width: 1200px){.dropdown-menu-xl-left{right:auto;left:0}.dropdown-menu-xl-right{right:0;left:auto}}.dropup .dropdown-menu{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropright .dropdown-menu{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropright .dropdown-toggle::after{vertical-align:0}.dropleft .dropdown-menu{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropleft .dropdown-toggle::before{vertical-align:0}.dropdown-menu[x-placement^="top"],.dropdown-menu[x-placement^="right"],.dropdown-menu[x-placement^="bottom"],.dropdown-menu[x-placement^="left"]{right:auto;bottom:auto}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid #ebecf0}.dropdown-item{display:block;width:100%;padding:.25rem 1.5rem;clear:both;font-weight:400;color:#172b4d;text-align:inherit;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:hover,.dropdown-item:focus{color:#112039;text-decoration:none;background-color:#f4f5f7}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#091e42}.dropdown-item.disabled,.dropdown-item:disabled{color:#a5adba;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1.5rem;margin-bottom:0;font-size:.875rem;color:#a5adba;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1.5rem;color:#172b4d}.btn-group,.btn-group-vertical{position:relative;display:-ms-inline-flexbox;display:inline-flex;vertical-align:middle}.btn-group>.btn,.btn-group-vertical>.btn{position:relative;-ms-flex:1 1 auto;flex:1 1 auto}.btn-group>.btn:hover,.btn-group-vertical>.btn:hover{z-index:1}.btn-group>.btn:focus,.btn-group>.btn:active,.btn-group>.btn.active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn.active{z-index:1}.btn-toolbar{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-pack:start;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn:not(:first-child),.btn-group>.btn-group:not(:first-child){margin-left:-1px}.btn-group>.btn:not(:last-child):not(.dropdown-toggle),.btn-group>.btn-group:not(:last-child)>.btn{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:not(:first-child),.btn-group>.btn-group:not(:first-child)>.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after,.dropright .dropdown-toggle-split::after{margin-left:0}.dropleft .dropdown-toggle-split::before{margin-right:0}.btn-sm+.dropdown-toggle-split,.btn-group-sm>.btn+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-lg+.dropdown-toggle-split,.btn-group-lg>.btn+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{-ms-flex-direction:column;flex-direction:column;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:center;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn:not(:first-child),.btn-group-vertical>.btn-group:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle),.btn-group-vertical>.btn-group:not(:last-child)>.btn{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:not(:first-child),.btn-group-vertical>.btn-group:not(:first-child)>.btn{border-top-left-radius:0;border-top-right-radius:0}.btn-group-toggle>.btn,.btn-group-toggle>.btn-group>.btn{margin-bottom:0}.btn-group-toggle>.btn input[type="radio"],.btn-group-toggle>.btn input[type="checkbox"],.btn-group-toggle>.btn-group>.btn input[type="radio"],.btn-group-toggle>.btn-group>.btn input[type="checkbox"]{position:absolute;clip:rect(0, 0, 0, 0);pointer-events:none}.input-group{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:stretch;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-control-plaintext,.input-group>.custom-select,.input-group>.custom-file{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;width:1%;margin-bottom:0}.input-group>.form-control+.form-control,.input-group>.form-control+.custom-select,.input-group>.form-control+.custom-file,.input-group>.form-control-plaintext+.form-control,.input-group>.form-control-plaintext+.custom-select,.input-group>.form-control-plaintext+.custom-file,.input-group>.custom-select+.form-control,.input-group>.custom-select+.custom-select,.input-group>.custom-select+.custom-file,.input-group>.custom-file+.form-control,.input-group>.custom-file+.custom-select,.input-group>.custom-file+.custom-file{margin-left:-1px}.input-group>.form-control:focus,.input-group>.custom-select:focus,.input-group>.custom-file .custom-file-input:focus ~ .custom-file-label{z-index:3}.input-group>.custom-file .custom-file-input:focus{z-index:4}.input-group>.form-control:not(:last-child),.input-group>.custom-select:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.form-control:not(:first-child),.input-group>.custom-select:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.custom-file{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center}.input-group>.custom-file:not(:last-child) .custom-file-label,.input-group>.custom-file:not(:last-child) .custom-file-label::after{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-file:not(:first-child) .custom-file-label{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-prepend,.input-group-append{display:-ms-flexbox;display:flex}.input-group-prepend .btn,.input-group-append .btn{position:relative;z-index:2}.input-group-prepend .btn:focus,.input-group-append .btn:focus{z-index:3}.input-group-prepend .btn+.btn,.input-group-prepend .btn+.input-group-text,.input-group-prepend .input-group-text+.input-group-text,.input-group-prepend .input-group-text+.btn,.input-group-append .btn+.btn,.input-group-append .btn+.input-group-text,.input-group-append .input-group-text+.input-group-text,.input-group-append .input-group-text+.btn{margin-left:-1px}.input-group-prepend{margin-right:-1px}.input-group-append{margin-left:-1px}.input-group-text{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding:.375rem .75rem;margin-bottom:0;font-size:1rem;font-weight:400;line-height:1.5;color:#6b778c;text-align:center;white-space:nowrap;background-color:#ebecf0;border:1px solid #c1c7d0;border-radius:.25rem}.input-group-text input[type="radio"],.input-group-text input[type="checkbox"]{margin-top:0}.input-group-lg>.form-control:not(textarea),.input-group-lg>.custom-select{height:calc(1.5em + 1rem + 2px)}.input-group-lg>.form-control,.input-group-lg>.custom-select,.input-group-lg>.input-group-prepend>.input-group-text,.input-group-lg>.input-group-append>.input-group-text,.input-group-lg>.input-group-prepend>.btn,.input-group-lg>.input-group-append>.btn{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.input-group-sm>.form-control:not(textarea),.input-group-sm>.custom-select{height:calc(1.5em + .5rem + 2px)}.input-group-sm>.form-control,.input-group-sm>.custom-select,.input-group-sm>.input-group-prepend>.input-group-text,.input-group-sm>.input-group-append>.input-group-text,.input-group-sm>.input-group-prepend>.btn,.input-group-sm>.input-group-append>.btn{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.input-group-lg>.custom-select,.input-group-sm>.custom-select{padding-right:1.75rem}.input-group>.input-group-prepend>.btn,.input-group>.input-group-prepend>.input-group-text,.input-group>.input-group-append:not(:last-child)>.btn,.input-group>.input-group-append:not(:last-child)>.input-group-text,.input-group>.input-group-append:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group>.input-group-append:last-child>.input-group-text:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.input-group-append>.btn,.input-group>.input-group-append>.input-group-text,.input-group>.input-group-prepend:not(:first-child)>.btn,.input-group>.input-group-prepend:not(:first-child)>.input-group-text,.input-group>.input-group-prepend:first-child>.btn:not(:first-child),.input-group>.input-group-prepend:first-child>.input-group-text:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.custom-control{position:relative;display:block;min-height:1.5rem;padding-left:1.5rem}.custom-control-inline{display:-ms-inline-flexbox;display:inline-flex;margin-right:1rem}.custom-control-input{position:absolute;z-index:-1;opacity:0}.custom-control-input:checked ~ .custom-control-label::before{color:#fff;border-color:#091e42;background-color:#091e42}.custom-control-input:focus ~ .custom-control-label::before{box-shadow:0 0 0 .2rem rgba(0,82,204,0.15)}.custom-control-input:focus:not(:checked) ~ .custom-control-label::before{border-color:#1851b2}.custom-control-input:not(:disabled):active ~ .custom-control-label::before{color:#fff;background-color:#1e65df;border-color:#1e65df}.custom-control-input:disabled ~ .custom-control-label{color:#a5adba}.custom-control-input:disabled ~ .custom-control-label::before{background-color:#ebecf0}.custom-control-label{position:relative;margin-bottom:0;vertical-align:top}.custom-control-label::before{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;pointer-events:none;content:"";background-color:#fff;border:#b3bac5 solid 1px}.custom-control-label::after{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;content:"";background:no-repeat 50% / 50% 50%}.custom-checkbox .custom-control-label::before{border-radius:.25rem}.custom-checkbox .custom-control-input:checked ~ .custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::before{border-color:#091e42;background-color:#091e42}.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:disabled:checked ~ .custom-control-label::before{background-color:rgba(101,84,192,0.5)}.custom-checkbox .custom-control-input:disabled:indeterminate ~ .custom-control-label::before{background-color:rgba(101,84,192,0.5)}.custom-radio .custom-control-label::before{border-radius:50%}.custom-radio .custom-control-input:checked ~ .custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.custom-radio .custom-control-input:disabled:checked ~ .custom-control-label::before{background-color:rgba(101,84,192,0.5)}.custom-switch{padding-left:2.25rem}.custom-switch .custom-control-label::before{left:-2.25rem;width:1.75rem;pointer-events:all;border-radius:.5rem}.custom-switch .custom-control-label::after{top:calc(.25rem + 2px);left:calc(-2.25rem + 2px);width:calc(1rem - 4px);height:calc(1rem - 4px);background-color:#b3bac5;border-radius:.5rem;transition:background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,-webkit-transform 0.15s ease-in-out;transition:transform 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out;transition:transform 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,-webkit-transform 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.custom-switch .custom-control-label::after{transition:none}}.custom-switch .custom-control-input:checked ~ .custom-control-label::after{background-color:#fff;-webkit-transform:translateX(.75rem);transform:translateX(.75rem)}.custom-switch .custom-control-input:disabled:checked ~ .custom-control-label::before{background-color:rgba(101,84,192,0.5)}.custom-select{display:inline-block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem 1.75rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#6b778c;vertical-align:middle;background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23505f79' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px;background-color:#fff;border:1px solid #c1c7d0;border-radius:.25rem;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-select:focus{border-color:#1851b2;outline:0;box-shadow:0 0 0 .2rem rgba(0,82,204,0.15)}.custom-select:focus::-ms-value{color:#6b778c;background-color:#fff}.custom-select[multiple],.custom-select[size]:not([size="1"]){height:auto;padding-right:.75rem;background-image:none}.custom-select:disabled{color:#a5adba;background-color:#ebecf0}.custom-select::-ms-expand{display:none}.custom-select-sm{height:calc(1.5em + .5rem + 2px);padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem}.custom-select-lg{height:calc(1.5em + 1rem + 2px);padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem}.custom-file{position:relative;display:inline-block;width:100%;height:calc(1.5em + .75rem + 2px);margin-bottom:0}.custom-file-input{position:relative;z-index:2;width:100%;height:calc(1.5em + .75rem + 2px);margin:0;opacity:0}.custom-file-input:focus ~ .custom-file-label{border-color:#1851b2;box-shadow:0 0 0 .2rem rgba(0,82,204,0.15)}.custom-file-input:disabled ~ .custom-file-label{background-color:#ebecf0}.custom-file-input:lang(en) ~ .custom-file-label::after{content:"Browse"}.custom-file-input ~ .custom-file-label[data-browse]::after{content:attr(data-browse)}.custom-file-label{position:absolute;top:0;right:0;left:0;z-index:1;height:calc(1.5em + .75rem + 2px);padding:.375rem .75rem;font-weight:400;line-height:1.5;color:#6b778c;background-color:#fff;border:1px solid #c1c7d0;border-radius:.25rem}.custom-file-label::after{position:absolute;top:0;right:0;bottom:0;z-index:3;display:block;height:calc(1.5em + .75rem);padding:.375rem .75rem;line-height:1.5;color:#6b778c;content:"Browse";background-color:#ebecf0;border-left:inherit;border-radius:0 .25rem .25rem 0}.custom-range{width:100%;height:calc(1rem + .4rem);padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-range:focus{outline:none}.custom-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #f4f5f7,0 0 0 .2rem rgba(0,82,204,0.15)}.custom-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #f4f5f7,0 0 0 .2rem rgba(0,82,204,0.15)}.custom-range:focus::-ms-thumb{box-shadow:0 0 0 1px #f4f5f7,0 0 0 .2rem rgba(0,82,204,0.15)}.custom-range::-moz-focus-outer{border:0}.custom-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#091e42;border:0;border-radius:1rem;transition:background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion: reduce){.custom-range::-webkit-slider-thumb{transition:none}}.custom-range::-webkit-slider-thumb:active{background-color:#1e65df}.custom-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dfe1e6;border-color:transparent;border-radius:1rem}.custom-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#091e42;border:0;border-radius:1rem;transition:background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion: reduce){.custom-range::-moz-range-thumb{transition:none}}.custom-range::-moz-range-thumb:active{background-color:#1e65df}.custom-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dfe1e6;border-color:transparent;border-radius:1rem}.custom-range::-ms-thumb{width:1rem;height:1rem;margin-top:0;margin-right:.2rem;margin-left:.2rem;background-color:#091e42;border:0;border-radius:1rem;transition:background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out;appearance:none}@media (prefers-reduced-motion: reduce){.custom-range::-ms-thumb{transition:none}}.custom-range::-ms-thumb:active{background-color:#1e65df}.custom-range::-ms-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:transparent;border-color:transparent;border-width:.5rem}.custom-range::-ms-fill-lower{background-color:#dfe1e6;border-radius:1rem}.custom-range::-ms-fill-upper{margin-right:15px;background-color:#dfe1e6;border-radius:1rem}.custom-range:disabled::-webkit-slider-thumb{background-color:#b3bac5}.custom-range:disabled::-webkit-slider-runnable-track{cursor:default}.custom-range:disabled::-moz-range-thumb{background-color:#b3bac5}.custom-range:disabled::-moz-range-track{cursor:default}.custom-range:disabled::-ms-thumb{background-color:#b3bac5}.custom-control-label::before,.custom-file-label,.custom-select{transition:background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.custom-control-label::before,.custom-file-label,.custom-select{transition:none}}.nav{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem}.nav-link:hover,.nav-link:focus{text-decoration:none}.nav-link.disabled{color:#a5adba;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #dfe1e6}.nav-tabs .nav-item{margin-bottom:-1px}.nav-tabs .nav-link{border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:hover,.nav-tabs .nav-link:focus{border-color:#ebecf0 #ebecf0 #dfe1e6}.nav-tabs .nav-link.disabled{color:#a5adba;background-color:transparent;border-color:transparent}.nav-tabs .nav-link.active,.nav-tabs .nav-item.show .nav-link{color:#6b778c;background-color:#f4f5f7;border-color:#dfe1e6 #dfe1e6 #f4f5f7}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#091e42}.nav-fill .nav-item{-ms-flex:1 1 auto;flex:1 1 auto;text-align:center}.nav-justified .nav-item{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;text-align:center}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between;padding:.5rem 1rem}.navbar>.container,.navbar>.container-fluid{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;line-height:inherit;white-space:nowrap}.navbar-brand:hover,.navbar-brand:focus{text-decoration:none}.navbar-nav{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static;float:none}.navbar-text{display:inline-block;padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{-ms-flex-preferred-size:100%;flex-basis:100%;-ms-flex-positive:1;flex-grow:1;-ms-flex-align:center;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem}.navbar-toggler:hover,.navbar-toggler:focus{text-decoration:none}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;content:"";background:no-repeat center center;background-size:100% 100%}@media (max-width: 575.98px){.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid{padding-right:0;padding-left:0}}@media (min-width: 576px){.navbar-expand-sm{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-sm .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-sm .navbar-collapse{display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}@media (max-width: 767.98px){.navbar-expand-md>.container,.navbar-expand-md>.container-fluid{padding-right:0;padding-left:0}}@media (min-width: 768px){.navbar-expand-md{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-md .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md>.container,.navbar-expand-md>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-md .navbar-collapse{display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}}@media (max-width: 991.98px){.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid{padding-right:0;padding-left:0}}@media (min-width: 992px){.navbar-expand-lg{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-lg .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-lg .navbar-collapse{display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}}@media (max-width: 1199.98px){.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid{padding-right:0;padding-left:0}}@media (min-width: 1200px){.navbar-expand-xl{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-xl .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-xl .navbar-collapse{display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}}.navbar-expand{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand>.container,.navbar-expand>.container-fluid{padding-right:0;padding-left:0}.navbar-expand .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand>.container,.navbar-expand>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand .navbar-collapse{display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-light .navbar-brand{color:rgba(9,30,66,0.9)}.navbar-light .navbar-brand:hover,.navbar-light .navbar-brand:focus{color:rgba(9,30,66,0.9)}.navbar-light .navbar-nav .nav-link{color:rgba(9,30,66,0.5)}.navbar-light .navbar-nav .nav-link:hover,.navbar-light .navbar-nav .nav-link:focus{color:rgba(9,30,66,0.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(9,30,66,0.3)}.navbar-light .navbar-nav .show>.nav-link,.navbar-light .navbar-nav .active>.nav-link,.navbar-light .navbar-nav .nav-link.show,.navbar-light .navbar-nav .nav-link.active{color:rgba(9,30,66,0.9)}.navbar-light .navbar-toggler{color:rgba(9,30,66,0.5);border-color:rgba(9,30,66,0.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3e%3cpath stroke='rgba(9,30,66,0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(9,30,66,0.5)}.navbar-light .navbar-text a{color:rgba(9,30,66,0.9)}.navbar-light .navbar-text a:hover,.navbar-light .navbar-text a:focus{color:rgba(9,30,66,0.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:hover,.navbar-dark .navbar-brand:focus{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,0.5)}.navbar-dark .navbar-nav .nav-link:hover,.navbar-dark .navbar-nav .nav-link:focus{color:rgba(255,255,255,0.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,0.25)}.navbar-dark .navbar-nav .show>.nav-link,.navbar-dark .navbar-nav .active>.nav-link,.navbar-dark .navbar-nav .nav-link.show,.navbar-dark .navbar-nav .nav-link.active{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,0.5);border-color:rgba(255,255,255,0.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3e%3cpath stroke='rgba(255,255,255,0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,0.5)}.navbar-dark .navbar-text a{color:#fff}.navbar-dark .navbar-text a:hover,.navbar-dark .navbar-text a:focus{color:#fff}.card{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(9,30,66,0.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group:first-child .list-group-item:first-child{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.card>.list-group:last-child .list-group-item:last-child{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.card-body,.card-admin-form .form-fieldset{-ms-flex:1 1 auto;flex:1 1 auto;padding:1.25rem}.card-title{margin-bottom:.75rem}.card-subtitle{margin-top:-.375rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link:hover{text-decoration:none}.card-link+.card-link{margin-left:1.25rem}.card-header{padding:.75rem 1.25rem;margin-bottom:0;background-color:rgba(0,0,0,0);border-bottom:1px solid rgba(9,30,66,0.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-header+.list-group .list-group-item:first-child{border-top:0}.card-footer{padding:.75rem 1.25rem;background-color:rgba(0,0,0,0);border-top:1px solid rgba(9,30,66,0.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.625rem;margin-bottom:-.75rem;margin-left:-.625rem;border-bottom:0}.card-header-pills{margin-right:-.625rem;margin-left:-.625rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1.25rem}.card-img{width:100%;border-radius:calc(.25rem - 1px)}.card-img-top{width:100%;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img-bottom{width:100%;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-deck{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column}.card-deck .card{margin-bottom:15px}@media (min-width: 576px){.card-deck{-ms-flex-flow:row wrap;flex-flow:row wrap;margin-right:-15px;margin-left:-15px}.card-deck .card{display:-ms-flexbox;display:flex;-ms-flex:1 0 0%;flex:1 0 0%;-ms-flex-direction:column;flex-direction:column;margin-right:15px;margin-bottom:0;margin-left:15px}}.card-group{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column}.card-group>.card{margin-bottom:15px}@media (min-width: 576px){.card-group{-ms-flex-flow:row wrap;flex-flow:row wrap}.card-group>.card{-ms-flex:1 0 0%;flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-img-top,.card-group>.card:not(:last-child) .card-header{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-img-bottom,.card-group>.card:not(:last-child) .card-footer{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-img-top,.card-group>.card:not(:first-child) .card-header{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-img-bottom,.card-group>.card:not(:first-child) .card-footer{border-bottom-left-radius:0}}.card-columns .card{margin-bottom:.75rem}@media (min-width: 576px){.card-columns{-webkit-column-count:3;-moz-column-count:3;column-count:3;-webkit-column-gap:1.25rem;-moz-column-gap:1.25rem;column-gap:1.25rem;orphans:1;widows:1}.card-columns .card{display:inline-block;width:100%}}.accordion>.card{overflow:hidden}.accordion>.card:not(:first-of-type) .card-header:first-child{border-radius:0}.accordion>.card:not(:first-of-type):not(:last-of-type){border-bottom:0;border-radius:0}.accordion>.card:first-of-type{border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.accordion>.card:last-of-type{border-top-left-radius:0;border-top-right-radius:0}.accordion>.card .card-header{margin-bottom:-1px}.breadcrumb{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding:.75rem 1rem;margin-bottom:1rem;list-style:none;background-color:#ebecf0;border-radius:.25rem}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{display:inline-block;padding-right:.5rem;color:#a5adba;content:"/"}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:underline}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:none}.breadcrumb-item.active{color:#a5adba}.pagination{display:-ms-flexbox;display:flex;padding-left:0;list-style:none;border-radius:.25rem}.page-link{position:relative;display:block;padding:.5rem .75rem;margin-left:-1px;line-height:1.25;color:#6b778c;background-color:#fff;border:1px solid #dfe1e6}.page-link:hover{z-index:2;color:#172b4d;text-decoration:none;background-color:#ebecf0;border-color:#dfe1e6}.page-link:focus{z-index:2;outline:0;box-shadow:0 0 0 .2rem rgba(0,82,204,0.15)}.page-item:first-child .page-link{margin-left:0;border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.page-item.active .page-link{z-index:1;color:#fff;background-color:#091e42;border-color:#091e42}.page-item.disabled .page-link{color:#a5adba;pointer-events:none;cursor:auto;background-color:#fff;border-color:#dfe1e6}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem;line-height:1.5}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem;line-height:1.5}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.25em .4em;font-size:75%;font-weight:700;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem;transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.badge{transition:none}}a.badge:hover,a.badge:focus{text-decoration:none}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{padding-right:.6em;padding-left:.6em;border-radius:10rem}.badge-primary{color:#fff;background-color:#6554c0}a.badge-primary:hover,a.badge-primary:focus{color:#fff;background-color:#4d3da4}a.badge-primary:focus,a.badge-primary.focus{outline:0;box-shadow:0 0 0 .2rem rgba(101,84,192,0.5)}.badge-secondary{color:#172b4d;background-color:#a5adba}a.badge-secondary:hover,a.badge-secondary:focus{color:#172b4d;background-color:#8893a4}a.badge-secondary:focus,a.badge-secondary.focus{outline:0;box-shadow:0 0 0 .2rem rgba(165,173,186,0.5)}.badge-success{color:#fff;background-color:#36b37e}a.badge-success:hover,a.badge-success:focus{color:#fff;background-color:#2a8c62}a.badge-success:focus,a.badge-success.focus{outline:0;box-shadow:0 0 0 .2rem rgba(54,179,126,0.5)}.badge-info{color:#fff;background-color:#00b8d9}a.badge-info:hover,a.badge-info:focus{color:#fff;background-color:#008da6}a.badge-info:focus,a.badge-info.focus{outline:0;box-shadow:0 0 0 .2rem rgba(0,184,217,0.5)}.badge-warning{color:#172b4d;background-color:#ffc107}a.badge-warning:hover,a.badge-warning:focus{color:#172b4d;background-color:#d39e00}a.badge-warning:focus,a.badge-warning.focus{outline:0;box-shadow:0 0 0 .2rem rgba(255,193,7,0.5)}.badge-danger{color:#fff;background-color:#ff5630}a.badge-danger:hover,a.badge-danger:focus{color:#fff;background-color:#fc2e00}a.badge-danger:focus,a.badge-danger.focus{outline:0;box-shadow:0 0 0 .2rem rgba(255,86,48,0.5)}.badge-light{color:#172b4d;background-color:#ebecf0}a.badge-light:hover,a.badge-light:focus{color:#172b4d;background-color:#ced0da}a.badge-light:focus,a.badge-light.focus{outline:0;box-shadow:0 0 0 .2rem rgba(235,236,240,0.5)}.badge-dark{color:#fff;background-color:#505f79}a.badge-dark:hover,a.badge-dark:focus{color:#fff;background-color:#3c475a}a.badge-dark:focus,a.badge-dark.focus{outline:0;box-shadow:0 0 0 .2rem rgba(80,95,121,0.5)}.jumbotron{padding:2rem 1rem;margin-bottom:2rem;background-color:#ebecf0;border-radius:.3rem}@media (min-width: 576px){.jumbotron{padding:4rem 2rem}}.jumbotron-fluid{padding-right:0;padding-left:0;border-radius:0}.alert{position:relative;padding:.75rem 1.25rem;margin-bottom:1rem;border:0 solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:4rem}.alert-dismissible .close{position:absolute;top:0;right:0;padding:.75rem 1.25rem;color:inherit}.alert-primary{color:#2a316f;background-color:#e0ddf2;border-color:#d4cfed}.alert-primary hr{border-top-color:#c3bce6}.alert-primary .alert-link{color:#1c214a}.alert-secondary{color:#41516d;background-color:#edeff1;border-color:#e6e8ec}.alert-secondary hr{border-top-color:#d8dbe1}.alert-secondary .alert-link{color:#2e394d}.alert-success{color:#195458;background-color:#d7f0e5;border-color:#c7eadb}.alert-success hr{border-top-color:#b4e3cf}.alert-success .alert-link{color:#0e2e30}.alert-info{color:#065578;background-color:#ccf1f7;border-color:#b8ebf4}.alert-info hr{border-top-color:#a2e5f1}.alert-info .alert-link{color:#043347}.alert-warning{color:#62592d;background-color:#fff3cd;border-color:#ffeeba}.alert-warning hr{border-top-color:#ffe8a1}.alert-warning .alert-link{color:#3f391d}.alert-danger{color:#62323c;background-color:#ffddd6;border-color:#ffd0c5}.alert-danger hr{border-top-color:#ffbbac}.alert-danger .alert-link{color:#402127}.alert-light{color:#5a6881;background-color:#fbfbfc;border-color:#f9fafb}.alert-light hr{border-top-color:#eaedf1}.alert-light .alert-link{color:#455063}.alert-dark{color:#233556;background-color:#dcdfe4;border-color:#ced2d9}.alert-dark hr{border-top-color:#c0c5ce}.alert-dark .alert-link{color:#141f32}@-webkit-keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}.progress{display:-ms-flexbox;display:flex;height:1rem;overflow:hidden;font-size:.75rem;background-color:#ebecf0;border-radius:.25rem}.progress-bar{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;color:#fff;text-align:center;white-space:nowrap;background-color:#6554c0;transition:width 0.6s ease}@media (prefers-reduced-motion: reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:progress-bar-stripes 1s linear infinite;animation:progress-bar-stripes 1s linear infinite}@media (prefers-reduced-motion: reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.media,.nav-side .nav-link{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start}.media-body{-ms-flex:1;flex:1}.list-group{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0}.list-group-item-action{width:100%;color:#6b778c;text-align:inherit}.list-group-item-action:hover,.list-group-item-action:focus{z-index:1;color:#6b778c;text-decoration:none;background-color:#f4f5f7}.list-group-item-action:active{color:#172b4d;background-color:#ebecf0}.list-group-item{position:relative;display:block;padding:.75rem 1.25rem;margin-bottom:-1px;background-color:#fff;border:1px solid rgba(9,30,66,0.125)}.list-group-item:first-child{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.list-group-item.disabled,.list-group-item:disabled{color:#a5adba;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#091e42;border-color:#091e42}.list-group-horizontal{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}@media (min-width: 576px){.list-group-horizontal-sm{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-sm .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal-sm .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}}@media (min-width: 768px){.list-group-horizontal-md{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-md .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal-md .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}}@media (min-width: 992px){.list-group-horizontal-lg{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-lg .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal-lg .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}}@media (min-width: 1200px){.list-group-horizontal-xl{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-xl .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal-xl .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}}.list-group-flush .list-group-item{border-right:0;border-left:0;border-radius:0}.list-group-flush .list-group-item:last-child{margin-bottom:-1px}.list-group-flush:first-child .list-group-item:first-child{border-top:0}.list-group-flush:last-child .list-group-item:last-child{margin-bottom:0;border-bottom:0}.list-group-item-primary{color:#393a84;background-color:#d4cfed}.list-group-item-primary.list-group-item-action:hover,.list-group-item-primary.list-group-item-action:focus{color:#393a84;background-color:#c3bce6}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#393a84;border-color:#393a84}.list-group-item-secondary{color:#5a6880;background-color:#e6e8ec}.list-group-item-secondary.list-group-item-action:hover,.list-group-item-secondary.list-group-item-action:focus{color:#5a6880;background-color:#d8dbe1}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#5a6880;border-color:#5a6880}.list-group-item-success{color:#206b61;background-color:#c7eadb}.list-group-item-success.list-group-item-action:hover,.list-group-item-success.list-group-item-action:focus{color:#206b61;background-color:#b4e3cf}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#206b61;border-color:#206b61}.list-group-item-info{color:#046e91;background-color:#b8ebf4}.list-group-item-info.list-group-item-action:hover,.list-group-item-info.list-group-item-action:focus{color:#046e91;background-color:#a2e5f1}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#046e91;border-color:#046e91}.list-group-item-warning{color:#897323;background-color:#ffeeba}.list-group-item-warning.list-group-item-action:hover,.list-group-item-warning.list-group-item-action:focus{color:#897323;background-color:#ffe8a1}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#897323;border-color:#897323}.list-group-item-danger{color:#893b39;background-color:#ffd0c5}.list-group-item-danger.list-group-item-action:hover,.list-group-item-danger.list-group-item-action:focus{color:#893b39;background-color:#ffbbac}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#893b39;border-color:#893b39}.list-group-item-light{color:#7f899c;background-color:#f9fafb}.list-group-item-light.list-group-item-action:hover,.list-group-item-light.list-group-item-action:focus{color:#7f899c;background-color:#eaedf1}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#7f899c;border-color:#7f899c}.list-group-item-dark{color:#2e405f;background-color:#ced2d9}.list-group-item-dark.list-group-item-action:hover,.list-group-item-dark.list-group-item-action:focus{color:#2e405f;background-color:#c0c5ce}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#2e405f;border-color:#2e405f}.close{float:right;font-size:1.5rem;font-weight:700;line-height:1;color:#091e42;text-shadow:0 1px 0 #fff;opacity:.5}.close:hover{color:#091e42;text-decoration:none}.close:not(:disabled):not(.disabled):hover,.close:not(:disabled):not(.disabled):focus{opacity:.75}button.close{padding:0;background-color:transparent;border:0;-webkit-appearance:none;-moz-appearance:none;appearance:none}a.close.disabled{pointer-events:none}.modal-open{overflow:hidden}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal{position:fixed;top:0;left:0;z-index:1050;display:none;width:100%;height:100%;overflow:hidden;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:-webkit-transform 0.3s ease-out;transition:transform 0.3s ease-out;transition:transform 0.3s ease-out, -webkit-transform 0.3s ease-out;-webkit-transform:translate(0, -50px);transform:translate(0, -50px)}@media (prefers-reduced-motion: reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{-webkit-transform:none;transform:none}.modal-dialog-scrollable{display:-ms-flexbox;display:flex;max-height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 1rem);overflow:hidden}.modal-dialog-scrollable .modal-header,.modal-dialog-scrollable .modal-footer{-ms-flex-negative:0;flex-shrink:0}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;min-height:calc(100% - 1rem)}.modal-dialog-centered::before{display:block;height:calc(100vh - 1rem);content:""}.modal-dialog-centered.modal-dialog-scrollable{-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;height:100%}.modal-dialog-centered.modal-dialog-scrollable .modal-content{max-height:none}.modal-dialog-centered.modal-dialog-scrollable::before{content:none}.modal-content{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(9,30,66,0.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#091e42}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:justify;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #dfe1e6;border-top-left-radius:.3rem;border-top-right-radius:.3rem}.modal-header .close{padding:1rem 1rem;margin:-1rem -1rem -1rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;padding:1rem}.modal-footer{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:end;justify-content:flex-end;padding:1rem;border-top:1px solid #dfe1e6;border-bottom-right-radius:.3rem;border-bottom-left-radius:.3rem}.modal-footer>:not(:first-child){margin-left:.25rem}.modal-footer>:not(:last-child){margin-right:.25rem}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width: 576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{max-height:calc(100% - 3.5rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-dialog-centered::before{height:calc(100vh - 3.5rem)}.modal-sm{max-width:300px}}@media (min-width: 992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width: 1200px){.modal-xl{max-width:1140px}}.tooltip{position:absolute;z-index:1070;display:block;margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-top,.bs-tooltip-auto[x-placement^="top"]{padding:.4rem 0}.bs-tooltip-top .arrow,.bs-tooltip-auto[x-placement^="top"] .arrow{bottom:0}.bs-tooltip-top .arrow::before,.bs-tooltip-auto[x-placement^="top"] .arrow::before{top:0;border-width:.4rem .4rem 0;border-top-color:#091e42}.bs-tooltip-right,.bs-tooltip-auto[x-placement^="right"]{padding:0 .4rem}.bs-tooltip-right .arrow,.bs-tooltip-auto[x-placement^="right"] .arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-right .arrow::before,.bs-tooltip-auto[x-placement^="right"] .arrow::before{right:0;border-width:.4rem .4rem .4rem 0;border-right-color:#091e42}.bs-tooltip-bottom,.bs-tooltip-auto[x-placement^="bottom"]{padding:.4rem 0}.bs-tooltip-bottom .arrow,.bs-tooltip-auto[x-placement^="bottom"] .arrow{top:0}.bs-tooltip-bottom .arrow::before,.bs-tooltip-auto[x-placement^="bottom"] .arrow::before{bottom:0;border-width:0 .4rem .4rem;border-bottom-color:#091e42}.bs-tooltip-left,.bs-tooltip-auto[x-placement^="left"]{padding:0 .4rem}.bs-tooltip-left .arrow,.bs-tooltip-auto[x-placement^="left"] .arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-left .arrow::before,.bs-tooltip-auto[x-placement^="left"] .arrow::before{left:0;border-width:.4rem 0 .4rem .4rem;border-left-color:#091e42}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#091e42;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1060;display:block;max-width:276px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(9,30,66,0.2);border-radius:.3rem}.popover .arrow{position:absolute;display:block;width:1rem;height:.5rem;margin:0 .3rem}.popover .arrow::before,.popover .arrow::after{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-top,.bs-popover-auto[x-placement^="top"]{margin-bottom:.5rem}.bs-popover-top>.arrow,.bs-popover-auto[x-placement^="top"]>.arrow{bottom:calc((.5rem + 1px) * -1)}.bs-popover-top>.arrow::before,.bs-popover-auto[x-placement^="top"]>.arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(9,30,66,0.25)}.bs-popover-top>.arrow::after,.bs-popover-auto[x-placement^="top"]>.arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-right,.bs-popover-auto[x-placement^="right"]{margin-left:.5rem}.bs-popover-right>.arrow,.bs-popover-auto[x-placement^="right"]>.arrow{left:calc((.5rem + 1px) * -1);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-right>.arrow::before,.bs-popover-auto[x-placement^="right"]>.arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(9,30,66,0.25)}.bs-popover-right>.arrow::after,.bs-popover-auto[x-placement^="right"]>.arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-bottom,.bs-popover-auto[x-placement^="bottom"]{margin-top:.5rem}.bs-popover-bottom>.arrow,.bs-popover-auto[x-placement^="bottom"]>.arrow{top:calc((.5rem + 1px) * -1)}.bs-popover-bottom>.arrow::before,.bs-popover-auto[x-placement^="bottom"]>.arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(9,30,66,0.25)}.bs-popover-bottom>.arrow::after,.bs-popover-auto[x-placement^="bottom"]>.arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-bottom .popover-header::before,.bs-popover-auto[x-placement^="bottom"] .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f7f7f7}.bs-popover-left,.bs-popover-auto[x-placement^="left"]{margin-right:.5rem}.bs-popover-left>.arrow,.bs-popover-auto[x-placement^="left"]>.arrow{right:calc((.5rem + 1px) * -1);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-left>.arrow::before,.bs-popover-auto[x-placement^="left"]>.arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(9,30,66,0.25)}.bs-popover-left>.arrow::after,.bs-popover-auto[x-placement^="left"]>.arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem .75rem;margin-bottom:0;font-size:1rem;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:.5rem .75rem;color:#172b4d}@-webkit-keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:spinner-border .75s linear infinite;animation:spinner-border .75s linear infinite}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1}}@keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:spinner-grow .75s linear infinite;animation:spinner-grow .75s linear infinite}.spinner-grow-sm{width:1rem;height:1rem}.align-baseline{vertical-align:baseline !important}.align-top{vertical-align:top !important}.align-middle{vertical-align:middle !important}.align-bottom{vertical-align:bottom !important}.align-text-bottom{vertical-align:text-bottom !important}.align-text-top{vertical-align:text-top !important}.bg-primary{background-color:#6554c0 !important}a.bg-primary:hover,a.bg-primary:focus,button.bg-primary:hover,button.bg-primary:focus{background-color:#4d3da4 !important}.bg-secondary{background-color:#a5adba !important}a.bg-secondary:hover,a.bg-secondary:focus,button.bg-secondary:hover,button.bg-secondary:focus{background-color:#8893a4 !important}.bg-success{background-color:#36b37e !important}a.bg-success:hover,a.bg-success:focus,button.bg-success:hover,button.bg-success:focus{background-color:#2a8c62 !important}.bg-info{background-color:#00b8d9 !important}a.bg-info:hover,a.bg-info:focus,button.bg-info:hover,button.bg-info:focus{background-color:#008da6 !important}.bg-warning{background-color:#ffc107 !important}a.bg-warning:hover,a.bg-warning:focus,button.bg-warning:hover,button.bg-warning:focus{background-color:#d39e00 !important}.bg-danger{background-color:#ff5630 !important}a.bg-danger:hover,a.bg-danger:focus,button.bg-danger:hover,button.bg-danger:focus{background-color:#fc2e00 !important}.bg-light{background-color:#ebecf0 !important}a.bg-light:hover,a.bg-light:focus,button.bg-light:hover,button.bg-light:focus{background-color:#ced0da !important}.bg-dark{background-color:#505f79 !important}a.bg-dark:hover,a.bg-dark:focus,button.bg-dark:hover,button.bg-dark:focus{background-color:#3c475a !important}.bg-white{background-color:#fff !important}.bg-transparent{background-color:transparent !important}.border,.control-month-picker,.control-time-picker,.control-image-preview{border:1px solid #dfe1e6 !important}.border-top,.card-admin-form .form-fieldset+.form-fieldset{border-top:1px solid #dfe1e6 !important}.border-right{border-right:1px solid #dfe1e6 !important}.border-bottom{border-bottom:1px solid #dfe1e6 !important}.border-left,.page-header h1 small{border-left:1px solid #dfe1e6 !important}.border-0,.control-time-picker input{border:0 !important}.border-top-0,.card-admin-table>:first-child th{border-top:0 !important}.border-right-0{border-right:0 !important}.border-bottom-0,.card-admin-table th{border-bottom:0 !important}.border-left-0{border-left:0 !important}.border-primary{border-color:#6554c0 !important}.border-secondary{border-color:#a5adba !important}.border-success{border-color:#36b37e !important}.border-info{border-color:#00b8d9 !important}.border-warning{border-color:#ffc107 !important}.border-danger,.card-admin-error,.card-admin-error .card-header,.login-error-card{border-color:#ff5630 !important}.border-light{border-color:#ebecf0 !important}.border-dark{border-color:#505f79 !important}.border-white{border-color:#fff !important}.rounded-sm{border-radius:.2rem !important}.rounded,.control-month-picker,.control-time-picker,.control-image-preview,.card-admin-settings-card,.media-check-icon{border-radius:.25rem !important}.rounded-top{border-top-left-radius:.25rem !important;border-top-right-radius:.25rem !important}.rounded-right{border-top-right-radius:.25rem !important;border-bottom-right-radius:.25rem !important}.rounded-bottom{border-bottom-right-radius:.25rem !important;border-bottom-left-radius:.25rem !important}.rounded-left{border-top-left-radius:.25rem !important;border-bottom-left-radius:.25rem !important}.rounded-lg,.card-admin-table img{border-radius:.3rem !important}.rounded-circle{border-radius:50% !important}.rounded-pill{border-radius:50rem !important}.rounded-0{border-radius:0 !important}.clearfix::after{display:block;clear:both;content:""}.d-none{display:none !important}.d-inline{display:inline !important}.d-inline-block,.control-image-preview{display:inline-block !important}.d-block,.card-admin-settings-card{display:block !important}.d-table{display:table !important}.d-table-row{display:table-row !important}.d-table-cell{display:table-cell !important}.d-flex,.control-time-picker,.page-header h1,.media-check-icon{display:-ms-flexbox !important;display:flex !important}.d-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}@media (min-width: 576px){.d-sm-none{display:none !important}.d-sm-inline{display:inline !important}.d-sm-inline-block{display:inline-block !important}.d-sm-block{display:block !important}.d-sm-table{display:table !important}.d-sm-table-row{display:table-row !important}.d-sm-table-cell{display:table-cell !important}.d-sm-flex{display:-ms-flexbox !important;display:flex !important}.d-sm-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}}@media (min-width: 768px){.d-md-none{display:none !important}.d-md-inline{display:inline !important}.d-md-inline-block{display:inline-block !important}.d-md-block{display:block !important}.d-md-table{display:table !important}.d-md-table-row{display:table-row !important}.d-md-table-cell{display:table-cell !important}.d-md-flex{display:-ms-flexbox !important;display:flex !important}.d-md-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}}@media (min-width: 992px){.d-lg-none{display:none !important}.d-lg-inline{display:inline !important}.d-lg-inline-block{display:inline-block !important}.d-lg-block{display:block !important}.d-lg-table{display:table !important}.d-lg-table-row{display:table-row !important}.d-lg-table-cell{display:table-cell !important}.d-lg-flex{display:-ms-flexbox !important;display:flex !important}.d-lg-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}}@media (min-width: 1200px){.d-xl-none{display:none !important}.d-xl-inline{display:inline !important}.d-xl-inline-block{display:inline-block !important}.d-xl-block{display:block !important}.d-xl-table{display:table !important}.d-xl-table-row{display:table-row !important}.d-xl-table-cell{display:table-cell !important}.d-xl-flex{display:-ms-flexbox !important;display:flex !important}.d-xl-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}}@media print{.d-print-none{display:none !important}.d-print-inline{display:inline !important}.d-print-inline-block{display:inline-block !important}.d-print-block{display:block !important}.d-print-table{display:table !important}.d-print-table-row{display:table-row !important}.d-print-table-cell{display:table-cell !important}.d-print-flex{display:-ms-flexbox !important;display:flex !important}.d-print-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}}.embed-responsive{position:relative;display:block;width:100%;padding:0;overflow:hidden}.embed-responsive::before{display:block;content:""}.embed-responsive .embed-responsive-item,.embed-responsive iframe,.embed-responsive embed,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-21by9::before{padding-top:42.85714%}.embed-responsive-16by9::before{padding-top:56.25%}.embed-responsive-4by3::before{padding-top:75%}.embed-responsive-1by1::before{padding-top:100%}.flex-row{-ms-flex-direction:row !important;flex-direction:row !important}.flex-column,.nav-side{-ms-flex-direction:column !important;flex-direction:column !important}.flex-row-reverse{-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-column-reverse{-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-fill{-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-grow-0{-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-grow-1{-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-start{-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-end{-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-center,.media-check-icon{-ms-flex-pack:center !important;justify-content:center !important}.justify-content-between{-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-start{-ms-flex-align:start !important;align-items:flex-start !important}.align-items-end{-ms-flex-align:end !important;align-items:flex-end !important}.align-items-center,.control-time-picker,.page-header h1,.media-check-icon{-ms-flex-align:center !important;align-items:center !important}.align-items-baseline{-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-stretch{-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}@media (min-width: 576px){.flex-sm-row{-ms-flex-direction:row !important;flex-direction:row !important}.flex-sm-column{-ms-flex-direction:column !important;flex-direction:column !important}.flex-sm-row-reverse{-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-sm-column-reverse{-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-sm-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-sm-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-sm-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-sm-fill{-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-sm-grow-0{-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-sm-grow-1{-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-sm-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-sm-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-sm-start{-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-sm-end{-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-sm-center{-ms-flex-pack:center !important;justify-content:center !important}.justify-content-sm-between{-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-sm-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-sm-start{-ms-flex-align:start !important;align-items:flex-start !important}.align-items-sm-end{-ms-flex-align:end !important;align-items:flex-end !important}.align-items-sm-center{-ms-flex-align:center !important;align-items:center !important}.align-items-sm-baseline{-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-sm-stretch{-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-sm-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-sm-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-sm-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-sm-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-sm-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-sm-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-sm-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-sm-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-sm-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-sm-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-sm-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-sm-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}}@media (min-width: 768px){.flex-md-row{-ms-flex-direction:row !important;flex-direction:row !important}.flex-md-column{-ms-flex-direction:column !important;flex-direction:column !important}.flex-md-row-reverse{-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-md-column-reverse{-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-md-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-md-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-md-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-md-fill{-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-md-grow-0{-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-md-grow-1{-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-md-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-md-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-md-start{-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-md-end{-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-md-center{-ms-flex-pack:center !important;justify-content:center !important}.justify-content-md-between{-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-md-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-md-start{-ms-flex-align:start !important;align-items:flex-start !important}.align-items-md-end{-ms-flex-align:end !important;align-items:flex-end !important}.align-items-md-center{-ms-flex-align:center !important;align-items:center !important}.align-items-md-baseline{-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-md-stretch{-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-md-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-md-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-md-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-md-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-md-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-md-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-md-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-md-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-md-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-md-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-md-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-md-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}}@media (min-width: 992px){.flex-lg-row{-ms-flex-direction:row !important;flex-direction:row !important}.flex-lg-column{-ms-flex-direction:column !important;flex-direction:column !important}.flex-lg-row-reverse{-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-lg-column-reverse{-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-lg-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-lg-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-lg-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-lg-fill{-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-lg-grow-0{-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-lg-grow-1{-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-lg-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-lg-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-lg-start{-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-lg-end{-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-lg-center{-ms-flex-pack:center !important;justify-content:center !important}.justify-content-lg-between{-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-lg-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-lg-start{-ms-flex-align:start !important;align-items:flex-start !important}.align-items-lg-end{-ms-flex-align:end !important;align-items:flex-end !important}.align-items-lg-center{-ms-flex-align:center !important;align-items:center !important}.align-items-lg-baseline{-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-lg-stretch{-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-lg-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-lg-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-lg-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-lg-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-lg-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-lg-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-lg-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-lg-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-lg-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-lg-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-lg-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-lg-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}}@media (min-width: 1200px){.flex-xl-row{-ms-flex-direction:row !important;flex-direction:row !important}.flex-xl-column{-ms-flex-direction:column !important;flex-direction:column !important}.flex-xl-row-reverse{-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-xl-column-reverse{-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-xl-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-xl-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-xl-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-xl-fill{-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-xl-grow-0{-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-xl-grow-1{-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-xl-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-xl-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-xl-start{-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-xl-end{-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-xl-center{-ms-flex-pack:center !important;justify-content:center !important}.justify-content-xl-between{-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-xl-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-xl-start{-ms-flex-align:start !important;align-items:flex-start !important}.align-items-xl-end{-ms-flex-align:end !important;align-items:flex-end !important}.align-items-xl-center{-ms-flex-align:center !important;align-items:center !important}.align-items-xl-baseline{-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-xl-stretch{-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-xl-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-xl-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-xl-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-xl-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-xl-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-xl-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-xl-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-xl-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-xl-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-xl-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-xl-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-xl-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}}.float-left{float:left !important}.float-right{float:right !important}.float-none{float:none !important}@media (min-width: 576px){.float-sm-left{float:left !important}.float-sm-right{float:right !important}.float-sm-none{float:none !important}}@media (min-width: 768px){.float-md-left{float:left !important}.float-md-right{float:right !important}.float-md-none{float:none !important}}@media (min-width: 992px){.float-lg-left{float:left !important}.float-lg-right{float:right !important}.float-lg-none{float:none !important}}@media (min-width: 1200px){.float-xl-left{float:left !important}.float-xl-right{float:right !important}.float-xl-none{float:none !important}}.overflow-auto{overflow:auto !important}.overflow-hidden{overflow:hidden !important}.position-static{position:static !important}.position-relative{position:relative !important}.position-absolute{position:absolute !important}.position-fixed{position:fixed !important}.position-sticky{position:-webkit-sticky !important;position:sticky !important}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}@supports ((position: -webkit-sticky) or (position: sticky)){.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.sr-only{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);white-space:nowrap;border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;overflow:visible;clip:auto;white-space:normal}.shadow-sm,.login-form-card,.card-admin-settings-grid,.card-admin-info,.card-admin-table,.card-admin-form,.card-admin-error,.login-error-card{box-shadow:0 0.125rem 0.25rem rgba(9,30,66,0.075) !important}.shadow{box-shadow:0 0.5rem 1rem rgba(9,30,66,0.15) !important}.shadow-lg{box-shadow:0 1rem 3rem rgba(9,30,66,0.175) !important}.shadow-none{box-shadow:none !important}.w-25{width:25% !important}.w-50{width:50% !important}.w-75{width:75% !important}.w-100,.card-admin-table .row-select label{width:100% !important}.w-auto{width:auto !important}.h-25{height:25% !important}.h-50{height:50% !important}.h-75{height:75% !important}.h-100{height:100% !important}.h-auto{height:auto !important}.mw-100{max-width:100% !important}.mh-100{max-height:100% !important}.min-vw-100{min-width:100vw !important}.min-vh-100{min-height:100vh !important}.vw-100{width:100vw !important}.vh-100{height:100vh !important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;pointer-events:auto;content:"";background-color:rgba(0,0,0,0)}.m-0,.page-header h1,.card-admin-settings-card h5,.card-admin-info .card-title,.card-admin-table .card-title,.card-admin-table table,.card-admin-table h5,.card-admin-table .row-select label{margin:0 !important}.mt-0,.media-admin-check h5,.my-0{margin-top:0 !important}.mr-0,.mx-0{margin-right:0 !important}.mb-0,.card-admin-form .form-group:last-child,.card-admin-form .form-fieldset .form-group:last-child,.my-0{margin-bottom:0 !important}.ml-0,.mx-0{margin-left:0 !important}.m-1,.card-admin-settings-card{margin:.25rem !important}.mt-1,.control-checkboxselect .form-check+.form-check,.control-radioselect .form-check+.form-check,.control-image-preview div,.my-1{margin-top:.25rem !important}.mr-1,.page-action .fa,.page-action .fab,.mx-1{margin-right:.25rem !important}.mb-1,.my-1{margin-bottom:.25rem !important}.ml-1,.mx-1{margin-left:.25rem !important}.m-2{margin:.5rem !important}.mt-2,.my-2{margin-top:.5rem !important}.mr-2,.mx-2{margin-right:.5rem !important}.mb-2,.control-image-preview,.my-2{margin-bottom:.5rem !important}.ml-2,.control-image-preview div span+span,.mx-2{margin-left:.5rem !important}.m-3{margin:1rem !important}.mt-3,.my-3{margin-top:1rem !important}.mr-3,.media-check-icon,.mx-3{margin-right:1rem !important}.mb-3,.page-header,.card-admin-info,.card-admin-form .card-admin-table,.my-3{margin-bottom:1rem !important}.ml-3,.control-yesno-switch .radio+.radio,.page-header h1 small,.mx-3{margin-left:1rem !important}.m-4{margin:1.5rem !important}.mt-4,.my-4{margin-top:1.5rem !important}.mr-4,.mx-4{margin-right:1.5rem !important}.mb-4,.my-4{margin-bottom:1.5rem !important}.ml-4,.mx-4{margin-left:1.5rem !important}.m-5{margin:3rem !important}.mt-5,.my-5{margin-top:3rem !important}.mr-5,.mx-5{margin-right:3rem !important}.mb-5,.my-5{margin-bottom:3rem !important}.ml-5,.mx-5{margin-left:3rem !important}.p-0,.navbar .btn-user{padding:0 !important}.pt-0,.card-admin-form .form-fieldset:first-child,.py-0{padding-top:0 !important}.pr-0,.px-0{padding-right:0 !important}.pb-0,.control-image-preview.control-image-metadata,.card-admin-form .form-fieldset:last-child,.py-0{padding-bottom:0 !important}.pl-0,.card-admin-table .badges-list,.px-0{padding-left:0 !important}.p-1,.control-month-picker,.control-time-picker{padding:.25rem !important}.pt-1,.py-1,.page-header h1 small,.card-admin-settings-grid .card-body,.card-admin-settings-grid .card-admin-form .form-fieldset,.card-admin-form .card-admin-settings-grid .form-fieldset,.card-admin-table th,.card-admin-table td{padding-top:.25rem !important}.pr-1,.px-1{padding-right:.25rem !important}.pb-1,.py-1,.page-header h1 small,.card-admin-settings-grid .card-body,.card-admin-settings-grid .card-admin-form .form-fieldset,.card-admin-form .card-admin-settings-grid .form-fieldset,.card-admin-table th,.card-admin-table td{padding-bottom:.25rem !important}.pl-1,.nav-side .media-body,.px-1{padding-left:.25rem !important}.p-2,.card-admin-settings-card,.card-admin-table .row-select label{padding:.5rem !important}.pt-2,.py-2,.control-image-preview div{padding-top:.5rem !important}.pr-2,.px-2{padding-right:.5rem !important}.pb-2,.py-2,.control-image-preview div{padding-bottom:.5rem !important}.pl-2,.px-2{padding-left:.5rem !important}.p-3,.control-image-preview{padding:1rem !important}.pt-3,.py-3,.card-admin-analytics-summary,.card-admin-table .blankslate td{padding-top:1rem !important}.pr-3,.px-3{padding-right:1rem !important}.pb-3,.py-3,.card-admin-analytics-summary,.card-admin-table .blankslate td{padding-bottom:1rem !important}.pl-3,.page-header h1 small,.px-3{padding-left:1rem !important}.p-4{padding:1.5rem !important}.pt-4,.py-4{padding-top:1.5rem !important}.pr-4,.px-4{padding-right:1.5rem !important}.pb-4,.py-4{padding-bottom:1.5rem !important}.pl-4,.px-4{padding-left:1.5rem !important}.p-5{padding:3rem !important}.pt-5,.py-5{padding-top:3rem !important}.pr-5,.px-5{padding-right:3rem !important}.pb-5,.py-5{padding-bottom:3rem !important}.pl-5,.px-5{padding-left:3rem !important}.m-n1{margin:-.25rem !important}.mt-n1,.my-n1{margin-top:-.25rem !important}.mr-n1,.mx-n1{margin-right:-.25rem !important}.mb-n1,.my-n1{margin-bottom:-.25rem !important}.ml-n1,.mx-n1{margin-left:-.25rem !important}.m-n2{margin:-.5rem !important}.mt-n2,.my-n2{margin-top:-.5rem !important}.mr-n2,.mx-n2{margin-right:-.5rem !important}.mb-n2,.my-n2{margin-bottom:-.5rem !important}.ml-n2,.mx-n2{margin-left:-.5rem !important}.m-n3{margin:-1rem !important}.mt-n3,.my-n3{margin-top:-1rem !important}.mr-n3,.mx-n3{margin-right:-1rem !important}.mb-n3,.my-n3{margin-bottom:-1rem !important}.ml-n3,.mx-n3{margin-left:-1rem !important}.m-n4{margin:-1.5rem !important}.mt-n4,.my-n4{margin-top:-1.5rem !important}.mr-n4,.mx-n4{margin-right:-1.5rem !important}.mb-n4,.my-n4{margin-bottom:-1.5rem !important}.ml-n4,.mx-n4{margin-left:-1.5rem !important}.m-n5{margin:-3rem !important}.mt-n5,.my-n5{margin-top:-3rem !important}.mr-n5,.mx-n5{margin-right:-3rem !important}.mb-n5,.my-n5{margin-bottom:-3rem !important}.ml-n5,.mx-n5{margin-left:-3rem !important}.m-auto{margin:auto !important}.mt-auto,.my-auto{margin-top:auto !important}.mr-auto,.mx-auto{margin-right:auto !important}.mb-auto,.my-auto{margin-bottom:auto !important}.ml-auto,.mx-auto{margin-left:auto !important}@media (min-width: 576px){.m-sm-0{margin:0 !important}.mt-sm-0,.my-sm-0{margin-top:0 !important}.mr-sm-0,.mx-sm-0{margin-right:0 !important}.mb-sm-0,.my-sm-0{margin-bottom:0 !important}.ml-sm-0,.mx-sm-0{margin-left:0 !important}.m-sm-1{margin:.25rem !important}.mt-sm-1,.my-sm-1{margin-top:.25rem !important}.mr-sm-1,.mx-sm-1{margin-right:.25rem !important}.mb-sm-1,.my-sm-1{margin-bottom:.25rem !important}.ml-sm-1,.mx-sm-1{margin-left:.25rem !important}.m-sm-2{margin:.5rem !important}.mt-sm-2,.my-sm-2{margin-top:.5rem !important}.mr-sm-2,.mx-sm-2{margin-right:.5rem !important}.mb-sm-2,.my-sm-2{margin-bottom:.5rem !important}.ml-sm-2,.mx-sm-2{margin-left:.5rem !important}.m-sm-3{margin:1rem !important}.mt-sm-3,.my-sm-3{margin-top:1rem !important}.mr-sm-3,.mx-sm-3{margin-right:1rem !important}.mb-sm-3,.my-sm-3{margin-bottom:1rem !important}.ml-sm-3,.mx-sm-3{margin-left:1rem !important}.m-sm-4{margin:1.5rem !important}.mt-sm-4,.my-sm-4{margin-top:1.5rem !important}.mr-sm-4,.mx-sm-4{margin-right:1.5rem !important}.mb-sm-4,.my-sm-4{margin-bottom:1.5rem !important}.ml-sm-4,.mx-sm-4{margin-left:1.5rem !important}.m-sm-5{margin:3rem !important}.mt-sm-5,.my-sm-5{margin-top:3rem !important}.mr-sm-5,.mx-sm-5{margin-right:3rem !important}.mb-sm-5,.my-sm-5{margin-bottom:3rem !important}.ml-sm-5,.mx-sm-5{margin-left:3rem !important}.p-sm-0{padding:0 !important}.pt-sm-0,.py-sm-0{padding-top:0 !important}.pr-sm-0,.px-sm-0{padding-right:0 !important}.pb-sm-0,.py-sm-0{padding-bottom:0 !important}.pl-sm-0,.px-sm-0{padding-left:0 !important}.p-sm-1{padding:.25rem !important}.pt-sm-1,.py-sm-1{padding-top:.25rem !important}.pr-sm-1,.px-sm-1{padding-right:.25rem !important}.pb-sm-1,.py-sm-1{padding-bottom:.25rem !important}.pl-sm-1,.px-sm-1{padding-left:.25rem !important}.p-sm-2{padding:.5rem !important}.pt-sm-2,.py-sm-2{padding-top:.5rem !important}.pr-sm-2,.px-sm-2{padding-right:.5rem !important}.pb-sm-2,.py-sm-2{padding-bottom:.5rem !important}.pl-sm-2,.px-sm-2{padding-left:.5rem !important}.p-sm-3{padding:1rem !important}.pt-sm-3,.py-sm-3{padding-top:1rem !important}.pr-sm-3,.px-sm-3{padding-right:1rem !important}.pb-sm-3,.py-sm-3{padding-bottom:1rem !important}.pl-sm-3,.px-sm-3{padding-left:1rem !important}.p-sm-4{padding:1.5rem !important}.pt-sm-4,.py-sm-4{padding-top:1.5rem !important}.pr-sm-4,.px-sm-4{padding-right:1.5rem !important}.pb-sm-4,.py-sm-4{padding-bottom:1.5rem !important}.pl-sm-4,.px-sm-4{padding-left:1.5rem !important}.p-sm-5{padding:3rem !important}.pt-sm-5,.py-sm-5{padding-top:3rem !important}.pr-sm-5,.px-sm-5{padding-right:3rem !important}.pb-sm-5,.py-sm-5{padding-bottom:3rem !important}.pl-sm-5,.px-sm-5{padding-left:3rem !important}.m-sm-n1{margin:-.25rem !important}.mt-sm-n1,.my-sm-n1{margin-top:-.25rem !important}.mr-sm-n1,.mx-sm-n1{margin-right:-.25rem !important}.mb-sm-n1,.my-sm-n1{margin-bottom:-.25rem !important}.ml-sm-n1,.mx-sm-n1{margin-left:-.25rem !important}.m-sm-n2{margin:-.5rem !important}.mt-sm-n2,.my-sm-n2{margin-top:-.5rem !important}.mr-sm-n2,.mx-sm-n2{margin-right:-.5rem !important}.mb-sm-n2,.my-sm-n2{margin-bottom:-.5rem !important}.ml-sm-n2,.mx-sm-n2{margin-left:-.5rem !important}.m-sm-n3{margin:-1rem !important}.mt-sm-n3,.my-sm-n3{margin-top:-1rem !important}.mr-sm-n3,.mx-sm-n3{margin-right:-1rem !important}.mb-sm-n3,.my-sm-n3{margin-bottom:-1rem !important}.ml-sm-n3,.mx-sm-n3{margin-left:-1rem !important}.m-sm-n4{margin:-1.5rem !important}.mt-sm-n4,.my-sm-n4{margin-top:-1.5rem !important}.mr-sm-n4,.mx-sm-n4{margin-right:-1.5rem !important}.mb-sm-n4,.my-sm-n4{margin-bottom:-1.5rem !important}.ml-sm-n4,.mx-sm-n4{margin-left:-1.5rem !important}.m-sm-n5{margin:-3rem !important}.mt-sm-n5,.my-sm-n5{margin-top:-3rem !important}.mr-sm-n5,.mx-sm-n5{margin-right:-3rem !important}.mb-sm-n5,.my-sm-n5{margin-bottom:-3rem !important}.ml-sm-n5,.mx-sm-n5{margin-left:-3rem !important}.m-sm-auto{margin:auto !important}.mt-sm-auto,.my-sm-auto{margin-top:auto !important}.mr-sm-auto,.mx-sm-auto{margin-right:auto !important}.mb-sm-auto,.my-sm-auto{margin-bottom:auto !important}.ml-sm-auto,.mx-sm-auto{margin-left:auto !important}}@media (min-width: 768px){.m-md-0{margin:0 !important}.mt-md-0,.my-md-0{margin-top:0 !important}.mr-md-0,.mx-md-0{margin-right:0 !important}.mb-md-0,.my-md-0{margin-bottom:0 !important}.ml-md-0,.mx-md-0{margin-left:0 !important}.m-md-1{margin:.25rem !important}.mt-md-1,.my-md-1{margin-top:.25rem !important}.mr-md-1,.mx-md-1{margin-right:.25rem !important}.mb-md-1,.my-md-1{margin-bottom:.25rem !important}.ml-md-1,.mx-md-1{margin-left:.25rem !important}.m-md-2{margin:.5rem !important}.mt-md-2,.my-md-2{margin-top:.5rem !important}.mr-md-2,.mx-md-2{margin-right:.5rem !important}.mb-md-2,.my-md-2{margin-bottom:.5rem !important}.ml-md-2,.mx-md-2{margin-left:.5rem !important}.m-md-3{margin:1rem !important}.mt-md-3,.my-md-3{margin-top:1rem !important}.mr-md-3,.mx-md-3{margin-right:1rem !important}.mb-md-3,.my-md-3{margin-bottom:1rem !important}.ml-md-3,.mx-md-3{margin-left:1rem !important}.m-md-4{margin:1.5rem !important}.mt-md-4,.my-md-4{margin-top:1.5rem !important}.mr-md-4,.mx-md-4{margin-right:1.5rem !important}.mb-md-4,.my-md-4{margin-bottom:1.5rem !important}.ml-md-4,.mx-md-4{margin-left:1.5rem !important}.m-md-5{margin:3rem !important}.mt-md-5,.my-md-5{margin-top:3rem !important}.mr-md-5,.mx-md-5{margin-right:3rem !important}.mb-md-5,.my-md-5{margin-bottom:3rem !important}.ml-md-5,.mx-md-5{margin-left:3rem !important}.p-md-0{padding:0 !important}.pt-md-0,.py-md-0{padding-top:0 !important}.pr-md-0,.px-md-0{padding-right:0 !important}.pb-md-0,.py-md-0{padding-bottom:0 !important}.pl-md-0,.px-md-0{padding-left:0 !important}.p-md-1{padding:.25rem !important}.pt-md-1,.py-md-1{padding-top:.25rem !important}.pr-md-1,.px-md-1{padding-right:.25rem !important}.pb-md-1,.py-md-1{padding-bottom:.25rem !important}.pl-md-1,.px-md-1{padding-left:.25rem !important}.p-md-2{padding:.5rem !important}.pt-md-2,.py-md-2{padding-top:.5rem !important}.pr-md-2,.px-md-2{padding-right:.5rem !important}.pb-md-2,.py-md-2{padding-bottom:.5rem !important}.pl-md-2,.px-md-2{padding-left:.5rem !important}.p-md-3{padding:1rem !important}.pt-md-3,.py-md-3{padding-top:1rem !important}.pr-md-3,.px-md-3{padding-right:1rem !important}.pb-md-3,.py-md-3{padding-bottom:1rem !important}.pl-md-3,.px-md-3{padding-left:1rem !important}.p-md-4{padding:1.5rem !important}.pt-md-4,.py-md-4{padding-top:1.5rem !important}.pr-md-4,.px-md-4{padding-right:1.5rem !important}.pb-md-4,.py-md-4{padding-bottom:1.5rem !important}.pl-md-4,.px-md-4{padding-left:1.5rem !important}.p-md-5{padding:3rem !important}.pt-md-5,.py-md-5{padding-top:3rem !important}.pr-md-5,.px-md-5{padding-right:3rem !important}.pb-md-5,.py-md-5{padding-bottom:3rem !important}.pl-md-5,.px-md-5{padding-left:3rem !important}.m-md-n1{margin:-.25rem !important}.mt-md-n1,.my-md-n1{margin-top:-.25rem !important}.mr-md-n1,.mx-md-n1{margin-right:-.25rem !important}.mb-md-n1,.my-md-n1{margin-bottom:-.25rem !important}.ml-md-n1,.mx-md-n1{margin-left:-.25rem !important}.m-md-n2{margin:-.5rem !important}.mt-md-n2,.my-md-n2{margin-top:-.5rem !important}.mr-md-n2,.mx-md-n2{margin-right:-.5rem !important}.mb-md-n2,.my-md-n2{margin-bottom:-.5rem !important}.ml-md-n2,.mx-md-n2{margin-left:-.5rem !important}.m-md-n3{margin:-1rem !important}.mt-md-n3,.my-md-n3{margin-top:-1rem !important}.mr-md-n3,.mx-md-n3{margin-right:-1rem !important}.mb-md-n3,.my-md-n3{margin-bottom:-1rem !important}.ml-md-n3,.mx-md-n3{margin-left:-1rem !important}.m-md-n4{margin:-1.5rem !important}.mt-md-n4,.my-md-n4{margin-top:-1.5rem !important}.mr-md-n4,.mx-md-n4{margin-right:-1.5rem !important}.mb-md-n4,.my-md-n4{margin-bottom:-1.5rem !important}.ml-md-n4,.mx-md-n4{margin-left:-1.5rem !important}.m-md-n5{margin:-3rem !important}.mt-md-n5,.my-md-n5{margin-top:-3rem !important}.mr-md-n5,.mx-md-n5{margin-right:-3rem !important}.mb-md-n5,.my-md-n5{margin-bottom:-3rem !important}.ml-md-n5,.mx-md-n5{margin-left:-3rem !important}.m-md-auto{margin:auto !important}.mt-md-auto,.my-md-auto{margin-top:auto !important}.mr-md-auto,.mx-md-auto{margin-right:auto !important}.mb-md-auto,.my-md-auto{margin-bottom:auto !important}.ml-md-auto,.mx-md-auto{margin-left:auto !important}}@media (min-width: 992px){.m-lg-0{margin:0 !important}.mt-lg-0,.my-lg-0{margin-top:0 !important}.mr-lg-0,.mx-lg-0{margin-right:0 !important}.mb-lg-0,.my-lg-0{margin-bottom:0 !important}.ml-lg-0,.mx-lg-0{margin-left:0 !important}.m-lg-1{margin:.25rem !important}.mt-lg-1,.my-lg-1{margin-top:.25rem !important}.mr-lg-1,.mx-lg-1{margin-right:.25rem !important}.mb-lg-1,.my-lg-1{margin-bottom:.25rem !important}.ml-lg-1,.mx-lg-1{margin-left:.25rem !important}.m-lg-2{margin:.5rem !important}.mt-lg-2,.my-lg-2{margin-top:.5rem !important}.mr-lg-2,.mx-lg-2{margin-right:.5rem !important}.mb-lg-2,.my-lg-2{margin-bottom:.5rem !important}.ml-lg-2,.mx-lg-2{margin-left:.5rem !important}.m-lg-3{margin:1rem !important}.mt-lg-3,.my-lg-3{margin-top:1rem !important}.mr-lg-3,.mx-lg-3{margin-right:1rem !important}.mb-lg-3,.my-lg-3{margin-bottom:1rem !important}.ml-lg-3,.mx-lg-3{margin-left:1rem !important}.m-lg-4{margin:1.5rem !important}.mt-lg-4,.my-lg-4{margin-top:1.5rem !important}.mr-lg-4,.mx-lg-4{margin-right:1.5rem !important}.mb-lg-4,.my-lg-4{margin-bottom:1.5rem !important}.ml-lg-4,.mx-lg-4{margin-left:1.5rem !important}.m-lg-5{margin:3rem !important}.mt-lg-5,.my-lg-5{margin-top:3rem !important}.mr-lg-5,.mx-lg-5{margin-right:3rem !important}.mb-lg-5,.my-lg-5{margin-bottom:3rem !important}.ml-lg-5,.mx-lg-5{margin-left:3rem !important}.p-lg-0{padding:0 !important}.pt-lg-0,.py-lg-0{padding-top:0 !important}.pr-lg-0,.px-lg-0{padding-right:0 !important}.pb-lg-0,.py-lg-0{padding-bottom:0 !important}.pl-lg-0,.px-lg-0{padding-left:0 !important}.p-lg-1{padding:.25rem !important}.pt-lg-1,.py-lg-1{padding-top:.25rem !important}.pr-lg-1,.px-lg-1{padding-right:.25rem !important}.pb-lg-1,.py-lg-1{padding-bottom:.25rem !important}.pl-lg-1,.px-lg-1{padding-left:.25rem !important}.p-lg-2{padding:.5rem !important}.pt-lg-2,.py-lg-2{padding-top:.5rem !important}.pr-lg-2,.px-lg-2{padding-right:.5rem !important}.pb-lg-2,.py-lg-2{padding-bottom:.5rem !important}.pl-lg-2,.px-lg-2{padding-left:.5rem !important}.p-lg-3{padding:1rem !important}.pt-lg-3,.py-lg-3{padding-top:1rem !important}.pr-lg-3,.px-lg-3{padding-right:1rem !important}.pb-lg-3,.py-lg-3{padding-bottom:1rem !important}.pl-lg-3,.px-lg-3{padding-left:1rem !important}.p-lg-4{padding:1.5rem !important}.pt-lg-4,.py-lg-4{padding-top:1.5rem !important}.pr-lg-4,.px-lg-4{padding-right:1.5rem !important}.pb-lg-4,.py-lg-4{padding-bottom:1.5rem !important}.pl-lg-4,.px-lg-4{padding-left:1.5rem !important}.p-lg-5{padding:3rem !important}.pt-lg-5,.py-lg-5{padding-top:3rem !important}.pr-lg-5,.px-lg-5{padding-right:3rem !important}.pb-lg-5,.py-lg-5{padding-bottom:3rem !important}.pl-lg-5,.px-lg-5{padding-left:3rem !important}.m-lg-n1{margin:-.25rem !important}.mt-lg-n1,.my-lg-n1{margin-top:-.25rem !important}.mr-lg-n1,.mx-lg-n1{margin-right:-.25rem !important}.mb-lg-n1,.my-lg-n1{margin-bottom:-.25rem !important}.ml-lg-n1,.mx-lg-n1{margin-left:-.25rem !important}.m-lg-n2{margin:-.5rem !important}.mt-lg-n2,.my-lg-n2{margin-top:-.5rem !important}.mr-lg-n2,.mx-lg-n2{margin-right:-.5rem !important}.mb-lg-n2,.my-lg-n2{margin-bottom:-.5rem !important}.ml-lg-n2,.mx-lg-n2{margin-left:-.5rem !important}.m-lg-n3{margin:-1rem !important}.mt-lg-n3,.my-lg-n3{margin-top:-1rem !important}.mr-lg-n3,.mx-lg-n3{margin-right:-1rem !important}.mb-lg-n3,.my-lg-n3{margin-bottom:-1rem !important}.ml-lg-n3,.mx-lg-n3{margin-left:-1rem !important}.m-lg-n4{margin:-1.5rem !important}.mt-lg-n4,.my-lg-n4{margin-top:-1.5rem !important}.mr-lg-n4,.mx-lg-n4{margin-right:-1.5rem !important}.mb-lg-n4,.my-lg-n4{margin-bottom:-1.5rem !important}.ml-lg-n4,.mx-lg-n4{margin-left:-1.5rem !important}.m-lg-n5{margin:-3rem !important}.mt-lg-n5,.my-lg-n5{margin-top:-3rem !important}.mr-lg-n5,.mx-lg-n5{margin-right:-3rem !important}.mb-lg-n5,.my-lg-n5{margin-bottom:-3rem !important}.ml-lg-n5,.mx-lg-n5{margin-left:-3rem !important}.m-lg-auto{margin:auto !important}.mt-lg-auto,.my-lg-auto{margin-top:auto !important}.mr-lg-auto,.mx-lg-auto{margin-right:auto !important}.mb-lg-auto,.my-lg-auto{margin-bottom:auto !important}.ml-lg-auto,.mx-lg-auto{margin-left:auto !important}}@media (min-width: 1200px){.m-xl-0{margin:0 !important}.mt-xl-0,.my-xl-0{margin-top:0 !important}.mr-xl-0,.mx-xl-0{margin-right:0 !important}.mb-xl-0,.my-xl-0{margin-bottom:0 !important}.ml-xl-0,.mx-xl-0{margin-left:0 !important}.m-xl-1{margin:.25rem !important}.mt-xl-1,.my-xl-1{margin-top:.25rem !important}.mr-xl-1,.mx-xl-1{margin-right:.25rem !important}.mb-xl-1,.my-xl-1{margin-bottom:.25rem !important}.ml-xl-1,.mx-xl-1{margin-left:.25rem !important}.m-xl-2{margin:.5rem !important}.mt-xl-2,.my-xl-2{margin-top:.5rem !important}.mr-xl-2,.mx-xl-2{margin-right:.5rem !important}.mb-xl-2,.my-xl-2{margin-bottom:.5rem !important}.ml-xl-2,.mx-xl-2{margin-left:.5rem !important}.m-xl-3{margin:1rem !important}.mt-xl-3,.my-xl-3{margin-top:1rem !important}.mr-xl-3,.mx-xl-3{margin-right:1rem !important}.mb-xl-3,.my-xl-3{margin-bottom:1rem !important}.ml-xl-3,.mx-xl-3{margin-left:1rem !important}.m-xl-4{margin:1.5rem !important}.mt-xl-4,.my-xl-4{margin-top:1.5rem !important}.mr-xl-4,.mx-xl-4{margin-right:1.5rem !important}.mb-xl-4,.my-xl-4{margin-bottom:1.5rem !important}.ml-xl-4,.mx-xl-4{margin-left:1.5rem !important}.m-xl-5{margin:3rem !important}.mt-xl-5,.my-xl-5{margin-top:3rem !important}.mr-xl-5,.mx-xl-5{margin-right:3rem !important}.mb-xl-5,.my-xl-5{margin-bottom:3rem !important}.ml-xl-5,.mx-xl-5{margin-left:3rem !important}.p-xl-0{padding:0 !important}.pt-xl-0,.py-xl-0{padding-top:0 !important}.pr-xl-0,.px-xl-0{padding-right:0 !important}.pb-xl-0,.py-xl-0{padding-bottom:0 !important}.pl-xl-0,.px-xl-0{padding-left:0 !important}.p-xl-1{padding:.25rem !important}.pt-xl-1,.py-xl-1{padding-top:.25rem !important}.pr-xl-1,.px-xl-1{padding-right:.25rem !important}.pb-xl-1,.py-xl-1{padding-bottom:.25rem !important}.pl-xl-1,.px-xl-1{padding-left:.25rem !important}.p-xl-2{padding:.5rem !important}.pt-xl-2,.py-xl-2{padding-top:.5rem !important}.pr-xl-2,.px-xl-2{padding-right:.5rem !important}.pb-xl-2,.py-xl-2{padding-bottom:.5rem !important}.pl-xl-2,.px-xl-2{padding-left:.5rem !important}.p-xl-3{padding:1rem !important}.pt-xl-3,.py-xl-3{padding-top:1rem !important}.pr-xl-3,.px-xl-3{padding-right:1rem !important}.pb-xl-3,.py-xl-3{padding-bottom:1rem !important}.pl-xl-3,.px-xl-3{padding-left:1rem !important}.p-xl-4{padding:1.5rem !important}.pt-xl-4,.py-xl-4{padding-top:1.5rem !important}.pr-xl-4,.px-xl-4{padding-right:1.5rem !important}.pb-xl-4,.py-xl-4{padding-bottom:1.5rem !important}.pl-xl-4,.px-xl-4{padding-left:1.5rem !important}.p-xl-5{padding:3rem !important}.pt-xl-5,.py-xl-5{padding-top:3rem !important}.pr-xl-5,.px-xl-5{padding-right:3rem !important}.pb-xl-5,.py-xl-5{padding-bottom:3rem !important}.pl-xl-5,.px-xl-5{padding-left:3rem !important}.m-xl-n1{margin:-.25rem !important}.mt-xl-n1,.my-xl-n1{margin-top:-.25rem !important}.mr-xl-n1,.mx-xl-n1{margin-right:-.25rem !important}.mb-xl-n1,.my-xl-n1{margin-bottom:-.25rem !important}.ml-xl-n1,.mx-xl-n1{margin-left:-.25rem !important}.m-xl-n2{margin:-.5rem !important}.mt-xl-n2,.my-xl-n2{margin-top:-.5rem !important}.mr-xl-n2,.mx-xl-n2{margin-right:-.5rem !important}.mb-xl-n2,.my-xl-n2{margin-bottom:-.5rem !important}.ml-xl-n2,.mx-xl-n2{margin-left:-.5rem !important}.m-xl-n3{margin:-1rem !important}.mt-xl-n3,.my-xl-n3{margin-top:-1rem !important}.mr-xl-n3,.mx-xl-n3{margin-right:-1rem !important}.mb-xl-n3,.my-xl-n3{margin-bottom:-1rem !important}.ml-xl-n3,.mx-xl-n3{margin-left:-1rem !important}.m-xl-n4{margin:-1.5rem !important}.mt-xl-n4,.my-xl-n4{margin-top:-1.5rem !important}.mr-xl-n4,.mx-xl-n4{margin-right:-1.5rem !important}.mb-xl-n4,.my-xl-n4{margin-bottom:-1.5rem !important}.ml-xl-n4,.mx-xl-n4{margin-left:-1.5rem !important}.m-xl-n5{margin:-3rem !important}.mt-xl-n5,.my-xl-n5{margin-top:-3rem !important}.mr-xl-n5,.mx-xl-n5{margin-right:-3rem !important}.mb-xl-n5,.my-xl-n5{margin-bottom:-3rem !important}.ml-xl-n5,.mx-xl-n5{margin-left:-3rem !important}.m-xl-auto{margin:auto !important}.mt-xl-auto,.my-xl-auto{margin-top:auto !important}.mr-xl-auto,.mx-xl-auto{margin-right:auto !important}.mb-xl-auto,.my-xl-auto{margin-bottom:auto !important}.ml-xl-auto,.mx-xl-auto{margin-left:auto !important}}.text-monospace{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace !important}.text-justify{text-align:justify !important}.text-wrap{white-space:normal !important}.text-nowrap,.card-admin-table th,.card-admin-table .badges-list{white-space:nowrap !important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-left{text-align:left !important}.text-right,.card-admin-table .badges-list{text-align:right !important}.text-center,.control-image-preview,.card-admin-stat,.card-admin-analytics-summary,.card-admin-table .row-select label,.card-admin-table .blankslate td{text-align:center !important}@media (min-width: 576px){.text-sm-left{text-align:left !important}.text-sm-right{text-align:right !important}.text-sm-center{text-align:center !important}}@media (min-width: 768px){.text-md-left{text-align:left !important}.text-md-right{text-align:right !important}.text-md-center{text-align:center !important}}@media (min-width: 992px){.text-lg-left{text-align:left !important}.text-lg-right{text-align:right !important}.text-lg-center{text-align:center !important}}@media (min-width: 1200px){.text-xl-left{text-align:left !important}.text-xl-right{text-align:right !important}.text-xl-center{text-align:center !important}}.text-lowercase{text-transform:lowercase !important}.text-uppercase{text-transform:uppercase !important}.text-capitalize{text-transform:capitalize !important}.font-weight-light{font-weight:300 !important}.font-weight-lighter{font-weight:lighter !important}.font-weight-normal{font-weight:400 !important}.font-weight-bold,.card-admin-table .item-name,.card-admin-table h5{font-weight:700 !important}.font-weight-bolder{font-weight:bolder !important}.font-italic{font-style:italic !important}.text-white{color:#fff !important}.text-primary{color:#6554c0 !important}a.text-primary:hover,a.text-primary:focus{color:#443692 !important}.text-secondary{color:#a5adba !important}a.text-secondary:hover,a.text-secondary:focus{color:#7a8699 !important}.text-success{color:#36b37e !important}a.text-success:hover,a.text-success:focus{color:#247855 !important}.text-info{color:#00b8d9 !important}a.text-info:hover,a.text-info:focus{color:#00778d !important}.text-warning{color:#ffc107 !important}a.text-warning:hover,a.text-warning:focus{color:#ba8b00 !important}.text-danger{color:#ff5630 !important}a.text-danger:hover,a.text-danger:focus{color:#e32a00 !important}.text-light{color:#ebecf0 !important}a.text-light:hover,a.text-light:focus{color:#bfc2cf !important}.text-dark{color:#505f79 !important}a.text-dark:hover,a.text-dark:focus{color:#323b4b !important}.text-body{color:#172b4d !important}.text-muted,.control-time-picker span,.control-image-preview div,.page-header h1 small{color:#a5adba !important}.text-black-50{color:rgba(9,30,66,0.5) !important}.text-white-50{color:rgba(255,255,255,0.5) !important}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.text-decoration-none{text-decoration:none !important}.text-break{word-break:break-word !important;overflow-wrap:break-word !important}.text-reset{color:inherit !important}.visible{visibility:visible !important}.invisible{visibility:hidden !important}html,body{height:100%}.control-checkboxselect .form-check,.control-radioselect .form-check{padding:0}.control-checkboxselect .form-check-label,.control-radioselect .form-check-label{font-size:.875rem}.control-month-picker{width:350px}.control-time-picker{width:280px;height:100%;font-size:4rem}.control-time-picker .row{height:100%}.control-time-picker input{font-size:4rem}.control-time-picker span{position:relative;bottom:.25rem}.control-image-preview{background-color:#fff}.control-image-preview img{max-width:300px;max-height:200px}.control-image-preview div{font-size:.875rem}.login-form{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center}.login-form-container{width:100%;padding:30px}.login-form-logo{margin-bottom:2rem;text-align:center}.login-form-logo img{max-width:200px;max-height:64px}.login-form-card{max-width:340px;margin:0 auto}.login-form-title{font-size:1.25rem;text-align:center}.navbar-brand{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center}.navbar-brand img{height:2rem;margin-right:.5rem;border-radius:.25rem}.navbar .btn-user{overflow:hidden}.navbar .btn-user img{height:2rem}.col-nav-side{max-width:240px}.nav-side .media-icon{width:30px;text-align:center}.nav-side .nav-link.active{color:#091e42}.nav-side .nav-link.active .media-icon{color:#6554c0}.nav-side .nav-section.active{font-weight:700}.nav-side .nav-action .media-body{margin-left:30px}.page-header h1{font-size:1.25rem}.page-header h1 a{color:#172b4d}.page-header h1 small{font-size:1.25rem;vertical-align:middle}.card-admin-settings-card{color:#172b4d}.card-admin-settings-card:hover,.card-admin-settings-card:focus{text-decoration:none;background-color:#ebecf0}.card-admin-settings-card h5{font-size:1rem}.card-admin-info .card-title{font-size:1.25rem}.card-admin-info .card-body,.card-admin-info .card-admin-form .form-fieldset,.card-admin-form .card-admin-info .form-fieldset{padding:.75rem}.card-admin-stat{font-size:1.25rem}.media-admin-check{font-size:.875rem}.media-admin-check h5{font-size:1rem}.media-check-icon{width:1.5rem;height:1.5rem;font-size:1rem;line-height:1rem;color:#fff;background:#6b778c}.media-check-icon.media-check-icon-warning{background:#ffab00}.media-check-icon.media-check-icon-danger{background:#ff5630}.media-check-icon.media-check-icon-success{background:#36b37e}.media-check-icon .spinner-border{width:1rem;height:1rem}.card-admin-analytics-summary{width:200px}.card-admin-analytics-summary div{font-size:2rem}.card-admin-analytics-summary small{font-size:1rem}.card-admin-analytics-chart{margin-top:-10px}.card-admin-table .card-title{font-size:1.25rem}.card-admin-table th,.card-admin-table td{vertical-align:middle}.card-admin-table th{font-size:.875rem;color:#6b778c;background-color:rgba(0,0,0,0)}.card-admin-table .row-select input,.card-admin-table th input{font-size:1.25rem}.card-admin-table .card-body,.card-admin-table .card-admin-form .form-fieldset,.card-admin-form .card-admin-table .form-fieldset{padding:.75rem}.card-admin-table table+.card-body,.card-admin-table .card-admin-form table+.form-fieldset,.card-admin-form .card-admin-table table+.form-fieldset{border-top:1px solid #dfe1e6}.card-admin-table .btn-thumbnail{width:2rem;height:2rem;padding:0;background-color:transparent;background-size:cover}.card-admin-table .item-name{color:#172b4d}.card-admin-table h5{font-size:.875rem;line-height:1.5}.card-admin-table h5 a{color:#172b4d}.card-admin-table .badges-list{width:0%}.card-admin-table .row-select{width:1px}.card-admin-table [data-timestamp]{text-decoration:none}.card-admin-form .card-header h5{font-size:1.25rem}.card-admin-form .form-fieldset{margin:0 -1.25rem}.login-error{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center}.login-error-container{width:100%;padding:30px}.login-error-card{max-width:540px;margin:0 auto}.login-error-title{font-size:1.25rem;text-align:center}
+:root{--blue: #0052cc;--indigo: #6610f2;--purple: #6554c0;--pink: #e83e8c;--red: #ff5630;--orange: #ffab00;--yellow: #ffc107;--green: #36b37e;--teal: #20c997;--cyan: #00b8d9;--white: #fff;--gray: #a5adba;--gray-dark: #505f79;--primary: #6554c0;--secondary: #a5adba;--success: #36b37e;--info: #00b8d9;--warning: #ffc107;--danger: #ff5630;--light: #ebecf0;--dark: #505f79;--breakpoint-xs: 0;--breakpoint-sm: 576px;--breakpoint-md: 768px;--breakpoint-lg: 992px;--breakpoint-xl: 1200px;--font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace}*,*::before,*::after{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(9,30,66,0)}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#172b4d;text-align:left;background-color:#f4f5f7}[tabindex="-1"]:focus{outline:0 !important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[title],abbr[data-original-title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul,dl{margin-top:0;margin-bottom:1rem}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#6b778c;text-decoration:none;background-color:transparent}a:hover{color:#172b4d;text-decoration:underline}a:not([href]):not([tabindex]){color:inherit;text-decoration:none}a:not([href]):not([tabindex]):hover,a:not([href]):not([tabindex]):focus{color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus{outline:0}pre,code,kbd,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#a5adba;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}input,button,select,optgroup,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}select{word-wrap:normal}button,[type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button}button:not(:disabled),[type="button"]:not(:disabled),[type="reset"]:not(:disabled),[type="submit"]:not(:disabled){cursor:pointer}button::-moz-focus-inner,[type="button"]::-moz-focus-inner,[type="reset"]::-moz-focus-inner,[type="submit"]::-moz-focus-inner{padding:0;border-style:none}input[type="radio"],input[type="checkbox"]{box-sizing:border-box;padding:0}input[type="date"],input[type="time"],input[type="datetime-local"],input[type="month"]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type="number"]::-webkit-inner-spin-button,[type="number"]::-webkit-outer-spin-button{height:auto}[type="search"]{outline-offset:-2px;-webkit-appearance:none}[type="search"]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none !important}h1,h2,h3,h4,h5,h6,.h1,.h2,.h3,.h4,.h5,.h6{margin-bottom:.5rem;font-weight:500;line-height:1.2}h1,.h1{font-size:2.5rem}h2,.h2{font-size:2rem}h3,.h3{font-size:1.75rem}h4,.h4{font-size:1.5rem}h5,.h5{font-size:1.25rem}h6,.h6{font-size:1rem}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:6rem;font-weight:300;line-height:1.2}.display-2{font-size:5.5rem;font-weight:300;line-height:1.2}.display-3{font-size:4.5rem;font-weight:300;line-height:1.2}.display-4{font-size:3.5rem;font-weight:300;line-height:1.2}hr{margin-top:1rem;margin-bottom:1rem;border:0;border-top:1px solid rgba(9,30,66,0.1)}small,.small{font-size:80%;font-weight:400}mark,.mark{padding:.2em;background-color:#fcf8e3}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:90%;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote-footer{display:block;font-size:80%;color:#a5adba}.blockquote-footer::before{content:"\2014\00A0"}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#f4f5f7;border:1px solid #dfe1e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:90%;color:#a5adba}code{font-size:87.5%;color:#e83e8c;word-break:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:87.5%;color:#fff;background-color:#172b4d;border-radius:.2rem}kbd kbd{padding:0;font-size:100%;font-weight:700}pre{display:block;font-size:87.5%;color:#172b4d}pre code{font-size:inherit;color:inherit;word-break:normal}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width: 576px){.container{max-width:540px}}@media (min-width: 768px){.container{max-width:720px}}@media (min-width: 992px){.container{max-width:960px}}@media (min-width: 1200px){.container{max-width:1140px}}.container-fluid{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.no-gutters{margin-right:0;margin-left:0}.no-gutters>.col,.no-gutters>[class*="col-"]{padding-right:0;padding-left:0}.col-1,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-10,.col-11,.col-12,.col,.col-auto,.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm,.col-sm-auto,.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12,.col-md,.col-md-auto,.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg,.col-lg-auto,.col-xl-1,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl,.col-xl-auto{position:relative;width:100%;padding-right:15px;padding-left:15px}.col{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-1{-ms-flex:0 0 8.33333%;flex:0 0 8.33333%;max-width:8.33333%}.col-2{-ms-flex:0 0 16.66667%;flex:0 0 16.66667%;max-width:16.66667%}.col-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-4{-ms-flex:0 0 33.33333%;flex:0 0 33.33333%;max-width:33.33333%}.col-5{-ms-flex:0 0 41.66667%;flex:0 0 41.66667%;max-width:41.66667%}.col-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-7{-ms-flex:0 0 58.33333%;flex:0 0 58.33333%;max-width:58.33333%}.col-8{-ms-flex:0 0 66.66667%;flex:0 0 66.66667%;max-width:66.66667%}.col-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-10{-ms-flex:0 0 83.33333%;flex:0 0 83.33333%;max-width:83.33333%}.col-11{-ms-flex:0 0 91.66667%;flex:0 0 91.66667%;max-width:91.66667%}.col-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-first{-ms-flex-order:-1;order:-1}.order-last{-ms-flex-order:13;order:13}.order-0{-ms-flex-order:0;order:0}.order-1{-ms-flex-order:1;order:1}.order-2{-ms-flex-order:2;order:2}.order-3{-ms-flex-order:3;order:3}.order-4{-ms-flex-order:4;order:4}.order-5{-ms-flex-order:5;order:5}.order-6{-ms-flex-order:6;order:6}.order-7{-ms-flex-order:7;order:7}.order-8{-ms-flex-order:8;order:8}.order-9{-ms-flex-order:9;order:9}.order-10{-ms-flex-order:10;order:10}.order-11{-ms-flex-order:11;order:11}.order-12{-ms-flex-order:12;order:12}.offset-1{margin-left:8.33333%}.offset-2{margin-left:16.66667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333%}.offset-5{margin-left:41.66667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333%}.offset-8{margin-left:66.66667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333%}.offset-11{margin-left:91.66667%}@media (min-width: 576px){.col-sm{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-sm-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-sm-1{-ms-flex:0 0 8.33333%;flex:0 0 8.33333%;max-width:8.33333%}.col-sm-2{-ms-flex:0 0 16.66667%;flex:0 0 16.66667%;max-width:16.66667%}.col-sm-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-sm-4{-ms-flex:0 0 33.33333%;flex:0 0 33.33333%;max-width:33.33333%}.col-sm-5{-ms-flex:0 0 41.66667%;flex:0 0 41.66667%;max-width:41.66667%}.col-sm-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-sm-7{-ms-flex:0 0 58.33333%;flex:0 0 58.33333%;max-width:58.33333%}.col-sm-8{-ms-flex:0 0 66.66667%;flex:0 0 66.66667%;max-width:66.66667%}.col-sm-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-sm-10{-ms-flex:0 0 83.33333%;flex:0 0 83.33333%;max-width:83.33333%}.col-sm-11{-ms-flex:0 0 91.66667%;flex:0 0 91.66667%;max-width:91.66667%}.col-sm-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-sm-first{-ms-flex-order:-1;order:-1}.order-sm-last{-ms-flex-order:13;order:13}.order-sm-0{-ms-flex-order:0;order:0}.order-sm-1{-ms-flex-order:1;order:1}.order-sm-2{-ms-flex-order:2;order:2}.order-sm-3{-ms-flex-order:3;order:3}.order-sm-4{-ms-flex-order:4;order:4}.order-sm-5{-ms-flex-order:5;order:5}.order-sm-6{-ms-flex-order:6;order:6}.order-sm-7{-ms-flex-order:7;order:7}.order-sm-8{-ms-flex-order:8;order:8}.order-sm-9{-ms-flex-order:9;order:9}.order-sm-10{-ms-flex-order:10;order:10}.order-sm-11{-ms-flex-order:11;order:11}.order-sm-12{-ms-flex-order:12;order:12}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333%}.offset-sm-2{margin-left:16.66667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333%}.offset-sm-5{margin-left:41.66667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333%}.offset-sm-8{margin-left:66.66667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333%}.offset-sm-11{margin-left:91.66667%}}@media (min-width: 768px){.col-md{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-md-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-md-1{-ms-flex:0 0 8.33333%;flex:0 0 8.33333%;max-width:8.33333%}.col-md-2{-ms-flex:0 0 16.66667%;flex:0 0 16.66667%;max-width:16.66667%}.col-md-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-md-4{-ms-flex:0 0 33.33333%;flex:0 0 33.33333%;max-width:33.33333%}.col-md-5{-ms-flex:0 0 41.66667%;flex:0 0 41.66667%;max-width:41.66667%}.col-md-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-md-7{-ms-flex:0 0 58.33333%;flex:0 0 58.33333%;max-width:58.33333%}.col-md-8{-ms-flex:0 0 66.66667%;flex:0 0 66.66667%;max-width:66.66667%}.col-md-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-md-10{-ms-flex:0 0 83.33333%;flex:0 0 83.33333%;max-width:83.33333%}.col-md-11{-ms-flex:0 0 91.66667%;flex:0 0 91.66667%;max-width:91.66667%}.col-md-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-md-first{-ms-flex-order:-1;order:-1}.order-md-last{-ms-flex-order:13;order:13}.order-md-0{-ms-flex-order:0;order:0}.order-md-1{-ms-flex-order:1;order:1}.order-md-2{-ms-flex-order:2;order:2}.order-md-3{-ms-flex-order:3;order:3}.order-md-4{-ms-flex-order:4;order:4}.order-md-5{-ms-flex-order:5;order:5}.order-md-6{-ms-flex-order:6;order:6}.order-md-7{-ms-flex-order:7;order:7}.order-md-8{-ms-flex-order:8;order:8}.order-md-9{-ms-flex-order:9;order:9}.order-md-10{-ms-flex-order:10;order:10}.order-md-11{-ms-flex-order:11;order:11}.order-md-12{-ms-flex-order:12;order:12}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333%}.offset-md-2{margin-left:16.66667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333%}.offset-md-5{margin-left:41.66667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333%}.offset-md-8{margin-left:66.66667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333%}.offset-md-11{margin-left:91.66667%}}@media (min-width: 992px){.col-lg{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-lg-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-lg-1{-ms-flex:0 0 8.33333%;flex:0 0 8.33333%;max-width:8.33333%}.col-lg-2{-ms-flex:0 0 16.66667%;flex:0 0 16.66667%;max-width:16.66667%}.col-lg-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-lg-4{-ms-flex:0 0 33.33333%;flex:0 0 33.33333%;max-width:33.33333%}.col-lg-5{-ms-flex:0 0 41.66667%;flex:0 0 41.66667%;max-width:41.66667%}.col-lg-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-lg-7{-ms-flex:0 0 58.33333%;flex:0 0 58.33333%;max-width:58.33333%}.col-lg-8{-ms-flex:0 0 66.66667%;flex:0 0 66.66667%;max-width:66.66667%}.col-lg-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-lg-10{-ms-flex:0 0 83.33333%;flex:0 0 83.33333%;max-width:83.33333%}.col-lg-11{-ms-flex:0 0 91.66667%;flex:0 0 91.66667%;max-width:91.66667%}.col-lg-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-lg-first{-ms-flex-order:-1;order:-1}.order-lg-last{-ms-flex-order:13;order:13}.order-lg-0{-ms-flex-order:0;order:0}.order-lg-1{-ms-flex-order:1;order:1}.order-lg-2{-ms-flex-order:2;order:2}.order-lg-3{-ms-flex-order:3;order:3}.order-lg-4{-ms-flex-order:4;order:4}.order-lg-5{-ms-flex-order:5;order:5}.order-lg-6{-ms-flex-order:6;order:6}.order-lg-7{-ms-flex-order:7;order:7}.order-lg-8{-ms-flex-order:8;order:8}.order-lg-9{-ms-flex-order:9;order:9}.order-lg-10{-ms-flex-order:10;order:10}.order-lg-11{-ms-flex-order:11;order:11}.order-lg-12{-ms-flex-order:12;order:12}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333%}.offset-lg-2{margin-left:16.66667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333%}.offset-lg-5{margin-left:41.66667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333%}.offset-lg-8{margin-left:66.66667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333%}.offset-lg-11{margin-left:91.66667%}}@media (min-width: 1200px){.col-xl{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-xl-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-xl-1{-ms-flex:0 0 8.33333%;flex:0 0 8.33333%;max-width:8.33333%}.col-xl-2{-ms-flex:0 0 16.66667%;flex:0 0 16.66667%;max-width:16.66667%}.col-xl-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-xl-4{-ms-flex:0 0 33.33333%;flex:0 0 33.33333%;max-width:33.33333%}.col-xl-5{-ms-flex:0 0 41.66667%;flex:0 0 41.66667%;max-width:41.66667%}.col-xl-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-xl-7{-ms-flex:0 0 58.33333%;flex:0 0 58.33333%;max-width:58.33333%}.col-xl-8{-ms-flex:0 0 66.66667%;flex:0 0 66.66667%;max-width:66.66667%}.col-xl-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-xl-10{-ms-flex:0 0 83.33333%;flex:0 0 83.33333%;max-width:83.33333%}.col-xl-11{-ms-flex:0 0 91.66667%;flex:0 0 91.66667%;max-width:91.66667%}.col-xl-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-xl-first{-ms-flex-order:-1;order:-1}.order-xl-last{-ms-flex-order:13;order:13}.order-xl-0{-ms-flex-order:0;order:0}.order-xl-1{-ms-flex-order:1;order:1}.order-xl-2{-ms-flex-order:2;order:2}.order-xl-3{-ms-flex-order:3;order:3}.order-xl-4{-ms-flex-order:4;order:4}.order-xl-5{-ms-flex-order:5;order:5}.order-xl-6{-ms-flex-order:6;order:6}.order-xl-7{-ms-flex-order:7;order:7}.order-xl-8{-ms-flex-order:8;order:8}.order-xl-9{-ms-flex-order:9;order:9}.order-xl-10{-ms-flex-order:10;order:10}.order-xl-11{-ms-flex-order:11;order:11}.order-xl-12{-ms-flex-order:12;order:12}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333%}.offset-xl-2{margin-left:16.66667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333%}.offset-xl-5{margin-left:41.66667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333%}.offset-xl-8{margin-left:66.66667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333%}.offset-xl-11{margin-left:91.66667%}}.table{width:100%;margin-bottom:1rem;color:#172b4d}.table th,.table td{padding:.75rem;vertical-align:top;border-top:1px solid #dfe1e6}.table thead th{vertical-align:bottom;border-bottom:2px solid #dfe1e6}.table tbody+tbody{border-top:2px solid #dfe1e6}.table-sm th,.table-sm td{padding:.3rem}.table-bordered{border:1px solid #dfe1e6}.table-bordered th,.table-bordered td{border:1px solid #dfe1e6}.table-bordered thead th,.table-bordered thead td{border-bottom-width:2px}.table-borderless th,.table-borderless td,.table-borderless thead th,.table-borderless tbody+tbody{border:0}.table-striped tbody tr:nth-of-type(odd){background-color:rgba(9,30,66,0.05)}.table-hover tbody tr:hover{color:#172b4d;background-color:rgba(9,30,66,0.075)}.table-primary,.table-primary>th,.table-primary>td{background-color:#d4cfed}.table-primary th,.table-primary td,.table-primary thead th,.table-primary tbody+tbody{border-color:#afa6de}.table-hover .table-primary:hover{background-color:#c3bce6}.table-hover .table-primary:hover>td,.table-hover .table-primary:hover>th{background-color:#c3bce6}.table-secondary,.table-secondary>th,.table-secondary>td{background-color:#e6e8ec}.table-secondary th,.table-secondary td,.table-secondary thead th,.table-secondary tbody+tbody{border-color:#d0d4db}.table-hover .table-secondary:hover{background-color:#d8dbe1}.table-hover .table-secondary:hover>td,.table-hover .table-secondary:hover>th{background-color:#d8dbe1}.table-success,.table-success>th,.table-success>td{background-color:#c7eadb}.table-success th,.table-success td,.table-success thead th,.table-success tbody+tbody{border-color:#96d7bc}.table-hover .table-success:hover{background-color:#b4e3cf}.table-hover .table-success:hover>td,.table-hover .table-success:hover>th{background-color:#b4e3cf}.table-info,.table-info>th,.table-info>td{background-color:#b8ebf4}.table-info th,.table-info td,.table-info thead th,.table-info tbody+tbody{border-color:#7adaeb}.table-hover .table-info:hover{background-color:#a2e5f1}.table-hover .table-info:hover>td,.table-hover .table-info:hover>th{background-color:#a2e5f1}.table-warning,.table-warning>th,.table-warning>td{background-color:#ffeeba}.table-warning th,.table-warning td,.table-warning thead th,.table-warning tbody+tbody{border-color:#ffdf7e}.table-hover .table-warning:hover{background-color:#ffe8a1}.table-hover .table-warning:hover>td,.table-hover .table-warning:hover>th{background-color:#ffe8a1}.table-danger,.table-danger>th,.table-danger>td{background-color:#ffd0c5}.table-danger th,.table-danger td,.table-danger thead th,.table-danger tbody+tbody{border-color:#ffa793}.table-hover .table-danger:hover{background-color:#ffbbac}.table-hover .table-danger:hover>td,.table-hover .table-danger:hover>th{background-color:#ffbbac}.table-light,.table-light>th,.table-light>td{background-color:#f9fafb}.table-light th,.table-light td,.table-light thead th,.table-light tbody+tbody{border-color:#f5f5f7}.table-hover .table-light:hover{background-color:#eaedf1}.table-hover .table-light:hover>td,.table-hover .table-light:hover>th{background-color:#eaedf1}.table-dark,.table-dark>th,.table-dark>td{background-color:#ced2d9}.table-dark th,.table-dark td,.table-dark thead th,.table-dark tbody+tbody{border-color:#a4acb9}.table-hover .table-dark:hover{background-color:#c0c5ce}.table-hover .table-dark:hover>td,.table-hover .table-dark:hover>th{background-color:#c0c5ce}.table-active,.table-active>th,.table-active>td{background-color:rgba(9,30,66,0.075)}.table-hover .table-active:hover{background-color:rgba(6,20,44,0.075)}.table-hover .table-active:hover>td,.table-hover .table-active:hover>th{background-color:rgba(6,20,44,0.075)}.table .thead-dark th{color:#fff;background-color:#505f79;border-color:#5f7190}.table .thead-light th{color:#6b778c;background-color:rgba(0,0,0,0);border-color:#dfe1e6}.table-dark{color:#fff;background-color:#505f79}.table-dark th,.table-dark td,.table-dark thead th{border-color:#5f7190}.table-dark.table-bordered{border:0}.table-dark.table-striped tbody tr:nth-of-type(odd){background-color:rgba(255,255,255,0.05)}.table-dark.table-hover tbody tr:hover{color:#fff;background-color:rgba(255,255,255,0.075)}@media (max-width: 575.98px){.table-responsive-sm{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-sm>.table-bordered{border:0}}@media (max-width: 767.98px){.table-responsive-md{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-md>.table-bordered{border:0}}@media (max-width: 991.98px){.table-responsive-lg{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-lg>.table-bordered{border:0}}@media (max-width: 1199.98px){.table-responsive-xl{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-xl>.table-bordered{border:0}}.table-responsive{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive>.table-bordered{border:0}.form-control{display:block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#6b778c;background-color:#fff;background-clip:padding-box;border:1px solid #c1c7d0;border-radius:.25rem;transition:border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.form-control{transition:none}}.form-control::-ms-expand{background-color:transparent;border:0}.form-control:focus{color:#6b778c;background-color:#fff;border-color:#1851b2;outline:0;box-shadow:0 0 0 .2rem rgba(0,82,204,0.15)}.form-control::-webkit-input-placeholder{color:#a5adba;opacity:1}.form-control::-moz-placeholder{color:#a5adba;opacity:1}.form-control:-ms-input-placeholder{color:#a5adba;opacity:1}.form-control::-ms-input-placeholder{color:#a5adba;opacity:1}.form-control::placeholder{color:#a5adba;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#ebecf0;opacity:1}select.form-control:focus::-ms-value{color:#6b778c;background-color:#fff}.form-control-file,.form-control-range{display:block;width:100%}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem;line-height:1.5}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem;line-height:1.5}.form-control-plaintext{display:block;width:100%;padding-top:.375rem;padding-bottom:.375rem;margin-bottom:0;line-height:1.5;color:#172b4d;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-sm,.form-control-plaintext.form-control-lg{padding-right:0;padding-left:0}.form-control-sm{height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.form-control-lg{height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}select.form-control[size],select.form-control[multiple]{height:auto}textarea.form-control{height:auto}.form-group{margin-bottom:1rem}.form-text{display:block;margin-top:.25rem}.form-row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-5px;margin-left:-5px}.form-row>.col,.form-row>[class*="col-"]{padding-right:5px;padding-left:5px}.form-check{position:relative;display:block;padding-left:1.25rem}.form-check-input{position:absolute;margin-top:.3rem;margin-left:-1.25rem}.form-check-input:disabled ~ .form-check-label{color:#a5adba}.form-check-label{margin-bottom:0}.form-check-inline{display:-ms-inline-flexbox;display:inline-flex;-ms-flex-align:center;align-items:center;padding-left:0;margin-right:.75rem}.form-check-inline .form-check-input{position:static;margin-top:0;margin-right:.3125rem;margin-left:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#36b37e}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;line-height:1.5;color:#fff;background-color:rgba(54,179,126,0.9);border-radius:.25rem}.was-validated .form-control:valid,.form-control.is-valid{border-color:#36b37e;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2336b37e' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:center right calc(.375em + .1875rem);background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.was-validated .form-control:valid:focus,.form-control.is-valid:focus{border-color:#36b37e;box-shadow:0 0 0 .2rem rgba(54,179,126,0.25)}.was-validated .form-control:valid ~ .valid-feedback,.was-validated .form-control:valid ~ .valid-tooltip,.form-control.is-valid ~ .valid-feedback,.form-control.is-valid ~ .valid-tooltip{display:block}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.was-validated .custom-select:valid,.custom-select.is-valid{border-color:#36b37e;padding-right:calc((1em + .75rem) * 3 / 4 + 1.75rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23505f79' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2336b37e' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem)}.was-validated .custom-select:valid:focus,.custom-select.is-valid:focus{border-color:#36b37e;box-shadow:0 0 0 .2rem rgba(54,179,126,0.25)}.was-validated .custom-select:valid ~ .valid-feedback,.was-validated .custom-select:valid ~ .valid-tooltip,.custom-select.is-valid ~ .valid-feedback,.custom-select.is-valid ~ .valid-tooltip{display:block}.was-validated .form-control-file:valid ~ .valid-feedback,.was-validated .form-control-file:valid ~ .valid-tooltip,.form-control-file.is-valid ~ .valid-feedback,.form-control-file.is-valid ~ .valid-tooltip{display:block}.was-validated .form-check-input:valid ~ .form-check-label,.form-check-input.is-valid ~ .form-check-label{color:#36b37e}.was-validated .form-check-input:valid ~ .valid-feedback,.was-validated .form-check-input:valid ~ .valid-tooltip,.form-check-input.is-valid ~ .valid-feedback,.form-check-input.is-valid ~ .valid-tooltip{display:block}.was-validated .custom-control-input:valid ~ .custom-control-label,.custom-control-input.is-valid ~ .custom-control-label{color:#36b37e}.was-validated .custom-control-input:valid ~ .custom-control-label::before,.custom-control-input.is-valid ~ .custom-control-label::before{border-color:#36b37e}.was-validated .custom-control-input:valid ~ .valid-feedback,.was-validated .custom-control-input:valid ~ .valid-tooltip,.custom-control-input.is-valid ~ .valid-feedback,.custom-control-input.is-valid ~ .valid-tooltip{display:block}.was-validated .custom-control-input:valid:checked ~ .custom-control-label::before,.custom-control-input.is-valid:checked ~ .custom-control-label::before{border-color:#51cb97;background-color:#51cb97}.was-validated .custom-control-input:valid:focus ~ .custom-control-label::before,.custom-control-input.is-valid:focus ~ .custom-control-label::before{box-shadow:0 0 0 .2rem rgba(54,179,126,0.25)}.was-validated .custom-control-input:valid:focus:not(:checked) ~ .custom-control-label::before,.custom-control-input.is-valid:focus:not(:checked) ~ .custom-control-label::before{border-color:#36b37e}.was-validated .custom-file-input:valid ~ .custom-file-label,.custom-file-input.is-valid ~ .custom-file-label{border-color:#36b37e}.was-validated .custom-file-input:valid ~ .valid-feedback,.was-validated .custom-file-input:valid ~ .valid-tooltip,.custom-file-input.is-valid ~ .valid-feedback,.custom-file-input.is-valid ~ .valid-tooltip{display:block}.was-validated .custom-file-input:valid:focus ~ .custom-file-label,.custom-file-input.is-valid:focus ~ .custom-file-label{border-color:#36b37e;box-shadow:0 0 0 .2rem rgba(54,179,126,0.25)}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#ff5630}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;line-height:1.5;color:#fff;background-color:rgba(255,86,48,0.9);border-radius:.25rem}.was-validated .form-control:invalid,.form-control.is-invalid{border-color:#ff5630;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23ff5630' viewBox='-2 -2 7 7'%3e%3cpath stroke='%23ff5630' d='M0 0l3 3m0-3L0 3'/%3e%3ccircle r='.5'/%3e%3ccircle cx='3' r='.5'/%3e%3ccircle cy='3' r='.5'/%3e%3ccircle cx='3' cy='3' r='.5'/%3e%3c/svg%3E");background-repeat:no-repeat;background-position:center right calc(.375em + .1875rem);background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.was-validated .form-control:invalid:focus,.form-control.is-invalid:focus{border-color:#ff5630;box-shadow:0 0 0 .2rem rgba(255,86,48,0.25)}.was-validated .form-control:invalid ~ .invalid-feedback,.was-validated .form-control:invalid ~ .invalid-tooltip,.form-control.is-invalid ~ .invalid-feedback,.form-control.is-invalid ~ .invalid-tooltip{display:block}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.was-validated .custom-select:invalid,.custom-select.is-invalid{border-color:#ff5630;padding-right:calc((1em + .75rem) * 3 / 4 + 1.75rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23505f79' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23ff5630' viewBox='-2 -2 7 7'%3e%3cpath stroke='%23ff5630' d='M0 0l3 3m0-3L0 3'/%3e%3ccircle r='.5'/%3e%3ccircle cx='3' r='.5'/%3e%3ccircle cy='3' r='.5'/%3e%3ccircle cx='3' cy='3' r='.5'/%3e%3c/svg%3E") #fff no-repeat center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem)}.was-validated .custom-select:invalid:focus,.custom-select.is-invalid:focus{border-color:#ff5630;box-shadow:0 0 0 .2rem rgba(255,86,48,0.25)}.was-validated .custom-select:invalid ~ .invalid-feedback,.was-validated .custom-select:invalid ~ .invalid-tooltip,.custom-select.is-invalid ~ .invalid-feedback,.custom-select.is-invalid ~ .invalid-tooltip{display:block}.was-validated .form-control-file:invalid ~ .invalid-feedback,.was-validated .form-control-file:invalid ~ .invalid-tooltip,.form-control-file.is-invalid ~ .invalid-feedback,.form-control-file.is-invalid ~ .invalid-tooltip{display:block}.was-validated .form-check-input:invalid ~ .form-check-label,.form-check-input.is-invalid ~ .form-check-label{color:#ff5630}.was-validated .form-check-input:invalid ~ .invalid-feedback,.was-validated .form-check-input:invalid ~ .invalid-tooltip,.form-check-input.is-invalid ~ .invalid-feedback,.form-check-input.is-invalid ~ .invalid-tooltip{display:block}.was-validated .custom-control-input:invalid ~ .custom-control-label,.custom-control-input.is-invalid ~ .custom-control-label{color:#ff5630}.was-validated .custom-control-input:invalid ~ .custom-control-label::before,.custom-control-input.is-invalid ~ .custom-control-label::before{border-color:#ff5630}.was-validated .custom-control-input:invalid ~ .invalid-feedback,.was-validated .custom-control-input:invalid ~ .invalid-tooltip,.custom-control-input.is-invalid ~ .invalid-feedback,.custom-control-input.is-invalid ~ .invalid-tooltip{display:block}.was-validated .custom-control-input:invalid:checked ~ .custom-control-label::before,.custom-control-input.is-invalid:checked ~ .custom-control-label::before{border-color:#ff8063;background-color:#ff8063}.was-validated .custom-control-input:invalid:focus ~ .custom-control-label::before,.custom-control-input.is-invalid:focus ~ .custom-control-label::before{box-shadow:0 0 0 .2rem rgba(255,86,48,0.25)}.was-validated .custom-control-input:invalid:focus:not(:checked) ~ .custom-control-label::before,.custom-control-input.is-invalid:focus:not(:checked) ~ .custom-control-label::before{border-color:#ff5630}.was-validated .custom-file-input:invalid ~ .custom-file-label,.custom-file-input.is-invalid ~ .custom-file-label{border-color:#ff5630}.was-validated .custom-file-input:invalid ~ .invalid-feedback,.was-validated .custom-file-input:invalid ~ .invalid-tooltip,.custom-file-input.is-invalid ~ .invalid-feedback,.custom-file-input.is-invalid ~ .invalid-tooltip{display:block}.was-validated .custom-file-input:invalid:focus ~ .custom-file-label,.custom-file-input.is-invalid:focus ~ .custom-file-label{border-color:#ff5630;box-shadow:0 0 0 .2rem rgba(255,86,48,0.25)}.form-inline{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center}.form-inline .form-check{width:100%}@media (min-width: 576px){.form-inline label{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;margin-bottom:0}.form-inline .form-group{display:-ms-flexbox;display:flex;-ms-flex:0 0 auto;flex:0 0 auto;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center;margin-bottom:0}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-plaintext{display:inline-block}.form-inline .input-group,.form-inline .custom-select{width:auto}.form-inline .form-check{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:auto;padding-left:0}.form-inline .form-check-input{position:relative;-ms-flex-negative:0;flex-shrink:0;margin-top:0;margin-right:.25rem;margin-left:0}.form-inline .custom-control{-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center}.form-inline .custom-control-label{margin-bottom:0}}.btn{display:inline-block;font-weight:400;color:#172b4d;text-align:center;vertical-align:middle;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;line-height:1.5;border-radius:.25rem;transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.btn{transition:none}}.btn:hover{color:#172b4d;text-decoration:none}.btn:focus,.btn.focus{outline:0;box-shadow:0 0 0 .2rem rgba(0,82,204,0.15)}.btn.disabled,.btn:disabled{opacity:.65}a.btn.disabled,fieldset:disabled a.btn{pointer-events:none}.btn-primary{color:#fff;background-color:#6554c0;border-color:#6554c0}.btn-primary:hover{color:#fff;background-color:#5140ae;border-color:#4d3da4}.btn-primary:focus,.btn-primary.focus{box-shadow:0 0 0 .2rem rgba(124,110,201,0.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#6554c0;border-color:#6554c0}.btn-primary:not(:disabled):not(.disabled):active,.btn-primary:not(:disabled):not(.disabled).active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#4d3da4;border-color:#49399b}.btn-primary:not(:disabled):not(.disabled):active:focus,.btn-primary:not(:disabled):not(.disabled).active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(124,110,201,0.5)}.btn-secondary{color:#172b4d;background-color:#a5adba;border-color:#a5adba}.btn-secondary:hover{color:#172b4d;background-color:#8f99a9;border-color:#8893a4}.btn-secondary:focus,.btn-secondary.focus{box-shadow:0 0 0 .2rem rgba(144,154,170,0.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#172b4d;background-color:#a5adba;border-color:#a5adba}.btn-secondary:not(:disabled):not(.disabled):active,.btn-secondary:not(:disabled):not(.disabled).active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#8893a4;border-color:#818c9e}.btn-secondary:not(:disabled):not(.disabled):active:focus,.btn-secondary:not(:disabled):not(.disabled).active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(144,154,170,0.5)}.btn-success{color:#fff;background-color:#36b37e;border-color:#36b37e}.btn-success:hover{color:#fff;background-color:#2d9669;border-color:#2a8c62}.btn-success:focus,.btn-success.focus{box-shadow:0 0 0 .2rem rgba(84,190,145,0.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#36b37e;border-color:#36b37e}.btn-success:not(:disabled):not(.disabled):active,.btn-success:not(:disabled):not(.disabled).active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#2a8c62;border-color:#27825c}.btn-success:not(:disabled):not(.disabled):active:focus,.btn-success:not(:disabled):not(.disabled).active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(84,190,145,0.5)}.btn-info{color:#fff;background-color:#00b8d9;border-color:#00b8d9}.btn-info:hover{color:#fff;background-color:#0098b3;border-color:#008da6}.btn-info:focus,.btn-info.focus{box-shadow:0 0 0 .2rem rgba(38,195,223,0.5)}.btn-info.disabled,.btn-info:disabled{color:#fff;background-color:#00b8d9;border-color:#00b8d9}.btn-info:not(:disabled):not(.disabled):active,.btn-info:not(:disabled):not(.disabled).active,.show>.btn-info.dropdown-toggle{color:#fff;background-color:#008da6;border-color:#008299}.btn-info:not(:disabled):not(.disabled):active:focus,.btn-info:not(:disabled):not(.disabled).active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(38,195,223,0.5)}.btn-warning{color:#172b4d;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#172b4d;background-color:#e0a800;border-color:#d39e00}.btn-warning:focus,.btn-warning.focus{box-shadow:0 0 0 .2rem rgba(220,171,18,0.5)}.btn-warning.disabled,.btn-warning:disabled{color:#172b4d;background-color:#ffc107;border-color:#ffc107}.btn-warning:not(:disabled):not(.disabled):active,.btn-warning:not(:disabled):not(.disabled).active,.show>.btn-warning.dropdown-toggle{color:#172b4d;background-color:#d39e00;border-color:#c69500}.btn-warning:not(:disabled):not(.disabled):active:focus,.btn-warning:not(:disabled):not(.disabled).active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(220,171,18,0.5)}.btn-danger{color:#fff;background-color:#ff5630;border-color:#ff5630}.btn-danger:hover{color:#fff;background-color:#ff370a;border-color:#fc2e00}.btn-danger:focus,.btn-danger.focus{box-shadow:0 0 0 .2rem rgba(255,111,79,0.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#ff5630;border-color:#ff5630}.btn-danger:not(:disabled):not(.disabled):active,.btn-danger:not(:disabled):not(.disabled).active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#fc2e00;border-color:#ef2c00}.btn-danger:not(:disabled):not(.disabled):active:focus,.btn-danger:not(:disabled):not(.disabled).active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,111,79,0.5)}.btn-light,.navbar .btn-user,.card-admin-table .btn-thumbnail{color:#172b4d;background-color:#ebecf0;border-color:#ebecf0}.btn-light:hover,.navbar .btn-user:hover,.card-admin-table .btn-thumbnail:hover{color:#172b4d;background-color:#d5d7e0;border-color:#ced0da}.btn-light:focus,.navbar .btn-user:focus,.card-admin-table .btn-thumbnail:focus,.btn-light.focus,.navbar .focus.btn-user,.card-admin-table .focus.btn-thumbnail{box-shadow:0 0 0 .2rem rgba(203,207,216,0.5)}.btn-light.disabled,.navbar .disabled.btn-user,.card-admin-table .disabled.btn-thumbnail,.btn-light:disabled,.navbar .btn-user:disabled,.card-admin-table .btn-thumbnail:disabled{color:#172b4d;background-color:#ebecf0;border-color:#ebecf0}.btn-light:not(:disabled):not(.disabled):active,.navbar .btn-user:not(:disabled):not(.disabled):active,.card-admin-table .btn-thumbnail:not(:disabled):not(.disabled):active,.btn-light:not(:disabled):not(.disabled).active,.navbar .btn-user:not(:disabled):not(.disabled).active,.card-admin-table .btn-thumbnail:not(:disabled):not(.disabled).active,.show>.btn-light.dropdown-toggle,.navbar .show>.dropdown-toggle.btn-user,.card-admin-table .show>.dropdown-toggle.btn-thumbnail{color:#172b4d;background-color:#ced0da;border-color:#c7c9d5}.btn-light:not(:disabled):not(.disabled):active:focus,.navbar .btn-user:not(:disabled):not(.disabled):active:focus,.card-admin-table .btn-thumbnail:not(:disabled):not(.disabled):active:focus,.btn-light:not(:disabled):not(.disabled).active:focus,.navbar .btn-user:not(:disabled):not(.disabled).active:focus,.card-admin-table .btn-thumbnail:not(:disabled):not(.disabled).active:focus,.show>.btn-light.dropdown-toggle:focus,.navbar .show>.dropdown-toggle.btn-user:focus,.card-admin-table .show>.dropdown-toggle.btn-thumbnail:focus{box-shadow:0 0 0 .2rem rgba(203,207,216,0.5)}.btn-dark{color:#fff;background-color:#505f79;border-color:#505f79}.btn-dark:hover{color:#fff;background-color:#414d62;border-color:#3c475a}.btn-dark:focus,.btn-dark.focus{box-shadow:0 0 0 .2rem rgba(106,119,141,0.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#505f79;border-color:#505f79}.btn-dark:not(:disabled):not(.disabled):active,.btn-dark:not(:disabled):not(.disabled).active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#3c475a;border-color:#374153}.btn-dark:not(:disabled):not(.disabled):active:focus,.btn-dark:not(:disabled):not(.disabled).active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(106,119,141,0.5)}.btn-outline-primary{color:#6554c0;border-color:#6554c0}.btn-outline-primary:hover{color:#fff;background-color:#6554c0;border-color:#6554c0}.btn-outline-primary:focus,.btn-outline-primary.focus{box-shadow:0 0 0 .2rem rgba(101,84,192,0.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#6554c0;background-color:transparent}.btn-outline-primary:not(:disabled):not(.disabled):active,.btn-outline-primary:not(:disabled):not(.disabled).active,.show>.btn-outline-primary.dropdown-toggle{color:#fff;background-color:#6554c0;border-color:#6554c0}.btn-outline-primary:not(:disabled):not(.disabled):active:focus,.btn-outline-primary:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(101,84,192,0.5)}.btn-outline-secondary{color:#a5adba;border-color:#a5adba}.btn-outline-secondary:hover{color:#172b4d;background-color:#a5adba;border-color:#a5adba}.btn-outline-secondary:focus,.btn-outline-secondary.focus{box-shadow:0 0 0 .2rem rgba(165,173,186,0.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#a5adba;background-color:transparent}.btn-outline-secondary:not(:disabled):not(.disabled):active,.btn-outline-secondary:not(:disabled):not(.disabled).active,.show>.btn-outline-secondary.dropdown-toggle{color:#172b4d;background-color:#a5adba;border-color:#a5adba}.btn-outline-secondary:not(:disabled):not(.disabled):active:focus,.btn-outline-secondary:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(165,173,186,0.5)}.btn-outline-success{color:#36b37e;border-color:#36b37e}.btn-outline-success:hover{color:#fff;background-color:#36b37e;border-color:#36b37e}.btn-outline-success:focus,.btn-outline-success.focus{box-shadow:0 0 0 .2rem rgba(54,179,126,0.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#36b37e;background-color:transparent}.btn-outline-success:not(:disabled):not(.disabled):active,.btn-outline-success:not(:disabled):not(.disabled).active,.show>.btn-outline-success.dropdown-toggle{color:#fff;background-color:#36b37e;border-color:#36b37e}.btn-outline-success:not(:disabled):not(.disabled):active:focus,.btn-outline-success:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(54,179,126,0.5)}.btn-outline-info{color:#00b8d9;border-color:#00b8d9}.btn-outline-info:hover{color:#fff;background-color:#00b8d9;border-color:#00b8d9}.btn-outline-info:focus,.btn-outline-info.focus{box-shadow:0 0 0 .2rem rgba(0,184,217,0.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#00b8d9;background-color:transparent}.btn-outline-info:not(:disabled):not(.disabled):active,.btn-outline-info:not(:disabled):not(.disabled).active,.show>.btn-outline-info.dropdown-toggle{color:#fff;background-color:#00b8d9;border-color:#00b8d9}.btn-outline-info:not(:disabled):not(.disabled):active:focus,.btn-outline-info:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(0,184,217,0.5)}.btn-outline-warning{color:#ffc107;border-color:#ffc107}.btn-outline-warning:hover{color:#172b4d;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning:focus,.btn-outline-warning.focus{box-shadow:0 0 0 .2rem rgba(255,193,7,0.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-warning:not(:disabled):not(.disabled):active,.btn-outline-warning:not(:disabled):not(.disabled).active,.show>.btn-outline-warning.dropdown-toggle{color:#172b4d;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning:not(:disabled):not(.disabled):active:focus,.btn-outline-warning:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,0.5)}.btn-outline-danger{color:#ff5630;border-color:#ff5630}.btn-outline-danger:hover{color:#fff;background-color:#ff5630;border-color:#ff5630}.btn-outline-danger:focus,.btn-outline-danger.focus{box-shadow:0 0 0 .2rem rgba(255,86,48,0.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#ff5630;background-color:transparent}.btn-outline-danger:not(:disabled):not(.disabled):active,.btn-outline-danger:not(:disabled):not(.disabled).active,.show>.btn-outline-danger.dropdown-toggle{color:#fff;background-color:#ff5630;border-color:#ff5630}.btn-outline-danger:not(:disabled):not(.disabled):active:focus,.btn-outline-danger:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,86,48,0.5)}.btn-outline-light{color:#ebecf0;border-color:#ebecf0}.btn-outline-light:hover{color:#172b4d;background-color:#ebecf0;border-color:#ebecf0}.btn-outline-light:focus,.btn-outline-light.focus{box-shadow:0 0 0 .2rem rgba(235,236,240,0.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#ebecf0;background-color:transparent}.btn-outline-light:not(:disabled):not(.disabled):active,.btn-outline-light:not(:disabled):not(.disabled).active,.show>.btn-outline-light.dropdown-toggle{color:#172b4d;background-color:#ebecf0;border-color:#ebecf0}.btn-outline-light:not(:disabled):not(.disabled):active:focus,.btn-outline-light:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(235,236,240,0.5)}.btn-outline-dark{color:#505f79;border-color:#505f79}.btn-outline-dark:hover{color:#fff;background-color:#505f79;border-color:#505f79}.btn-outline-dark:focus,.btn-outline-dark.focus{box-shadow:0 0 0 .2rem rgba(80,95,121,0.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#505f79;background-color:transparent}.btn-outline-dark:not(:disabled):not(.disabled):active,.btn-outline-dark:not(:disabled):not(.disabled).active,.show>.btn-outline-dark.dropdown-toggle{color:#fff;background-color:#505f79;border-color:#505f79}.btn-outline-dark:not(:disabled):not(.disabled):active:focus,.btn-outline-dark:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(80,95,121,0.5)}.btn-link{font-weight:400;color:#6b778c;text-decoration:none}.btn-link:hover{color:#172b4d;text-decoration:underline}.btn-link:focus,.btn-link.focus{text-decoration:underline;box-shadow:none}.btn-link:disabled,.btn-link.disabled{color:#a5adba;pointer-events:none}.btn-lg,.btn-group-lg>.btn{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.btn-sm,.btn-group-sm>.btn{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.btn-block,.card-admin-table .btn-thumbnail{display:block;width:100%}.btn-block+.btn-block,.card-admin-table .btn-thumbnail+.btn-block,.card-admin-table .btn-block+.btn-thumbnail,.card-admin-table .btn-thumbnail+.btn-thumbnail{margin-top:.5rem}input[type="submit"].btn-block,.card-admin-table input.btn-thumbnail[type="submit"],input[type="reset"].btn-block,.card-admin-table input.btn-thumbnail[type="reset"],input[type="button"].btn-block,.card-admin-table input.btn-thumbnail[type="button"]{width:100%}.fade{transition:opacity 0.15s linear}@media (prefers-reduced-motion: reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{position:relative;height:0;overflow:hidden;transition:height 0.35s ease}@media (prefers-reduced-motion: reduce){.collapsing{transition:none}}.dropup,.dropright,.dropdown,.dropleft{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:10rem;padding:.5rem 0;margin:.125rem 0 0;font-size:1rem;color:#172b4d;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(9,30,66,0.15);border-radius:.25rem}.dropdown-menu-left{right:auto;left:0}.dropdown-menu-right{right:0;left:auto}@media (min-width: 576px){.dropdown-menu-sm-left{right:auto;left:0}.dropdown-menu-sm-right{right:0;left:auto}}@media (min-width: 768px){.dropdown-menu-md-left{right:auto;left:0}.dropdown-menu-md-right{right:0;left:auto}}@media (min-width: 992px){.dropdown-menu-lg-left{right:auto;left:0}.dropdown-menu-lg-right{right:0;left:auto}}@media (min-width: 1200px){.dropdown-menu-xl-left{right:auto;left:0}.dropdown-menu-xl-right{right:0;left:auto}}.dropup .dropdown-menu{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropright .dropdown-menu{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropright .dropdown-toggle::after{vertical-align:0}.dropleft .dropdown-menu{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropleft .dropdown-toggle::before{vertical-align:0}.dropdown-menu[x-placement^="top"],.dropdown-menu[x-placement^="right"],.dropdown-menu[x-placement^="bottom"],.dropdown-menu[x-placement^="left"]{right:auto;bottom:auto}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid #ebecf0}.dropdown-item{display:block;width:100%;padding:.25rem 1.5rem;clear:both;font-weight:400;color:#172b4d;text-align:inherit;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:hover,.dropdown-item:focus{color:#112039;text-decoration:none;background-color:#f4f5f7}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#091e42}.dropdown-item.disabled,.dropdown-item:disabled{color:#a5adba;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1.5rem;margin-bottom:0;font-size:.875rem;color:#a5adba;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1.5rem;color:#172b4d}.btn-group,.btn-group-vertical{position:relative;display:-ms-inline-flexbox;display:inline-flex;vertical-align:middle}.btn-group>.btn,.btn-group-vertical>.btn{position:relative;-ms-flex:1 1 auto;flex:1 1 auto}.btn-group>.btn:hover,.btn-group-vertical>.btn:hover{z-index:1}.btn-group>.btn:focus,.btn-group>.btn:active,.btn-group>.btn.active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn.active{z-index:1}.btn-toolbar{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-pack:start;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn:not(:first-child),.btn-group>.btn-group:not(:first-child){margin-left:-1px}.btn-group>.btn:not(:last-child):not(.dropdown-toggle),.btn-group>.btn-group:not(:last-child)>.btn{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:not(:first-child),.btn-group>.btn-group:not(:first-child)>.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after,.dropright .dropdown-toggle-split::after{margin-left:0}.dropleft .dropdown-toggle-split::before{margin-right:0}.btn-sm+.dropdown-toggle-split,.btn-group-sm>.btn+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-lg+.dropdown-toggle-split,.btn-group-lg>.btn+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{-ms-flex-direction:column;flex-direction:column;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:center;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn:not(:first-child),.btn-group-vertical>.btn-group:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle),.btn-group-vertical>.btn-group:not(:last-child)>.btn{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:not(:first-child),.btn-group-vertical>.btn-group:not(:first-child)>.btn{border-top-left-radius:0;border-top-right-radius:0}.btn-group-toggle>.btn,.btn-group-toggle>.btn-group>.btn{margin-bottom:0}.btn-group-toggle>.btn input[type="radio"],.btn-group-toggle>.btn input[type="checkbox"],.btn-group-toggle>.btn-group>.btn input[type="radio"],.btn-group-toggle>.btn-group>.btn input[type="checkbox"]{position:absolute;clip:rect(0, 0, 0, 0);pointer-events:none}.input-group{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:stretch;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-control-plaintext,.input-group>.custom-select,.input-group>.custom-file{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;width:1%;margin-bottom:0}.input-group>.form-control+.form-control,.input-group>.form-control+.custom-select,.input-group>.form-control+.custom-file,.input-group>.form-control-plaintext+.form-control,.input-group>.form-control-plaintext+.custom-select,.input-group>.form-control-plaintext+.custom-file,.input-group>.custom-select+.form-control,.input-group>.custom-select+.custom-select,.input-group>.custom-select+.custom-file,.input-group>.custom-file+.form-control,.input-group>.custom-file+.custom-select,.input-group>.custom-file+.custom-file{margin-left:-1px}.input-group>.form-control:focus,.input-group>.custom-select:focus,.input-group>.custom-file .custom-file-input:focus ~ .custom-file-label{z-index:3}.input-group>.custom-file .custom-file-input:focus{z-index:4}.input-group>.form-control:not(:last-child),.input-group>.custom-select:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.form-control:not(:first-child),.input-group>.custom-select:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.custom-file{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center}.input-group>.custom-file:not(:last-child) .custom-file-label,.input-group>.custom-file:not(:last-child) .custom-file-label::after{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-file:not(:first-child) .custom-file-label{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-prepend,.input-group-append{display:-ms-flexbox;display:flex}.input-group-prepend .btn,.input-group-append .btn{position:relative;z-index:2}.input-group-prepend .btn:focus,.input-group-append .btn:focus{z-index:3}.input-group-prepend .btn+.btn,.input-group-prepend .btn+.input-group-text,.input-group-prepend .input-group-text+.input-group-text,.input-group-prepend .input-group-text+.btn,.input-group-append .btn+.btn,.input-group-append .btn+.input-group-text,.input-group-append .input-group-text+.input-group-text,.input-group-append .input-group-text+.btn{margin-left:-1px}.input-group-prepend{margin-right:-1px}.input-group-append{margin-left:-1px}.input-group-text{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding:.375rem .75rem;margin-bottom:0;font-size:1rem;font-weight:400;line-height:1.5;color:#6b778c;text-align:center;white-space:nowrap;background-color:#ebecf0;border:1px solid #c1c7d0;border-radius:.25rem}.input-group-text input[type="radio"],.input-group-text input[type="checkbox"]{margin-top:0}.input-group-lg>.form-control:not(textarea),.input-group-lg>.custom-select{height:calc(1.5em + 1rem + 2px)}.input-group-lg>.form-control,.input-group-lg>.custom-select,.input-group-lg>.input-group-prepend>.input-group-text,.input-group-lg>.input-group-append>.input-group-text,.input-group-lg>.input-group-prepend>.btn,.input-group-lg>.input-group-append>.btn{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.input-group-sm>.form-control:not(textarea),.input-group-sm>.custom-select{height:calc(1.5em + .5rem + 2px)}.input-group-sm>.form-control,.input-group-sm>.custom-select,.input-group-sm>.input-group-prepend>.input-group-text,.input-group-sm>.input-group-append>.input-group-text,.input-group-sm>.input-group-prepend>.btn,.input-group-sm>.input-group-append>.btn{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.input-group-lg>.custom-select,.input-group-sm>.custom-select{padding-right:1.75rem}.input-group>.input-group-prepend>.btn,.input-group>.input-group-prepend>.input-group-text,.input-group>.input-group-append:not(:last-child)>.btn,.input-group>.input-group-append:not(:last-child)>.input-group-text,.input-group>.input-group-append:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group>.input-group-append:last-child>.input-group-text:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.input-group-append>.btn,.input-group>.input-group-append>.input-group-text,.input-group>.input-group-prepend:not(:first-child)>.btn,.input-group>.input-group-prepend:not(:first-child)>.input-group-text,.input-group>.input-group-prepend:first-child>.btn:not(:first-child),.input-group>.input-group-prepend:first-child>.input-group-text:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.custom-control{position:relative;display:block;min-height:1.5rem;padding-left:1.5rem}.custom-control-inline{display:-ms-inline-flexbox;display:inline-flex;margin-right:1rem}.custom-control-input{position:absolute;z-index:-1;opacity:0}.custom-control-input:checked ~ .custom-control-label::before{color:#fff;border-color:#091e42;background-color:#091e42}.custom-control-input:focus ~ .custom-control-label::before{box-shadow:0 0 0 .2rem rgba(0,82,204,0.15)}.custom-control-input:focus:not(:checked) ~ .custom-control-label::before{border-color:#1851b2}.custom-control-input:not(:disabled):active ~ .custom-control-label::before{color:#fff;background-color:#1e65df;border-color:#1e65df}.custom-control-input:disabled ~ .custom-control-label{color:#a5adba}.custom-control-input:disabled ~ .custom-control-label::before{background-color:#ebecf0}.custom-control-label{position:relative;margin-bottom:0;vertical-align:top}.custom-control-label::before{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;pointer-events:none;content:"";background-color:#fff;border:#b3bac5 solid 1px}.custom-control-label::after{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;content:"";background:no-repeat 50% / 50% 50%}.custom-checkbox .custom-control-label::before{border-radius:.25rem}.custom-checkbox .custom-control-input:checked ~ .custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::before{border-color:#091e42;background-color:#091e42}.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:disabled:checked ~ .custom-control-label::before{background-color:rgba(101,84,192,0.5)}.custom-checkbox .custom-control-input:disabled:indeterminate ~ .custom-control-label::before{background-color:rgba(101,84,192,0.5)}.custom-radio .custom-control-label::before{border-radius:50%}.custom-radio .custom-control-input:checked ~ .custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.custom-radio .custom-control-input:disabled:checked ~ .custom-control-label::before{background-color:rgba(101,84,192,0.5)}.custom-switch{padding-left:2.25rem}.custom-switch .custom-control-label::before{left:-2.25rem;width:1.75rem;pointer-events:all;border-radius:.5rem}.custom-switch .custom-control-label::after{top:calc(.25rem + 2px);left:calc(-2.25rem + 2px);width:calc(1rem - 4px);height:calc(1rem - 4px);background-color:#b3bac5;border-radius:.5rem;transition:background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,-webkit-transform 0.15s ease-in-out;transition:transform 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out;transition:transform 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,-webkit-transform 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.custom-switch .custom-control-label::after{transition:none}}.custom-switch .custom-control-input:checked ~ .custom-control-label::after{background-color:#fff;-webkit-transform:translateX(.75rem);transform:translateX(.75rem)}.custom-switch .custom-control-input:disabled:checked ~ .custom-control-label::before{background-color:rgba(101,84,192,0.5)}.custom-select{display:inline-block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem 1.75rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#6b778c;vertical-align:middle;background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23505f79' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px;background-color:#fff;border:1px solid #c1c7d0;border-radius:.25rem;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-select:focus{border-color:#1851b2;outline:0;box-shadow:0 0 0 .2rem rgba(0,82,204,0.15)}.custom-select:focus::-ms-value{color:#6b778c;background-color:#fff}.custom-select[multiple],.custom-select[size]:not([size="1"]){height:auto;padding-right:.75rem;background-image:none}.custom-select:disabled{color:#a5adba;background-color:#ebecf0}.custom-select::-ms-expand{display:none}.custom-select-sm{height:calc(1.5em + .5rem + 2px);padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem}.custom-select-lg{height:calc(1.5em + 1rem + 2px);padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem}.custom-file{position:relative;display:inline-block;width:100%;height:calc(1.5em + .75rem + 2px);margin-bottom:0}.custom-file-input{position:relative;z-index:2;width:100%;height:calc(1.5em + .75rem + 2px);margin:0;opacity:0}.custom-file-input:focus ~ .custom-file-label{border-color:#1851b2;box-shadow:0 0 0 .2rem rgba(0,82,204,0.15)}.custom-file-input:disabled ~ .custom-file-label{background-color:#ebecf0}.custom-file-input:lang(en) ~ .custom-file-label::after{content:"Browse"}.custom-file-input ~ .custom-file-label[data-browse]::after{content:attr(data-browse)}.custom-file-label{position:absolute;top:0;right:0;left:0;z-index:1;height:calc(1.5em + .75rem + 2px);padding:.375rem .75rem;font-weight:400;line-height:1.5;color:#6b778c;background-color:#fff;border:1px solid #c1c7d0;border-radius:.25rem}.custom-file-label::after{position:absolute;top:0;right:0;bottom:0;z-index:3;display:block;height:calc(1.5em + .75rem);padding:.375rem .75rem;line-height:1.5;color:#6b778c;content:"Browse";background-color:#ebecf0;border-left:inherit;border-radius:0 .25rem .25rem 0}.custom-range{width:100%;height:calc(1rem + .4rem);padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-range:focus{outline:none}.custom-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #f4f5f7,0 0 0 .2rem rgba(0,82,204,0.15)}.custom-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #f4f5f7,0 0 0 .2rem rgba(0,82,204,0.15)}.custom-range:focus::-ms-thumb{box-shadow:0 0 0 1px #f4f5f7,0 0 0 .2rem rgba(0,82,204,0.15)}.custom-range::-moz-focus-outer{border:0}.custom-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#091e42;border:0;border-radius:1rem;transition:background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion: reduce){.custom-range::-webkit-slider-thumb{transition:none}}.custom-range::-webkit-slider-thumb:active{background-color:#1e65df}.custom-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dfe1e6;border-color:transparent;border-radius:1rem}.custom-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#091e42;border:0;border-radius:1rem;transition:background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion: reduce){.custom-range::-moz-range-thumb{transition:none}}.custom-range::-moz-range-thumb:active{background-color:#1e65df}.custom-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dfe1e6;border-color:transparent;border-radius:1rem}.custom-range::-ms-thumb{width:1rem;height:1rem;margin-top:0;margin-right:.2rem;margin-left:.2rem;background-color:#091e42;border:0;border-radius:1rem;transition:background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out;appearance:none}@media (prefers-reduced-motion: reduce){.custom-range::-ms-thumb{transition:none}}.custom-range::-ms-thumb:active{background-color:#1e65df}.custom-range::-ms-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:transparent;border-color:transparent;border-width:.5rem}.custom-range::-ms-fill-lower{background-color:#dfe1e6;border-radius:1rem}.custom-range::-ms-fill-upper{margin-right:15px;background-color:#dfe1e6;border-radius:1rem}.custom-range:disabled::-webkit-slider-thumb{background-color:#b3bac5}.custom-range:disabled::-webkit-slider-runnable-track{cursor:default}.custom-range:disabled::-moz-range-thumb{background-color:#b3bac5}.custom-range:disabled::-moz-range-track{cursor:default}.custom-range:disabled::-ms-thumb{background-color:#b3bac5}.custom-control-label::before,.custom-file-label,.custom-select{transition:background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.custom-control-label::before,.custom-file-label,.custom-select{transition:none}}.nav{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem}.nav-link:hover,.nav-link:focus{text-decoration:none}.nav-link.disabled{color:#a5adba;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #dfe1e6}.nav-tabs .nav-item{margin-bottom:-1px}.nav-tabs .nav-link{border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:hover,.nav-tabs .nav-link:focus{border-color:#ebecf0 #ebecf0 #dfe1e6}.nav-tabs .nav-link.disabled{color:#a5adba;background-color:transparent;border-color:transparent}.nav-tabs .nav-link.active,.nav-tabs .nav-item.show .nav-link{color:#6b778c;background-color:#f4f5f7;border-color:#dfe1e6 #dfe1e6 #f4f5f7}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#091e42}.nav-fill .nav-item{-ms-flex:1 1 auto;flex:1 1 auto;text-align:center}.nav-justified .nav-item{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;text-align:center}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between;padding:.5rem 1rem}.navbar>.container,.navbar>.container-fluid{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;line-height:inherit;white-space:nowrap}.navbar-brand:hover,.navbar-brand:focus{text-decoration:none}.navbar-nav{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static;float:none}.navbar-text{display:inline-block;padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{-ms-flex-preferred-size:100%;flex-basis:100%;-ms-flex-positive:1;flex-grow:1;-ms-flex-align:center;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem}.navbar-toggler:hover,.navbar-toggler:focus{text-decoration:none}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;content:"";background:no-repeat center center;background-size:100% 100%}@media (max-width: 575.98px){.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid{padding-right:0;padding-left:0}}@media (min-width: 576px){.navbar-expand-sm{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-sm .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-sm .navbar-collapse{display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}@media (max-width: 767.98px){.navbar-expand-md>.container,.navbar-expand-md>.container-fluid{padding-right:0;padding-left:0}}@media (min-width: 768px){.navbar-expand-md{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-md .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md>.container,.navbar-expand-md>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-md .navbar-collapse{display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}}@media (max-width: 991.98px){.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid{padding-right:0;padding-left:0}}@media (min-width: 992px){.navbar-expand-lg{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-lg .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-lg .navbar-collapse{display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}}@media (max-width: 1199.98px){.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid{padding-right:0;padding-left:0}}@media (min-width: 1200px){.navbar-expand-xl{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-xl .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-xl .navbar-collapse{display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}}.navbar-expand{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand>.container,.navbar-expand>.container-fluid{padding-right:0;padding-left:0}.navbar-expand .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand>.container,.navbar-expand>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand .navbar-collapse{display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-light .navbar-brand{color:rgba(9,30,66,0.9)}.navbar-light .navbar-brand:hover,.navbar-light .navbar-brand:focus{color:rgba(9,30,66,0.9)}.navbar-light .navbar-nav .nav-link{color:rgba(9,30,66,0.5)}.navbar-light .navbar-nav .nav-link:hover,.navbar-light .navbar-nav .nav-link:focus{color:rgba(9,30,66,0.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(9,30,66,0.3)}.navbar-light .navbar-nav .show>.nav-link,.navbar-light .navbar-nav .active>.nav-link,.navbar-light .navbar-nav .nav-link.show,.navbar-light .navbar-nav .nav-link.active{color:rgba(9,30,66,0.9)}.navbar-light .navbar-toggler{color:rgba(9,30,66,0.5);border-color:rgba(9,30,66,0.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3e%3cpath stroke='rgba(9,30,66,0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(9,30,66,0.5)}.navbar-light .navbar-text a{color:rgba(9,30,66,0.9)}.navbar-light .navbar-text a:hover,.navbar-light .navbar-text a:focus{color:rgba(9,30,66,0.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:hover,.navbar-dark .navbar-brand:focus{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,0.5)}.navbar-dark .navbar-nav .nav-link:hover,.navbar-dark .navbar-nav .nav-link:focus{color:rgba(255,255,255,0.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,0.25)}.navbar-dark .navbar-nav .show>.nav-link,.navbar-dark .navbar-nav .active>.nav-link,.navbar-dark .navbar-nav .nav-link.show,.navbar-dark .navbar-nav .nav-link.active{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,0.5);border-color:rgba(255,255,255,0.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3e%3cpath stroke='rgba(255,255,255,0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,0.5)}.navbar-dark .navbar-text a{color:#fff}.navbar-dark .navbar-text a:hover,.navbar-dark .navbar-text a:focus{color:#fff}.card{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(9,30,66,0.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group:first-child .list-group-item:first-child{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.card>.list-group:last-child .list-group-item:last-child{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.card-body,.card-admin-form .form-fieldset{-ms-flex:1 1 auto;flex:1 1 auto;padding:1.25rem}.card-title{margin-bottom:.75rem}.card-subtitle{margin-top:-.375rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link:hover{text-decoration:none}.card-link+.card-link{margin-left:1.25rem}.card-header{padding:.75rem 1.25rem;margin-bottom:0;background-color:rgba(0,0,0,0);border-bottom:1px solid rgba(9,30,66,0.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-header+.list-group .list-group-item:first-child{border-top:0}.card-footer{padding:.75rem 1.25rem;background-color:rgba(0,0,0,0);border-top:1px solid rgba(9,30,66,0.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.625rem;margin-bottom:-.75rem;margin-left:-.625rem;border-bottom:0}.card-header-pills{margin-right:-.625rem;margin-left:-.625rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1.25rem}.card-img{width:100%;border-radius:calc(.25rem - 1px)}.card-img-top{width:100%;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img-bottom{width:100%;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-deck{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column}.card-deck .card{margin-bottom:15px}@media (min-width: 576px){.card-deck{-ms-flex-flow:row wrap;flex-flow:row wrap;margin-right:-15px;margin-left:-15px}.card-deck .card{display:-ms-flexbox;display:flex;-ms-flex:1 0 0%;flex:1 0 0%;-ms-flex-direction:column;flex-direction:column;margin-right:15px;margin-bottom:0;margin-left:15px}}.card-group{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column}.card-group>.card{margin-bottom:15px}@media (min-width: 576px){.card-group{-ms-flex-flow:row wrap;flex-flow:row wrap}.card-group>.card{-ms-flex:1 0 0%;flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-img-top,.card-group>.card:not(:last-child) .card-header{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-img-bottom,.card-group>.card:not(:last-child) .card-footer{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-img-top,.card-group>.card:not(:first-child) .card-header{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-img-bottom,.card-group>.card:not(:first-child) .card-footer{border-bottom-left-radius:0}}.card-columns .card{margin-bottom:.75rem}@media (min-width: 576px){.card-columns{-webkit-column-count:3;-moz-column-count:3;column-count:3;-webkit-column-gap:1.25rem;-moz-column-gap:1.25rem;column-gap:1.25rem;orphans:1;widows:1}.card-columns .card{display:inline-block;width:100%}}.accordion>.card{overflow:hidden}.accordion>.card:not(:first-of-type) .card-header:first-child{border-radius:0}.accordion>.card:not(:first-of-type):not(:last-of-type){border-bottom:0;border-radius:0}.accordion>.card:first-of-type{border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.accordion>.card:last-of-type{border-top-left-radius:0;border-top-right-radius:0}.accordion>.card .card-header{margin-bottom:-1px}.breadcrumb{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding:.75rem 1rem;margin-bottom:1rem;list-style:none;background-color:#ebecf0;border-radius:.25rem}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{display:inline-block;padding-right:.5rem;color:#a5adba;content:"/"}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:underline}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:none}.breadcrumb-item.active{color:#a5adba}.pagination{display:-ms-flexbox;display:flex;padding-left:0;list-style:none;border-radius:.25rem}.page-link{position:relative;display:block;padding:.5rem .75rem;margin-left:-1px;line-height:1.25;color:#6b778c;background-color:#fff;border:1px solid #dfe1e6}.page-link:hover{z-index:2;color:#172b4d;text-decoration:none;background-color:#ebecf0;border-color:#dfe1e6}.page-link:focus{z-index:2;outline:0;box-shadow:0 0 0 .2rem rgba(0,82,204,0.15)}.page-item:first-child .page-link{margin-left:0;border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.page-item.active .page-link{z-index:1;color:#fff;background-color:#091e42;border-color:#091e42}.page-item.disabled .page-link{color:#a5adba;pointer-events:none;cursor:auto;background-color:#fff;border-color:#dfe1e6}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem;line-height:1.5}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem;line-height:1.5}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.25em .4em;font-size:75%;font-weight:700;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem;transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.badge{transition:none}}a.badge:hover,a.badge:focus{text-decoration:none}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{padding-right:.6em;padding-left:.6em;border-radius:10rem}.badge-primary{color:#fff;background-color:#6554c0}a.badge-primary:hover,a.badge-primary:focus{color:#fff;background-color:#4d3da4}a.badge-primary:focus,a.badge-primary.focus{outline:0;box-shadow:0 0 0 .2rem rgba(101,84,192,0.5)}.badge-secondary{color:#172b4d;background-color:#a5adba}a.badge-secondary:hover,a.badge-secondary:focus{color:#172b4d;background-color:#8893a4}a.badge-secondary:focus,a.badge-secondary.focus{outline:0;box-shadow:0 0 0 .2rem rgba(165,173,186,0.5)}.badge-success{color:#fff;background-color:#36b37e}a.badge-success:hover,a.badge-success:focus{color:#fff;background-color:#2a8c62}a.badge-success:focus,a.badge-success.focus{outline:0;box-shadow:0 0 0 .2rem rgba(54,179,126,0.5)}.badge-info{color:#fff;background-color:#00b8d9}a.badge-info:hover,a.badge-info:focus{color:#fff;background-color:#008da6}a.badge-info:focus,a.badge-info.focus{outline:0;box-shadow:0 0 0 .2rem rgba(0,184,217,0.5)}.badge-warning{color:#172b4d;background-color:#ffc107}a.badge-warning:hover,a.badge-warning:focus{color:#172b4d;background-color:#d39e00}a.badge-warning:focus,a.badge-warning.focus{outline:0;box-shadow:0 0 0 .2rem rgba(255,193,7,0.5)}.badge-danger{color:#fff;background-color:#ff5630}a.badge-danger:hover,a.badge-danger:focus{color:#fff;background-color:#fc2e00}a.badge-danger:focus,a.badge-danger.focus{outline:0;box-shadow:0 0 0 .2rem rgba(255,86,48,0.5)}.badge-light{color:#172b4d;background-color:#ebecf0}a.badge-light:hover,a.badge-light:focus{color:#172b4d;background-color:#ced0da}a.badge-light:focus,a.badge-light.focus{outline:0;box-shadow:0 0 0 .2rem rgba(235,236,240,0.5)}.badge-dark{color:#fff;background-color:#505f79}a.badge-dark:hover,a.badge-dark:focus{color:#fff;background-color:#3c475a}a.badge-dark:focus,a.badge-dark.focus{outline:0;box-shadow:0 0 0 .2rem rgba(80,95,121,0.5)}.jumbotron{padding:2rem 1rem;margin-bottom:2rem;background-color:#ebecf0;border-radius:.3rem}@media (min-width: 576px){.jumbotron{padding:4rem 2rem}}.jumbotron-fluid{padding-right:0;padding-left:0;border-radius:0}.alert{position:relative;padding:.75rem 1.25rem;margin-bottom:1rem;border:0 solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:4rem}.alert-dismissible .close{position:absolute;top:0;right:0;padding:.75rem 1.25rem;color:inherit}.alert-primary{color:#2a316f;background-color:#e0ddf2;border-color:#d4cfed}.alert-primary hr{border-top-color:#c3bce6}.alert-primary .alert-link{color:#1c214a}.alert-secondary{color:#41516d;background-color:#edeff1;border-color:#e6e8ec}.alert-secondary hr{border-top-color:#d8dbe1}.alert-secondary .alert-link{color:#2e394d}.alert-success{color:#195458;background-color:#d7f0e5;border-color:#c7eadb}.alert-success hr{border-top-color:#b4e3cf}.alert-success .alert-link{color:#0e2e30}.alert-info{color:#065578;background-color:#ccf1f7;border-color:#b8ebf4}.alert-info hr{border-top-color:#a2e5f1}.alert-info .alert-link{color:#043347}.alert-warning{color:#62592d;background-color:#fff3cd;border-color:#ffeeba}.alert-warning hr{border-top-color:#ffe8a1}.alert-warning .alert-link{color:#3f391d}.alert-danger{color:#62323c;background-color:#ffddd6;border-color:#ffd0c5}.alert-danger hr{border-top-color:#ffbbac}.alert-danger .alert-link{color:#402127}.alert-light{color:#5a6881;background-color:#fbfbfc;border-color:#f9fafb}.alert-light hr{border-top-color:#eaedf1}.alert-light .alert-link{color:#455063}.alert-dark{color:#233556;background-color:#dcdfe4;border-color:#ced2d9}.alert-dark hr{border-top-color:#c0c5ce}.alert-dark .alert-link{color:#141f32}@-webkit-keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}.progress{display:-ms-flexbox;display:flex;height:1rem;overflow:hidden;font-size:.75rem;background-color:#ebecf0;border-radius:.25rem}.progress-bar{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;color:#fff;text-align:center;white-space:nowrap;background-color:#6554c0;transition:width 0.6s ease}@media (prefers-reduced-motion: reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:progress-bar-stripes 1s linear infinite;animation:progress-bar-stripes 1s linear infinite}@media (prefers-reduced-motion: reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.media,.nav-side .nav-link{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start}.media-body{-ms-flex:1;flex:1}.list-group{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0}.list-group-item-action{width:100%;color:#6b778c;text-align:inherit}.list-group-item-action:hover,.list-group-item-action:focus{z-index:1;color:#6b778c;text-decoration:none;background-color:#f4f5f7}.list-group-item-action:active{color:#172b4d;background-color:#ebecf0}.list-group-item{position:relative;display:block;padding:.75rem 1.25rem;margin-bottom:-1px;background-color:#fff;border:1px solid rgba(9,30,66,0.125)}.list-group-item:first-child{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.list-group-item.disabled,.list-group-item:disabled{color:#a5adba;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#091e42;border-color:#091e42}.list-group-horizontal{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}@media (min-width: 576px){.list-group-horizontal-sm{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-sm .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal-sm .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}}@media (min-width: 768px){.list-group-horizontal-md{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-md .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal-md .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}}@media (min-width: 992px){.list-group-horizontal-lg{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-lg .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal-lg .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}}@media (min-width: 1200px){.list-group-horizontal-xl{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-xl .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal-xl .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}}.list-group-flush .list-group-item{border-right:0;border-left:0;border-radius:0}.list-group-flush .list-group-item:last-child{margin-bottom:-1px}.list-group-flush:first-child .list-group-item:first-child{border-top:0}.list-group-flush:last-child .list-group-item:last-child{margin-bottom:0;border-bottom:0}.list-group-item-primary{color:#393a84;background-color:#d4cfed}.list-group-item-primary.list-group-item-action:hover,.list-group-item-primary.list-group-item-action:focus{color:#393a84;background-color:#c3bce6}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#393a84;border-color:#393a84}.list-group-item-secondary{color:#5a6880;background-color:#e6e8ec}.list-group-item-secondary.list-group-item-action:hover,.list-group-item-secondary.list-group-item-action:focus{color:#5a6880;background-color:#d8dbe1}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#5a6880;border-color:#5a6880}.list-group-item-success{color:#206b61;background-color:#c7eadb}.list-group-item-success.list-group-item-action:hover,.list-group-item-success.list-group-item-action:focus{color:#206b61;background-color:#b4e3cf}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#206b61;border-color:#206b61}.list-group-item-info{color:#046e91;background-color:#b8ebf4}.list-group-item-info.list-group-item-action:hover,.list-group-item-info.list-group-item-action:focus{color:#046e91;background-color:#a2e5f1}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#046e91;border-color:#046e91}.list-group-item-warning{color:#897323;background-color:#ffeeba}.list-group-item-warning.list-group-item-action:hover,.list-group-item-warning.list-group-item-action:focus{color:#897323;background-color:#ffe8a1}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#897323;border-color:#897323}.list-group-item-danger{color:#893b39;background-color:#ffd0c5}.list-group-item-danger.list-group-item-action:hover,.list-group-item-danger.list-group-item-action:focus{color:#893b39;background-color:#ffbbac}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#893b39;border-color:#893b39}.list-group-item-light{color:#7f899c;background-color:#f9fafb}.list-group-item-light.list-group-item-action:hover,.list-group-item-light.list-group-item-action:focus{color:#7f899c;background-color:#eaedf1}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#7f899c;border-color:#7f899c}.list-group-item-dark{color:#2e405f;background-color:#ced2d9}.list-group-item-dark.list-group-item-action:hover,.list-group-item-dark.list-group-item-action:focus{color:#2e405f;background-color:#c0c5ce}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#2e405f;border-color:#2e405f}.close{float:right;font-size:1.5rem;font-weight:700;line-height:1;color:#091e42;text-shadow:0 1px 0 #fff;opacity:.5}.close:hover{color:#091e42;text-decoration:none}.close:not(:disabled):not(.disabled):hover,.close:not(:disabled):not(.disabled):focus{opacity:.75}button.close{padding:0;background-color:transparent;border:0;-webkit-appearance:none;-moz-appearance:none;appearance:none}a.close.disabled{pointer-events:none}.modal-open{overflow:hidden}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal{position:fixed;top:0;left:0;z-index:1050;display:none;width:100%;height:100%;overflow:hidden;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:-webkit-transform 0.3s ease-out;transition:transform 0.3s ease-out;transition:transform 0.3s ease-out, -webkit-transform 0.3s ease-out;-webkit-transform:translate(0, -50px);transform:translate(0, -50px)}@media (prefers-reduced-motion: reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{-webkit-transform:none;transform:none}.modal-dialog-scrollable{display:-ms-flexbox;display:flex;max-height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 1rem);overflow:hidden}.modal-dialog-scrollable .modal-header,.modal-dialog-scrollable .modal-footer{-ms-flex-negative:0;flex-shrink:0}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;min-height:calc(100% - 1rem)}.modal-dialog-centered::before{display:block;height:calc(100vh - 1rem);content:""}.modal-dialog-centered.modal-dialog-scrollable{-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;height:100%}.modal-dialog-centered.modal-dialog-scrollable .modal-content{max-height:none}.modal-dialog-centered.modal-dialog-scrollable::before{content:none}.modal-content{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(9,30,66,0.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#091e42}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:justify;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #dfe1e6;border-top-left-radius:.3rem;border-top-right-radius:.3rem}.modal-header .close{padding:1rem 1rem;margin:-1rem -1rem -1rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;padding:1rem}.modal-footer{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:end;justify-content:flex-end;padding:1rem;border-top:1px solid #dfe1e6;border-bottom-right-radius:.3rem;border-bottom-left-radius:.3rem}.modal-footer>:not(:first-child){margin-left:.25rem}.modal-footer>:not(:last-child){margin-right:.25rem}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width: 576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{max-height:calc(100% - 3.5rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-dialog-centered::before{height:calc(100vh - 3.5rem)}.modal-sm{max-width:300px}}@media (min-width: 992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width: 1200px){.modal-xl{max-width:1140px}}.tooltip{position:absolute;z-index:1070;display:block;margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-top,.bs-tooltip-auto[x-placement^="top"]{padding:.4rem 0}.bs-tooltip-top .arrow,.bs-tooltip-auto[x-placement^="top"] .arrow{bottom:0}.bs-tooltip-top .arrow::before,.bs-tooltip-auto[x-placement^="top"] .arrow::before{top:0;border-width:.4rem .4rem 0;border-top-color:#091e42}.bs-tooltip-right,.bs-tooltip-auto[x-placement^="right"]{padding:0 .4rem}.bs-tooltip-right .arrow,.bs-tooltip-auto[x-placement^="right"] .arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-right .arrow::before,.bs-tooltip-auto[x-placement^="right"] .arrow::before{right:0;border-width:.4rem .4rem .4rem 0;border-right-color:#091e42}.bs-tooltip-bottom,.bs-tooltip-auto[x-placement^="bottom"]{padding:.4rem 0}.bs-tooltip-bottom .arrow,.bs-tooltip-auto[x-placement^="bottom"] .arrow{top:0}.bs-tooltip-bottom .arrow::before,.bs-tooltip-auto[x-placement^="bottom"] .arrow::before{bottom:0;border-width:0 .4rem .4rem;border-bottom-color:#091e42}.bs-tooltip-left,.bs-tooltip-auto[x-placement^="left"]{padding:0 .4rem}.bs-tooltip-left .arrow,.bs-tooltip-auto[x-placement^="left"] .arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-left .arrow::before,.bs-tooltip-auto[x-placement^="left"] .arrow::before{left:0;border-width:.4rem 0 .4rem .4rem;border-left-color:#091e42}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#091e42;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1060;display:block;max-width:276px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(9,30,66,0.2);border-radius:.3rem}.popover .arrow{position:absolute;display:block;width:1rem;height:.5rem;margin:0 .3rem}.popover .arrow::before,.popover .arrow::after{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-top,.bs-popover-auto[x-placement^="top"]{margin-bottom:.5rem}.bs-popover-top>.arrow,.bs-popover-auto[x-placement^="top"]>.arrow{bottom:calc((.5rem + 1px) * -1)}.bs-popover-top>.arrow::before,.bs-popover-auto[x-placement^="top"]>.arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(9,30,66,0.25)}.bs-popover-top>.arrow::after,.bs-popover-auto[x-placement^="top"]>.arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-right,.bs-popover-auto[x-placement^="right"]{margin-left:.5rem}.bs-popover-right>.arrow,.bs-popover-auto[x-placement^="right"]>.arrow{left:calc((.5rem + 1px) * -1);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-right>.arrow::before,.bs-popover-auto[x-placement^="right"]>.arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(9,30,66,0.25)}.bs-popover-right>.arrow::after,.bs-popover-auto[x-placement^="right"]>.arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-bottom,.bs-popover-auto[x-placement^="bottom"]{margin-top:.5rem}.bs-popover-bottom>.arrow,.bs-popover-auto[x-placement^="bottom"]>.arrow{top:calc((.5rem + 1px) * -1)}.bs-popover-bottom>.arrow::before,.bs-popover-auto[x-placement^="bottom"]>.arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(9,30,66,0.25)}.bs-popover-bottom>.arrow::after,.bs-popover-auto[x-placement^="bottom"]>.arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-bottom .popover-header::before,.bs-popover-auto[x-placement^="bottom"] .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f7f7f7}.bs-popover-left,.bs-popover-auto[x-placement^="left"]{margin-right:.5rem}.bs-popover-left>.arrow,.bs-popover-auto[x-placement^="left"]>.arrow{right:calc((.5rem + 1px) * -1);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-left>.arrow::before,.bs-popover-auto[x-placement^="left"]>.arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(9,30,66,0.25)}.bs-popover-left>.arrow::after,.bs-popover-auto[x-placement^="left"]>.arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem .75rem;margin-bottom:0;font-size:1rem;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:.5rem .75rem;color:#172b4d}@-webkit-keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:spinner-border .75s linear infinite;animation:spinner-border .75s linear infinite}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1}}@keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:spinner-grow .75s linear infinite;animation:spinner-grow .75s linear infinite}.spinner-grow-sm{width:1rem;height:1rem}.align-baseline{vertical-align:baseline !important}.align-top{vertical-align:top !important}.align-middle{vertical-align:middle !important}.align-bottom{vertical-align:bottom !important}.align-text-bottom{vertical-align:text-bottom !important}.align-text-top{vertical-align:text-top !important}.bg-primary{background-color:#6554c0 !important}a.bg-primary:hover,a.bg-primary:focus,button.bg-primary:hover,button.bg-primary:focus{background-color:#4d3da4 !important}.bg-secondary{background-color:#a5adba !important}a.bg-secondary:hover,a.bg-secondary:focus,button.bg-secondary:hover,button.bg-secondary:focus{background-color:#8893a4 !important}.bg-success{background-color:#36b37e !important}a.bg-success:hover,a.bg-success:focus,button.bg-success:hover,button.bg-success:focus{background-color:#2a8c62 !important}.bg-info{background-color:#00b8d9 !important}a.bg-info:hover,a.bg-info:focus,button.bg-info:hover,button.bg-info:focus{background-color:#008da6 !important}.bg-warning{background-color:#ffc107 !important}a.bg-warning:hover,a.bg-warning:focus,button.bg-warning:hover,button.bg-warning:focus{background-color:#d39e00 !important}.bg-danger{background-color:#ff5630 !important}a.bg-danger:hover,a.bg-danger:focus,button.bg-danger:hover,button.bg-danger:focus{background-color:#fc2e00 !important}.bg-light{background-color:#ebecf0 !important}a.bg-light:hover,a.bg-light:focus,button.bg-light:hover,button.bg-light:focus{background-color:#ced0da !important}.bg-dark{background-color:#505f79 !important}a.bg-dark:hover,a.bg-dark:focus,button.bg-dark:hover,button.bg-dark:focus{background-color:#3c475a !important}.bg-white{background-color:#fff !important}.bg-transparent{background-color:transparent !important}.border,.control-month-picker,.control-time-picker,.control-image-preview{border:1px solid #dfe1e6 !important}.border-top,.card-admin-form .form-fieldset+.form-fieldset{border-top:1px solid #dfe1e6 !important}.border-right{border-right:1px solid #dfe1e6 !important}.border-bottom{border-bottom:1px solid #dfe1e6 !important}.border-left,.page-header h1 small{border-left:1px solid #dfe1e6 !important}.border-0,.control-time-picker input{border:0 !important}.border-top-0,.card-admin-table>:first-child th{border-top:0 !important}.border-right-0{border-right:0 !important}.border-bottom-0,.card-admin-table th{border-bottom:0 !important}.border-left-0{border-left:0 !important}.border-primary{border-color:#6554c0 !important}.border-secondary{border-color:#a5adba !important}.border-success{border-color:#36b37e !important}.border-info{border-color:#00b8d9 !important}.border-warning{border-color:#ffc107 !important}.border-danger,.card-admin-error,.card-admin-error .card-header,.login-error-card{border-color:#ff5630 !important}.border-light{border-color:#ebecf0 !important}.border-dark{border-color:#505f79 !important}.border-white{border-color:#fff !important}.rounded-sm{border-radius:.2rem !important}.rounded,.control-month-picker,.control-time-picker,.control-image-preview,.card-admin-settings-card,.media-check-icon{border-radius:.25rem !important}.rounded-top{border-top-left-radius:.25rem !important;border-top-right-radius:.25rem !important}.rounded-right{border-top-right-radius:.25rem !important;border-bottom-right-radius:.25rem !important}.rounded-bottom{border-bottom-right-radius:.25rem !important;border-bottom-left-radius:.25rem !important}.rounded-left{border-top-left-radius:.25rem !important;border-bottom-left-radius:.25rem !important}.rounded-lg,.card-admin-table img{border-radius:.3rem !important}.rounded-circle{border-radius:50% !important}.rounded-pill{border-radius:50rem !important}.rounded-0{border-radius:0 !important}.clearfix::after{display:block;clear:both;content:""}.d-none{display:none !important}.d-inline{display:inline !important}.d-inline-block,.control-image-preview{display:inline-block !important}.d-block,.card-admin-settings-card{display:block !important}.d-table{display:table !important}.d-table-row{display:table-row !important}.d-table-cell{display:table-cell !important}.d-flex,.control-time-picker,.page-header h1,.media-check-icon{display:-ms-flexbox !important;display:flex !important}.d-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}@media (min-width: 576px){.d-sm-none{display:none !important}.d-sm-inline{display:inline !important}.d-sm-inline-block{display:inline-block !important}.d-sm-block{display:block !important}.d-sm-table{display:table !important}.d-sm-table-row{display:table-row !important}.d-sm-table-cell{display:table-cell !important}.d-sm-flex{display:-ms-flexbox !important;display:flex !important}.d-sm-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}}@media (min-width: 768px){.d-md-none{display:none !important}.d-md-inline{display:inline !important}.d-md-inline-block{display:inline-block !important}.d-md-block{display:block !important}.d-md-table{display:table !important}.d-md-table-row{display:table-row !important}.d-md-table-cell{display:table-cell !important}.d-md-flex{display:-ms-flexbox !important;display:flex !important}.d-md-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}}@media (min-width: 992px){.d-lg-none{display:none !important}.d-lg-inline{display:inline !important}.d-lg-inline-block{display:inline-block !important}.d-lg-block{display:block !important}.d-lg-table{display:table !important}.d-lg-table-row{display:table-row !important}.d-lg-table-cell{display:table-cell !important}.d-lg-flex{display:-ms-flexbox !important;display:flex !important}.d-lg-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}}@media (min-width: 1200px){.d-xl-none{display:none !important}.d-xl-inline{display:inline !important}.d-xl-inline-block{display:inline-block !important}.d-xl-block{display:block !important}.d-xl-table{display:table !important}.d-xl-table-row{display:table-row !important}.d-xl-table-cell{display:table-cell !important}.d-xl-flex{display:-ms-flexbox !important;display:flex !important}.d-xl-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}}@media print{.d-print-none{display:none !important}.d-print-inline{display:inline !important}.d-print-inline-block{display:inline-block !important}.d-print-block{display:block !important}.d-print-table{display:table !important}.d-print-table-row{display:table-row !important}.d-print-table-cell{display:table-cell !important}.d-print-flex{display:-ms-flexbox !important;display:flex !important}.d-print-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}}.embed-responsive{position:relative;display:block;width:100%;padding:0;overflow:hidden}.embed-responsive::before{display:block;content:""}.embed-responsive .embed-responsive-item,.embed-responsive iframe,.embed-responsive embed,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-21by9::before{padding-top:42.85714%}.embed-responsive-16by9::before{padding-top:56.25%}.embed-responsive-4by3::before{padding-top:75%}.embed-responsive-1by1::before{padding-top:100%}.flex-row{-ms-flex-direction:row !important;flex-direction:row !important}.flex-column,.nav-side{-ms-flex-direction:column !important;flex-direction:column !important}.flex-row-reverse{-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-column-reverse{-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-fill{-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-grow-0{-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-grow-1{-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-start{-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-end{-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-center,.media-check-icon{-ms-flex-pack:center !important;justify-content:center !important}.justify-content-between{-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-start{-ms-flex-align:start !important;align-items:flex-start !important}.align-items-end{-ms-flex-align:end !important;align-items:flex-end !important}.align-items-center,.control-time-picker,.page-header h1,.media-check-icon{-ms-flex-align:center !important;align-items:center !important}.align-items-baseline{-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-stretch{-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}@media (min-width: 576px){.flex-sm-row{-ms-flex-direction:row !important;flex-direction:row !important}.flex-sm-column{-ms-flex-direction:column !important;flex-direction:column !important}.flex-sm-row-reverse{-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-sm-column-reverse{-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-sm-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-sm-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-sm-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-sm-fill{-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-sm-grow-0{-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-sm-grow-1{-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-sm-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-sm-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-sm-start{-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-sm-end{-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-sm-center{-ms-flex-pack:center !important;justify-content:center !important}.justify-content-sm-between{-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-sm-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-sm-start{-ms-flex-align:start !important;align-items:flex-start !important}.align-items-sm-end{-ms-flex-align:end !important;align-items:flex-end !important}.align-items-sm-center{-ms-flex-align:center !important;align-items:center !important}.align-items-sm-baseline{-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-sm-stretch{-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-sm-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-sm-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-sm-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-sm-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-sm-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-sm-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-sm-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-sm-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-sm-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-sm-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-sm-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-sm-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}}@media (min-width: 768px){.flex-md-row{-ms-flex-direction:row !important;flex-direction:row !important}.flex-md-column{-ms-flex-direction:column !important;flex-direction:column !important}.flex-md-row-reverse{-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-md-column-reverse{-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-md-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-md-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-md-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-md-fill{-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-md-grow-0{-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-md-grow-1{-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-md-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-md-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-md-start{-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-md-end{-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-md-center{-ms-flex-pack:center !important;justify-content:center !important}.justify-content-md-between{-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-md-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-md-start{-ms-flex-align:start !important;align-items:flex-start !important}.align-items-md-end{-ms-flex-align:end !important;align-items:flex-end !important}.align-items-md-center{-ms-flex-align:center !important;align-items:center !important}.align-items-md-baseline{-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-md-stretch{-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-md-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-md-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-md-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-md-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-md-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-md-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-md-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-md-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-md-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-md-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-md-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-md-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}}@media (min-width: 992px){.flex-lg-row{-ms-flex-direction:row !important;flex-direction:row !important}.flex-lg-column{-ms-flex-direction:column !important;flex-direction:column !important}.flex-lg-row-reverse{-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-lg-column-reverse{-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-lg-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-lg-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-lg-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-lg-fill{-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-lg-grow-0{-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-lg-grow-1{-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-lg-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-lg-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-lg-start{-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-lg-end{-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-lg-center{-ms-flex-pack:center !important;justify-content:center !important}.justify-content-lg-between{-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-lg-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-lg-start{-ms-flex-align:start !important;align-items:flex-start !important}.align-items-lg-end{-ms-flex-align:end !important;align-items:flex-end !important}.align-items-lg-center{-ms-flex-align:center !important;align-items:center !important}.align-items-lg-baseline{-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-lg-stretch{-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-lg-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-lg-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-lg-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-lg-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-lg-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-lg-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-lg-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-lg-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-lg-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-lg-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-lg-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-lg-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}}@media (min-width: 1200px){.flex-xl-row{-ms-flex-direction:row !important;flex-direction:row !important}.flex-xl-column{-ms-flex-direction:column !important;flex-direction:column !important}.flex-xl-row-reverse{-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-xl-column-reverse{-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-xl-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-xl-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-xl-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-xl-fill{-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-xl-grow-0{-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-xl-grow-1{-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-xl-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-xl-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-xl-start{-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-xl-end{-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-xl-center{-ms-flex-pack:center !important;justify-content:center !important}.justify-content-xl-between{-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-xl-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-xl-start{-ms-flex-align:start !important;align-items:flex-start !important}.align-items-xl-end{-ms-flex-align:end !important;align-items:flex-end !important}.align-items-xl-center{-ms-flex-align:center !important;align-items:center !important}.align-items-xl-baseline{-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-xl-stretch{-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-xl-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-xl-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-xl-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-xl-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-xl-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-xl-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-xl-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-xl-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-xl-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-xl-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-xl-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-xl-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}}.float-left{float:left !important}.float-right{float:right !important}.float-none{float:none !important}@media (min-width: 576px){.float-sm-left{float:left !important}.float-sm-right{float:right !important}.float-sm-none{float:none !important}}@media (min-width: 768px){.float-md-left{float:left !important}.float-md-right{float:right !important}.float-md-none{float:none !important}}@media (min-width: 992px){.float-lg-left{float:left !important}.float-lg-right{float:right !important}.float-lg-none{float:none !important}}@media (min-width: 1200px){.float-xl-left{float:left !important}.float-xl-right{float:right !important}.float-xl-none{float:none !important}}.overflow-auto{overflow:auto !important}.overflow-hidden{overflow:hidden !important}.position-static{position:static !important}.position-relative{position:relative !important}.position-absolute{position:absolute !important}.position-fixed{position:fixed !important}.position-sticky{position:-webkit-sticky !important;position:sticky !important}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}@supports ((position: -webkit-sticky) or (position: sticky)){.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.sr-only{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);white-space:nowrap;border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;overflow:visible;clip:auto;white-space:normal}.shadow-sm,.login-form-card,.card-admin-settings-grid,.card-admin-info,.card-admin-table,.card-admin-form,.card-admin-error,.login-error-card{box-shadow:0 0.125rem 0.25rem rgba(9,30,66,0.075) !important}.shadow{box-shadow:0 0.5rem 1rem rgba(9,30,66,0.15) !important}.shadow-lg{box-shadow:0 1rem 3rem rgba(9,30,66,0.175) !important}.shadow-none{box-shadow:none !important}.w-25{width:25% !important}.w-50{width:50% !important}.w-75{width:75% !important}.w-100,.card-admin-table .row-select label{width:100% !important}.w-auto{width:auto !important}.h-25{height:25% !important}.h-50{height:50% !important}.h-75{height:75% !important}.h-100{height:100% !important}.h-auto{height:auto !important}.mw-100{max-width:100% !important}.mh-100{max-height:100% !important}.min-vw-100{min-width:100vw !important}.min-vh-100{min-height:100vh !important}.vw-100{width:100vw !important}.vh-100{height:100vh !important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;pointer-events:auto;content:"";background-color:rgba(0,0,0,0)}.m-0,.page-header h1,.card-admin-info .card-title,.card-admin-table .card-title,.card-admin-table table,.card-admin-table h5,.card-admin-table .row-select label{margin:0 !important}.mt-0,.media-admin-check h5,.my-0{margin-top:0 !important}.mr-0,.mx-0{margin-right:0 !important}.mb-0,.card-admin-form .form-group:last-child,.card-admin-form .form-fieldset .form-group:last-child,.my-0{margin-bottom:0 !important}.ml-0,.mx-0{margin-left:0 !important}.m-1,.card-admin-settings-card{margin:.25rem !important}.mt-1,.control-checkboxselect .form-check+.form-check,.control-radioselect .form-check+.form-check,.control-image-preview div,.my-1{margin-top:.25rem !important}.mr-1,.page-action .fa,.page-action .fab,.mx-1{margin-right:.25rem !important}.mb-1,.card-admin-settings-card h5,.my-1{margin-bottom:.25rem !important}.ml-1,.mx-1{margin-left:.25rem !important}.m-2{margin:.5rem !important}.mt-2,.my-2{margin-top:.5rem !important}.mr-2,.mx-2{margin-right:.5rem !important}.mb-2,.control-image-preview,.my-2{margin-bottom:.5rem !important}.ml-2,.control-image-preview div span+span,.mx-2{margin-left:.5rem !important}.m-3{margin:1rem !important}.mt-3,.my-3{margin-top:1rem !important}.mr-3,.media-check-icon,.mx-3{margin-right:1rem !important}.mb-3,.page-header,.card-admin-info,.card-admin-form .card-admin-table,.my-3{margin-bottom:1rem !important}.ml-3,.control-yesno-switch .radio+.radio,.page-header h1 small,.mx-3{margin-left:1rem !important}.m-4{margin:1.5rem !important}.mt-4,.my-4{margin-top:1.5rem !important}.mr-4,.mx-4{margin-right:1.5rem !important}.mb-4,.my-4{margin-bottom:1.5rem !important}.ml-4,.mx-4{margin-left:1.5rem !important}.m-5{margin:3rem !important}.mt-5,.my-5{margin-top:3rem !important}.mr-5,.mx-5{margin-right:3rem !important}.mb-5,.my-5{margin-bottom:3rem !important}.ml-5,.mx-5{margin-left:3rem !important}.p-0,.navbar .btn-user{padding:0 !important}.pt-0,.card-admin-form .form-fieldset:first-child,.py-0{padding-top:0 !important}.pr-0,.px-0{padding-right:0 !important}.pb-0,.control-image-preview.control-image-metadata,.card-admin-form .form-fieldset:last-child,.py-0{padding-bottom:0 !important}.pl-0,.card-admin-table .badges-list,.px-0{padding-left:0 !important}.p-1,.control-month-picker,.control-time-picker{padding:.25rem !important}.pt-1,.py-1,.page-header h1 small,.card-admin-settings-grid .card-body,.card-admin-settings-grid .card-admin-form .form-fieldset,.card-admin-form .card-admin-settings-grid .form-fieldset,.card-admin-table th,.card-admin-table td{padding-top:.25rem !important}.pr-1,.px-1{padding-right:.25rem !important}.pb-1,.py-1,.page-header h1 small,.card-admin-settings-grid .card-body,.card-admin-settings-grid .card-admin-form .form-fieldset,.card-admin-form .card-admin-settings-grid .form-fieldset,.card-admin-table th,.card-admin-table td{padding-bottom:.25rem !important}.pl-1,.nav-side .media-body,.px-1{padding-left:.25rem !important}.p-2,.card-admin-settings-card,.card-admin-table .row-select label{padding:.5rem !important}.pt-2,.py-2,.control-image-preview div{padding-top:.5rem !important}.pr-2,.px-2{padding-right:.5rem !important}.pb-2,.py-2,.control-image-preview div{padding-bottom:.5rem !important}.pl-2,.px-2{padding-left:.5rem !important}.p-3,.control-image-preview{padding:1rem !important}.pt-3,.py-3,.card-admin-analytics-summary,.card-admin-table .blankslate td{padding-top:1rem !important}.pr-3,.px-3{padding-right:1rem !important}.pb-3,.py-3,.card-admin-analytics-summary,.card-admin-table .blankslate td{padding-bottom:1rem !important}.pl-3,.page-header h1 small,.px-3{padding-left:1rem !important}.p-4{padding:1.5rem !important}.pt-4,.py-4{padding-top:1.5rem !important}.pr-4,.px-4{padding-right:1.5rem !important}.pb-4,.py-4{padding-bottom:1.5rem !important}.pl-4,.px-4{padding-left:1.5rem !important}.p-5{padding:3rem !important}.pt-5,.py-5{padding-top:3rem !important}.pr-5,.px-5{padding-right:3rem !important}.pb-5,.py-5{padding-bottom:3rem !important}.pl-5,.px-5{padding-left:3rem !important}.m-n1{margin:-.25rem !important}.mt-n1,.my-n1{margin-top:-.25rem !important}.mr-n1,.mx-n1{margin-right:-.25rem !important}.mb-n1,.my-n1{margin-bottom:-.25rem !important}.ml-n1,.mx-n1{margin-left:-.25rem !important}.m-n2{margin:-.5rem !important}.mt-n2,.my-n2{margin-top:-.5rem !important}.mr-n2,.mx-n2{margin-right:-.5rem !important}.mb-n2,.my-n2{margin-bottom:-.5rem !important}.ml-n2,.mx-n2{margin-left:-.5rem !important}.m-n3{margin:-1rem !important}.mt-n3,.my-n3{margin-top:-1rem !important}.mr-n3,.mx-n3{margin-right:-1rem !important}.mb-n3,.my-n3{margin-bottom:-1rem !important}.ml-n3,.mx-n3{margin-left:-1rem !important}.m-n4{margin:-1.5rem !important}.mt-n4,.my-n4{margin-top:-1.5rem !important}.mr-n4,.mx-n4{margin-right:-1.5rem !important}.mb-n4,.my-n4{margin-bottom:-1.5rem !important}.ml-n4,.mx-n4{margin-left:-1.5rem !important}.m-n5{margin:-3rem !important}.mt-n5,.my-n5{margin-top:-3rem !important}.mr-n5,.mx-n5{margin-right:-3rem !important}.mb-n5,.my-n5{margin-bottom:-3rem !important}.ml-n5,.mx-n5{margin-left:-3rem !important}.m-auto{margin:auto !important}.mt-auto,.my-auto{margin-top:auto !important}.mr-auto,.mx-auto{margin-right:auto !important}.mb-auto,.my-auto{margin-bottom:auto !important}.ml-auto,.mx-auto{margin-left:auto !important}@media (min-width: 576px){.m-sm-0{margin:0 !important}.mt-sm-0,.my-sm-0{margin-top:0 !important}.mr-sm-0,.mx-sm-0{margin-right:0 !important}.mb-sm-0,.my-sm-0{margin-bottom:0 !important}.ml-sm-0,.mx-sm-0{margin-left:0 !important}.m-sm-1{margin:.25rem !important}.mt-sm-1,.my-sm-1{margin-top:.25rem !important}.mr-sm-1,.mx-sm-1{margin-right:.25rem !important}.mb-sm-1,.my-sm-1{margin-bottom:.25rem !important}.ml-sm-1,.mx-sm-1{margin-left:.25rem !important}.m-sm-2{margin:.5rem !important}.mt-sm-2,.my-sm-2{margin-top:.5rem !important}.mr-sm-2,.mx-sm-2{margin-right:.5rem !important}.mb-sm-2,.my-sm-2{margin-bottom:.5rem !important}.ml-sm-2,.mx-sm-2{margin-left:.5rem !important}.m-sm-3{margin:1rem !important}.mt-sm-3,.my-sm-3{margin-top:1rem !important}.mr-sm-3,.mx-sm-3{margin-right:1rem !important}.mb-sm-3,.my-sm-3{margin-bottom:1rem !important}.ml-sm-3,.mx-sm-3{margin-left:1rem !important}.m-sm-4{margin:1.5rem !important}.mt-sm-4,.my-sm-4{margin-top:1.5rem !important}.mr-sm-4,.mx-sm-4{margin-right:1.5rem !important}.mb-sm-4,.my-sm-4{margin-bottom:1.5rem !important}.ml-sm-4,.mx-sm-4{margin-left:1.5rem !important}.m-sm-5{margin:3rem !important}.mt-sm-5,.my-sm-5{margin-top:3rem !important}.mr-sm-5,.mx-sm-5{margin-right:3rem !important}.mb-sm-5,.my-sm-5{margin-bottom:3rem !important}.ml-sm-5,.mx-sm-5{margin-left:3rem !important}.p-sm-0{padding:0 !important}.pt-sm-0,.py-sm-0{padding-top:0 !important}.pr-sm-0,.px-sm-0{padding-right:0 !important}.pb-sm-0,.py-sm-0{padding-bottom:0 !important}.pl-sm-0,.px-sm-0{padding-left:0 !important}.p-sm-1{padding:.25rem !important}.pt-sm-1,.py-sm-1{padding-top:.25rem !important}.pr-sm-1,.px-sm-1{padding-right:.25rem !important}.pb-sm-1,.py-sm-1{padding-bottom:.25rem !important}.pl-sm-1,.px-sm-1{padding-left:.25rem !important}.p-sm-2{padding:.5rem !important}.pt-sm-2,.py-sm-2{padding-top:.5rem !important}.pr-sm-2,.px-sm-2{padding-right:.5rem !important}.pb-sm-2,.py-sm-2{padding-bottom:.5rem !important}.pl-sm-2,.px-sm-2{padding-left:.5rem !important}.p-sm-3{padding:1rem !important}.pt-sm-3,.py-sm-3{padding-top:1rem !important}.pr-sm-3,.px-sm-3{padding-right:1rem !important}.pb-sm-3,.py-sm-3{padding-bottom:1rem !important}.pl-sm-3,.px-sm-3{padding-left:1rem !important}.p-sm-4{padding:1.5rem !important}.pt-sm-4,.py-sm-4{padding-top:1.5rem !important}.pr-sm-4,.px-sm-4{padding-right:1.5rem !important}.pb-sm-4,.py-sm-4{padding-bottom:1.5rem !important}.pl-sm-4,.px-sm-4{padding-left:1.5rem !important}.p-sm-5{padding:3rem !important}.pt-sm-5,.py-sm-5{padding-top:3rem !important}.pr-sm-5,.px-sm-5{padding-right:3rem !important}.pb-sm-5,.py-sm-5{padding-bottom:3rem !important}.pl-sm-5,.px-sm-5{padding-left:3rem !important}.m-sm-n1{margin:-.25rem !important}.mt-sm-n1,.my-sm-n1{margin-top:-.25rem !important}.mr-sm-n1,.mx-sm-n1{margin-right:-.25rem !important}.mb-sm-n1,.my-sm-n1{margin-bottom:-.25rem !important}.ml-sm-n1,.mx-sm-n1{margin-left:-.25rem !important}.m-sm-n2{margin:-.5rem !important}.mt-sm-n2,.my-sm-n2{margin-top:-.5rem !important}.mr-sm-n2,.mx-sm-n2{margin-right:-.5rem !important}.mb-sm-n2,.my-sm-n2{margin-bottom:-.5rem !important}.ml-sm-n2,.mx-sm-n2{margin-left:-.5rem !important}.m-sm-n3{margin:-1rem !important}.mt-sm-n3,.my-sm-n3{margin-top:-1rem !important}.mr-sm-n3,.mx-sm-n3{margin-right:-1rem !important}.mb-sm-n3,.my-sm-n3{margin-bottom:-1rem !important}.ml-sm-n3,.mx-sm-n3{margin-left:-1rem !important}.m-sm-n4{margin:-1.5rem !important}.mt-sm-n4,.my-sm-n4{margin-top:-1.5rem !important}.mr-sm-n4,.mx-sm-n4{margin-right:-1.5rem !important}.mb-sm-n4,.my-sm-n4{margin-bottom:-1.5rem !important}.ml-sm-n4,.mx-sm-n4{margin-left:-1.5rem !important}.m-sm-n5{margin:-3rem !important}.mt-sm-n5,.my-sm-n5{margin-top:-3rem !important}.mr-sm-n5,.mx-sm-n5{margin-right:-3rem !important}.mb-sm-n5,.my-sm-n5{margin-bottom:-3rem !important}.ml-sm-n5,.mx-sm-n5{margin-left:-3rem !important}.m-sm-auto{margin:auto !important}.mt-sm-auto,.my-sm-auto{margin-top:auto !important}.mr-sm-auto,.mx-sm-auto{margin-right:auto !important}.mb-sm-auto,.my-sm-auto{margin-bottom:auto !important}.ml-sm-auto,.mx-sm-auto{margin-left:auto !important}}@media (min-width: 768px){.m-md-0{margin:0 !important}.mt-md-0,.my-md-0{margin-top:0 !important}.mr-md-0,.mx-md-0{margin-right:0 !important}.mb-md-0,.my-md-0{margin-bottom:0 !important}.ml-md-0,.mx-md-0{margin-left:0 !important}.m-md-1{margin:.25rem !important}.mt-md-1,.my-md-1{margin-top:.25rem !important}.mr-md-1,.mx-md-1{margin-right:.25rem !important}.mb-md-1,.my-md-1{margin-bottom:.25rem !important}.ml-md-1,.mx-md-1{margin-left:.25rem !important}.m-md-2{margin:.5rem !important}.mt-md-2,.my-md-2{margin-top:.5rem !important}.mr-md-2,.mx-md-2{margin-right:.5rem !important}.mb-md-2,.my-md-2{margin-bottom:.5rem !important}.ml-md-2,.mx-md-2{margin-left:.5rem !important}.m-md-3{margin:1rem !important}.mt-md-3,.my-md-3{margin-top:1rem !important}.mr-md-3,.mx-md-3{margin-right:1rem !important}.mb-md-3,.my-md-3{margin-bottom:1rem !important}.ml-md-3,.mx-md-3{margin-left:1rem !important}.m-md-4{margin:1.5rem !important}.mt-md-4,.my-md-4{margin-top:1.5rem !important}.mr-md-4,.mx-md-4{margin-right:1.5rem !important}.mb-md-4,.my-md-4{margin-bottom:1.5rem !important}.ml-md-4,.mx-md-4{margin-left:1.5rem !important}.m-md-5{margin:3rem !important}.mt-md-5,.my-md-5{margin-top:3rem !important}.mr-md-5,.mx-md-5{margin-right:3rem !important}.mb-md-5,.my-md-5{margin-bottom:3rem !important}.ml-md-5,.mx-md-5{margin-left:3rem !important}.p-md-0{padding:0 !important}.pt-md-0,.py-md-0{padding-top:0 !important}.pr-md-0,.px-md-0{padding-right:0 !important}.pb-md-0,.py-md-0{padding-bottom:0 !important}.pl-md-0,.px-md-0{padding-left:0 !important}.p-md-1{padding:.25rem !important}.pt-md-1,.py-md-1{padding-top:.25rem !important}.pr-md-1,.px-md-1{padding-right:.25rem !important}.pb-md-1,.py-md-1{padding-bottom:.25rem !important}.pl-md-1,.px-md-1{padding-left:.25rem !important}.p-md-2{padding:.5rem !important}.pt-md-2,.py-md-2{padding-top:.5rem !important}.pr-md-2,.px-md-2{padding-right:.5rem !important}.pb-md-2,.py-md-2{padding-bottom:.5rem !important}.pl-md-2,.px-md-2{padding-left:.5rem !important}.p-md-3{padding:1rem !important}.pt-md-3,.py-md-3{padding-top:1rem !important}.pr-md-3,.px-md-3{padding-right:1rem !important}.pb-md-3,.py-md-3{padding-bottom:1rem !important}.pl-md-3,.px-md-3{padding-left:1rem !important}.p-md-4{padding:1.5rem !important}.pt-md-4,.py-md-4{padding-top:1.5rem !important}.pr-md-4,.px-md-4{padding-right:1.5rem !important}.pb-md-4,.py-md-4{padding-bottom:1.5rem !important}.pl-md-4,.px-md-4{padding-left:1.5rem !important}.p-md-5{padding:3rem !important}.pt-md-5,.py-md-5{padding-top:3rem !important}.pr-md-5,.px-md-5{padding-right:3rem !important}.pb-md-5,.py-md-5{padding-bottom:3rem !important}.pl-md-5,.px-md-5{padding-left:3rem !important}.m-md-n1{margin:-.25rem !important}.mt-md-n1,.my-md-n1{margin-top:-.25rem !important}.mr-md-n1,.mx-md-n1{margin-right:-.25rem !important}.mb-md-n1,.my-md-n1{margin-bottom:-.25rem !important}.ml-md-n1,.mx-md-n1{margin-left:-.25rem !important}.m-md-n2{margin:-.5rem !important}.mt-md-n2,.my-md-n2{margin-top:-.5rem !important}.mr-md-n2,.mx-md-n2{margin-right:-.5rem !important}.mb-md-n2,.my-md-n2{margin-bottom:-.5rem !important}.ml-md-n2,.mx-md-n2{margin-left:-.5rem !important}.m-md-n3{margin:-1rem !important}.mt-md-n3,.my-md-n3{margin-top:-1rem !important}.mr-md-n3,.mx-md-n3{margin-right:-1rem !important}.mb-md-n3,.my-md-n3{margin-bottom:-1rem !important}.ml-md-n3,.mx-md-n3{margin-left:-1rem !important}.m-md-n4{margin:-1.5rem !important}.mt-md-n4,.my-md-n4{margin-top:-1.5rem !important}.mr-md-n4,.mx-md-n4{margin-right:-1.5rem !important}.mb-md-n4,.my-md-n4{margin-bottom:-1.5rem !important}.ml-md-n4,.mx-md-n4{margin-left:-1.5rem !important}.m-md-n5{margin:-3rem !important}.mt-md-n5,.my-md-n5{margin-top:-3rem !important}.mr-md-n5,.mx-md-n5{margin-right:-3rem !important}.mb-md-n5,.my-md-n5{margin-bottom:-3rem !important}.ml-md-n5,.mx-md-n5{margin-left:-3rem !important}.m-md-auto{margin:auto !important}.mt-md-auto,.my-md-auto{margin-top:auto !important}.mr-md-auto,.mx-md-auto{margin-right:auto !important}.mb-md-auto,.my-md-auto{margin-bottom:auto !important}.ml-md-auto,.mx-md-auto{margin-left:auto !important}}@media (min-width: 992px){.m-lg-0{margin:0 !important}.mt-lg-0,.my-lg-0{margin-top:0 !important}.mr-lg-0,.mx-lg-0{margin-right:0 !important}.mb-lg-0,.my-lg-0{margin-bottom:0 !important}.ml-lg-0,.mx-lg-0{margin-left:0 !important}.m-lg-1{margin:.25rem !important}.mt-lg-1,.my-lg-1{margin-top:.25rem !important}.mr-lg-1,.mx-lg-1{margin-right:.25rem !important}.mb-lg-1,.my-lg-1{margin-bottom:.25rem !important}.ml-lg-1,.mx-lg-1{margin-left:.25rem !important}.m-lg-2{margin:.5rem !important}.mt-lg-2,.my-lg-2{margin-top:.5rem !important}.mr-lg-2,.mx-lg-2{margin-right:.5rem !important}.mb-lg-2,.my-lg-2{margin-bottom:.5rem !important}.ml-lg-2,.mx-lg-2{margin-left:.5rem !important}.m-lg-3{margin:1rem !important}.mt-lg-3,.my-lg-3{margin-top:1rem !important}.mr-lg-3,.mx-lg-3{margin-right:1rem !important}.mb-lg-3,.my-lg-3{margin-bottom:1rem !important}.ml-lg-3,.mx-lg-3{margin-left:1rem !important}.m-lg-4{margin:1.5rem !important}.mt-lg-4,.my-lg-4{margin-top:1.5rem !important}.mr-lg-4,.mx-lg-4{margin-right:1.5rem !important}.mb-lg-4,.my-lg-4{margin-bottom:1.5rem !important}.ml-lg-4,.mx-lg-4{margin-left:1.5rem !important}.m-lg-5{margin:3rem !important}.mt-lg-5,.my-lg-5{margin-top:3rem !important}.mr-lg-5,.mx-lg-5{margin-right:3rem !important}.mb-lg-5,.my-lg-5{margin-bottom:3rem !important}.ml-lg-5,.mx-lg-5{margin-left:3rem !important}.p-lg-0{padding:0 !important}.pt-lg-0,.py-lg-0{padding-top:0 !important}.pr-lg-0,.px-lg-0{padding-right:0 !important}.pb-lg-0,.py-lg-0{padding-bottom:0 !important}.pl-lg-0,.px-lg-0{padding-left:0 !important}.p-lg-1{padding:.25rem !important}.pt-lg-1,.py-lg-1{padding-top:.25rem !important}.pr-lg-1,.px-lg-1{padding-right:.25rem !important}.pb-lg-1,.py-lg-1{padding-bottom:.25rem !important}.pl-lg-1,.px-lg-1{padding-left:.25rem !important}.p-lg-2{padding:.5rem !important}.pt-lg-2,.py-lg-2{padding-top:.5rem !important}.pr-lg-2,.px-lg-2{padding-right:.5rem !important}.pb-lg-2,.py-lg-2{padding-bottom:.5rem !important}.pl-lg-2,.px-lg-2{padding-left:.5rem !important}.p-lg-3{padding:1rem !important}.pt-lg-3,.py-lg-3{padding-top:1rem !important}.pr-lg-3,.px-lg-3{padding-right:1rem !important}.pb-lg-3,.py-lg-3{padding-bottom:1rem !important}.pl-lg-3,.px-lg-3{padding-left:1rem !important}.p-lg-4{padding:1.5rem !important}.pt-lg-4,.py-lg-4{padding-top:1.5rem !important}.pr-lg-4,.px-lg-4{padding-right:1.5rem !important}.pb-lg-4,.py-lg-4{padding-bottom:1.5rem !important}.pl-lg-4,.px-lg-4{padding-left:1.5rem !important}.p-lg-5{padding:3rem !important}.pt-lg-5,.py-lg-5{padding-top:3rem !important}.pr-lg-5,.px-lg-5{padding-right:3rem !important}.pb-lg-5,.py-lg-5{padding-bottom:3rem !important}.pl-lg-5,.px-lg-5{padding-left:3rem !important}.m-lg-n1{margin:-.25rem !important}.mt-lg-n1,.my-lg-n1{margin-top:-.25rem !important}.mr-lg-n1,.mx-lg-n1{margin-right:-.25rem !important}.mb-lg-n1,.my-lg-n1{margin-bottom:-.25rem !important}.ml-lg-n1,.mx-lg-n1{margin-left:-.25rem !important}.m-lg-n2{margin:-.5rem !important}.mt-lg-n2,.my-lg-n2{margin-top:-.5rem !important}.mr-lg-n2,.mx-lg-n2{margin-right:-.5rem !important}.mb-lg-n2,.my-lg-n2{margin-bottom:-.5rem !important}.ml-lg-n2,.mx-lg-n2{margin-left:-.5rem !important}.m-lg-n3{margin:-1rem !important}.mt-lg-n3,.my-lg-n3{margin-top:-1rem !important}.mr-lg-n3,.mx-lg-n3{margin-right:-1rem !important}.mb-lg-n3,.my-lg-n3{margin-bottom:-1rem !important}.ml-lg-n3,.mx-lg-n3{margin-left:-1rem !important}.m-lg-n4{margin:-1.5rem !important}.mt-lg-n4,.my-lg-n4{margin-top:-1.5rem !important}.mr-lg-n4,.mx-lg-n4{margin-right:-1.5rem !important}.mb-lg-n4,.my-lg-n4{margin-bottom:-1.5rem !important}.ml-lg-n4,.mx-lg-n4{margin-left:-1.5rem !important}.m-lg-n5{margin:-3rem !important}.mt-lg-n5,.my-lg-n5{margin-top:-3rem !important}.mr-lg-n5,.mx-lg-n5{margin-right:-3rem !important}.mb-lg-n5,.my-lg-n5{margin-bottom:-3rem !important}.ml-lg-n5,.mx-lg-n5{margin-left:-3rem !important}.m-lg-auto{margin:auto !important}.mt-lg-auto,.my-lg-auto{margin-top:auto !important}.mr-lg-auto,.mx-lg-auto{margin-right:auto !important}.mb-lg-auto,.my-lg-auto{margin-bottom:auto !important}.ml-lg-auto,.mx-lg-auto{margin-left:auto !important}}@media (min-width: 1200px){.m-xl-0{margin:0 !important}.mt-xl-0,.my-xl-0{margin-top:0 !important}.mr-xl-0,.mx-xl-0{margin-right:0 !important}.mb-xl-0,.my-xl-0{margin-bottom:0 !important}.ml-xl-0,.mx-xl-0{margin-left:0 !important}.m-xl-1{margin:.25rem !important}.mt-xl-1,.my-xl-1{margin-top:.25rem !important}.mr-xl-1,.mx-xl-1{margin-right:.25rem !important}.mb-xl-1,.my-xl-1{margin-bottom:.25rem !important}.ml-xl-1,.mx-xl-1{margin-left:.25rem !important}.m-xl-2{margin:.5rem !important}.mt-xl-2,.my-xl-2{margin-top:.5rem !important}.mr-xl-2,.mx-xl-2{margin-right:.5rem !important}.mb-xl-2,.my-xl-2{margin-bottom:.5rem !important}.ml-xl-2,.mx-xl-2{margin-left:.5rem !important}.m-xl-3{margin:1rem !important}.mt-xl-3,.my-xl-3{margin-top:1rem !important}.mr-xl-3,.mx-xl-3{margin-right:1rem !important}.mb-xl-3,.my-xl-3{margin-bottom:1rem !important}.ml-xl-3,.mx-xl-3{margin-left:1rem !important}.m-xl-4{margin:1.5rem !important}.mt-xl-4,.my-xl-4{margin-top:1.5rem !important}.mr-xl-4,.mx-xl-4{margin-right:1.5rem !important}.mb-xl-4,.my-xl-4{margin-bottom:1.5rem !important}.ml-xl-4,.mx-xl-4{margin-left:1.5rem !important}.m-xl-5{margin:3rem !important}.mt-xl-5,.my-xl-5{margin-top:3rem !important}.mr-xl-5,.mx-xl-5{margin-right:3rem !important}.mb-xl-5,.my-xl-5{margin-bottom:3rem !important}.ml-xl-5,.mx-xl-5{margin-left:3rem !important}.p-xl-0{padding:0 !important}.pt-xl-0,.py-xl-0{padding-top:0 !important}.pr-xl-0,.px-xl-0{padding-right:0 !important}.pb-xl-0,.py-xl-0{padding-bottom:0 !important}.pl-xl-0,.px-xl-0{padding-left:0 !important}.p-xl-1{padding:.25rem !important}.pt-xl-1,.py-xl-1{padding-top:.25rem !important}.pr-xl-1,.px-xl-1{padding-right:.25rem !important}.pb-xl-1,.py-xl-1{padding-bottom:.25rem !important}.pl-xl-1,.px-xl-1{padding-left:.25rem !important}.p-xl-2{padding:.5rem !important}.pt-xl-2,.py-xl-2{padding-top:.5rem !important}.pr-xl-2,.px-xl-2{padding-right:.5rem !important}.pb-xl-2,.py-xl-2{padding-bottom:.5rem !important}.pl-xl-2,.px-xl-2{padding-left:.5rem !important}.p-xl-3{padding:1rem !important}.pt-xl-3,.py-xl-3{padding-top:1rem !important}.pr-xl-3,.px-xl-3{padding-right:1rem !important}.pb-xl-3,.py-xl-3{padding-bottom:1rem !important}.pl-xl-3,.px-xl-3{padding-left:1rem !important}.p-xl-4{padding:1.5rem !important}.pt-xl-4,.py-xl-4{padding-top:1.5rem !important}.pr-xl-4,.px-xl-4{padding-right:1.5rem !important}.pb-xl-4,.py-xl-4{padding-bottom:1.5rem !important}.pl-xl-4,.px-xl-4{padding-left:1.5rem !important}.p-xl-5{padding:3rem !important}.pt-xl-5,.py-xl-5{padding-top:3rem !important}.pr-xl-5,.px-xl-5{padding-right:3rem !important}.pb-xl-5,.py-xl-5{padding-bottom:3rem !important}.pl-xl-5,.px-xl-5{padding-left:3rem !important}.m-xl-n1{margin:-.25rem !important}.mt-xl-n1,.my-xl-n1{margin-top:-.25rem !important}.mr-xl-n1,.mx-xl-n1{margin-right:-.25rem !important}.mb-xl-n1,.my-xl-n1{margin-bottom:-.25rem !important}.ml-xl-n1,.mx-xl-n1{margin-left:-.25rem !important}.m-xl-n2{margin:-.5rem !important}.mt-xl-n2,.my-xl-n2{margin-top:-.5rem !important}.mr-xl-n2,.mx-xl-n2{margin-right:-.5rem !important}.mb-xl-n2,.my-xl-n2{margin-bottom:-.5rem !important}.ml-xl-n2,.mx-xl-n2{margin-left:-.5rem !important}.m-xl-n3{margin:-1rem !important}.mt-xl-n3,.my-xl-n3{margin-top:-1rem !important}.mr-xl-n3,.mx-xl-n3{margin-right:-1rem !important}.mb-xl-n3,.my-xl-n3{margin-bottom:-1rem !important}.ml-xl-n3,.mx-xl-n3{margin-left:-1rem !important}.m-xl-n4{margin:-1.5rem !important}.mt-xl-n4,.my-xl-n4{margin-top:-1.5rem !important}.mr-xl-n4,.mx-xl-n4{margin-right:-1.5rem !important}.mb-xl-n4,.my-xl-n4{margin-bottom:-1.5rem !important}.ml-xl-n4,.mx-xl-n4{margin-left:-1.5rem !important}.m-xl-n5{margin:-3rem !important}.mt-xl-n5,.my-xl-n5{margin-top:-3rem !important}.mr-xl-n5,.mx-xl-n5{margin-right:-3rem !important}.mb-xl-n5,.my-xl-n5{margin-bottom:-3rem !important}.ml-xl-n5,.mx-xl-n5{margin-left:-3rem !important}.m-xl-auto{margin:auto !important}.mt-xl-auto,.my-xl-auto{margin-top:auto !important}.mr-xl-auto,.mx-xl-auto{margin-right:auto !important}.mb-xl-auto,.my-xl-auto{margin-bottom:auto !important}.ml-xl-auto,.mx-xl-auto{margin-left:auto !important}}.text-monospace{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace !important}.text-justify{text-align:justify !important}.text-wrap{white-space:normal !important}.text-nowrap,.card-admin-table th,.card-admin-table .badges-list{white-space:nowrap !important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-left{text-align:left !important}.text-right,.card-admin-table .badges-list{text-align:right !important}.text-center,.control-image-preview,.card-admin-stat,.card-admin-analytics-summary,.card-admin-table .row-select label,.card-admin-table .blankslate td{text-align:center !important}@media (min-width: 576px){.text-sm-left{text-align:left !important}.text-sm-right{text-align:right !important}.text-sm-center{text-align:center !important}}@media (min-width: 768px){.text-md-left{text-align:left !important}.text-md-right{text-align:right !important}.text-md-center{text-align:center !important}}@media (min-width: 992px){.text-lg-left{text-align:left !important}.text-lg-right{text-align:right !important}.text-lg-center{text-align:center !important}}@media (min-width: 1200px){.text-xl-left{text-align:left !important}.text-xl-right{text-align:right !important}.text-xl-center{text-align:center !important}}.text-lowercase{text-transform:lowercase !important}.text-uppercase{text-transform:uppercase !important}.text-capitalize{text-transform:capitalize !important}.font-weight-light{font-weight:300 !important}.font-weight-lighter{font-weight:lighter !important}.font-weight-normal{font-weight:400 !important}.font-weight-bold,.card-admin-table .item-name,.card-admin-table h5{font-weight:700 !important}.font-weight-bolder{font-weight:bolder !important}.font-italic{font-style:italic !important}.text-white{color:#fff !important}.text-primary{color:#6554c0 !important}a.text-primary:hover,a.text-primary:focus{color:#443692 !important}.text-secondary{color:#a5adba !important}a.text-secondary:hover,a.text-secondary:focus{color:#7a8699 !important}.text-success{color:#36b37e !important}a.text-success:hover,a.text-success:focus{color:#247855 !important}.text-info{color:#00b8d9 !important}a.text-info:hover,a.text-info:focus{color:#00778d !important}.text-warning{color:#ffc107 !important}a.text-warning:hover,a.text-warning:focus{color:#ba8b00 !important}.text-danger{color:#ff5630 !important}a.text-danger:hover,a.text-danger:focus{color:#e32a00 !important}.text-light{color:#ebecf0 !important}a.text-light:hover,a.text-light:focus{color:#bfc2cf !important}.text-dark{color:#505f79 !important}a.text-dark:hover,a.text-dark:focus{color:#323b4b !important}.text-body{color:#172b4d !important}.text-muted,.control-time-picker span,.control-image-preview div,.page-header h1 small{color:#a5adba !important}.text-black-50{color:rgba(9,30,66,0.5) !important}.text-white-50{color:rgba(255,255,255,0.5) !important}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.text-decoration-none{text-decoration:none !important}.text-break{word-break:break-word !important;overflow-wrap:break-word !important}.text-reset{color:inherit !important}.visible{visibility:visible !important}.invisible{visibility:hidden !important}html,body{height:100%}.control-checkboxselect .form-check,.control-radioselect .form-check{padding:0}.control-checkboxselect .form-check-label,.control-radioselect .form-check-label{font-size:.875rem}.control-month-picker{width:350px}.control-time-picker{width:280px;height:100%;font-size:4rem}.control-time-picker .row{height:100%}.control-time-picker input{font-size:4rem}.control-time-picker span{position:relative;bottom:.25rem}.control-image-preview{background-color:#fff}.control-image-preview img{max-width:300px;max-height:200px}.control-image-preview div{font-size:.875rem}.login-form{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center}.login-form-container{width:100%;padding:30px}.login-form-logo{margin-bottom:2rem;text-align:center}.login-form-logo img{max-width:200px;max-height:64px}.login-form-card{max-width:340px;margin:0 auto}.login-form-title{font-size:1.25rem;text-align:center}.navbar-brand{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center}.navbar-brand img{height:2rem;margin-right:.5rem;border-radius:.25rem}.navbar .btn-user{overflow:hidden}.navbar .btn-user img{height:2rem}.col-nav-side{max-width:240px}.nav-side .media-icon{width:30px;text-align:center}.nav-side .nav-link.active{color:#091e42}.nav-side .nav-link.active .media-icon{color:#6554c0}.nav-side .nav-section.active{font-weight:700}.nav-side .nav-action .media-body{margin-left:30px}.page-header h1{font-size:1.25rem}.page-header h1 a{color:#172b4d}.page-header h1 small{font-size:1.25rem;vertical-align:middle}.card-admin-settings-card{line-height:1.5;color:#172b4d}.card-admin-settings-card:hover,.card-admin-settings-card:focus{text-decoration:none;background-color:#ebecf0}.card-admin-settings-card h5{font-size:1rem;line-height:1.5}.card-admin-info .card-title{font-size:1.25rem}.card-admin-info .card-body,.card-admin-info .card-admin-form .form-fieldset,.card-admin-form .card-admin-info .form-fieldset{padding:.75rem}.card-admin-stat{font-size:1.25rem}.media-admin-check{font-size:.875rem}.media-admin-check h5{font-size:1rem}.media-check-icon{width:1.5rem;height:1.5rem;font-size:1rem;line-height:1rem;color:#fff;background:#6b778c}.media-check-icon.media-check-icon-warning{background:#ffab00}.media-check-icon.media-check-icon-danger{background:#ff5630}.media-check-icon.media-check-icon-success{background:#36b37e}.media-check-icon .spinner-border{width:1rem;height:1rem}.card-admin-analytics-summary{width:200px}.card-admin-analytics-summary div{font-size:2rem}.card-admin-analytics-summary small{font-size:1rem}.card-admin-analytics-chart{margin-top:-10px}.card-admin-table .card-title{font-size:1.25rem}.card-admin-table th,.card-admin-table td{vertical-align:middle}.card-admin-table th{font-size:.875rem;color:#6b778c;background-color:rgba(0,0,0,0)}.card-admin-table .row-select input,.card-admin-table th input{font-size:1.25rem}.card-admin-table .card-body,.card-admin-table .card-admin-form .form-fieldset,.card-admin-form .card-admin-table .form-fieldset{padding:.75rem}.card-admin-table table+.card-body,.card-admin-table .card-admin-form table+.form-fieldset,.card-admin-form .card-admin-table table+.form-fieldset{border-top:1px solid #dfe1e6}.card-admin-table .btn-thumbnail{width:2rem;height:2rem;padding:0;background-color:transparent;background-size:cover}.card-admin-table .item-name{color:#172b4d}.card-admin-table h5{font-size:.875rem;line-height:1.5}.card-admin-table h5 a{color:#172b4d}.card-admin-table .badges-list{width:0%}.card-admin-table .row-select{width:1px}.card-admin-table [data-timestamp]{text-decoration:none}.card-admin-form .card-header h5{font-size:1.25rem}.card-admin-form .form-fieldset{margin:0 -1.25rem}.login-error{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center}.login-error-container{width:100%;padding:30px}.login-error-card{max-width:540px;margin:0 auto}.login-error-title{font-size:1.25rem;text-align:center}
 
 

BIN
misago/static/misago/img/attachment-403.png


BIN
misago/static/misago/img/attachment-404.png


BIN
misago/static/misago/img/blank-avatar.png


BIN
misago/static/misago/img/error-403.png


BIN
misago/static/misago/img/error-404.png


+ 9 - 0
misago/templates/misago/admin/conf/captcha_settings.html

@@ -30,4 +30,13 @@
 
 
   </fieldset>
   </fieldset>
 </div>
 </div>
+<div class="form-fieldset">
+  <fieldset>
+    <legend>{% trans "Stop Forum Spam" %}</legend>
+
+    {% form_row form.enable_stop_forum_spam %}
+    {% form_row form.stop_forum_spam_confidence %}
+
+  </fieldset>
+</div>
 {% endblock form-body %}
 {% endblock form-body %}

+ 47 - 4
misago/templates/misago/admin/conf/threads_settings.html

@@ -5,19 +5,62 @@
 {% block form-body %}
 {% block form-body %}
 <div class="form-fieldset">
 <div class="form-fieldset">
   <fieldset>
   <fieldset>
-    <legend>{% trans "Threads" %}</legend>
+    <legend>{% trans "Posting" %}</legend>
 
 
     {% form_row form.thread_title_length_min %}
     {% form_row form.thread_title_length_min %}
     {% form_row form.thread_title_length_max %}
     {% form_row form.thread_title_length_max %}
     
     
+    {% form_row form.post_length_min %}
+    {% form_row form.post_length_max %}
+
+    {% form_row form.post_attachments_limit %}
+    {% form_row form.unused_attachments_lifetime %}
+
+    {% form_row form.hourly_post_limit %}
+    {% form_row form.daily_post_limit %}
+    
   </fieldset>
   </fieldset>
 </div>
 </div>
 <div class="form-fieldset">
 <div class="form-fieldset">
   <fieldset>
   <fieldset>
-    <legend>{% trans "Posts" %}</legend>
+    <legend>{% trans "Threads lists" %}</legend>
+    
+    {% form_row form.threads_per_page %}
 
 
-    {% form_row form.post_length_min %}
-    {% form_row form.post_length_max %}
+  </fieldset>
+</div>
+<div class="form-fieldset">
+  <fieldset>
+    <legend>{% trans "Thread pages" %}</legend>
+    
+    {% form_row form.posts_per_page %}
+    {% form_row form.posts_per_page_orphans %}
+    {% form_row form.events_per_page %}
+
+  </fieldset>
+</div>
+<div class="form-fieldset">
+  <fieldset>
+    <legend>{% trans "Read-tracker" %}</legend>
+    
+    {% form_row form.readtracker_cutoff %}
+
+  </fieldset>
+</div>
+<div class="form-fieldset">
+  <fieldset>
+    <legend>{% trans "Attachment error images" %}</legend>
+
+    {% with form.attachment_403_image_delete as delete_field %}
+      {% with form_settings.attachment_403_image as setting %}
+        {% form_image_row form.attachment_403_image delete_field=delete_field size=setting.image_size dimensions=setting.image_dimensions %}
+      {% endwith %}
+    {% endwith %}
+    {% with form.attachment_404_image_delete as delete_field %}
+      {% with form_settings.attachment_404_image as setting %}
+        {% form_image_row form.attachment_404_image delete_field=delete_field size=setting.image_size dimensions=setting.image_dimensions %}
+      {% endwith %}
+    {% endwith %}
 
 
   </fieldset>
   </fieldset>
 </div>
 </div>

+ 53 - 1
misago/templates/misago/admin/conf/users_settings.html

@@ -5,11 +5,20 @@
 {% block form-body %}
 {% block form-body %}
 <div class="form-fieldset">
 <div class="form-fieldset">
   <fieldset>
   <fieldset>
-    <legend>{% trans "Basic settings" %}</legend>
+    <legend>{% trans "New accounts" %}</legend>
 
 
     {% form_row form.account_activation %}
     {% form_row form.account_activation %}
+    {% form_row form.new_inactive_accounts_delete %}
+
+  </fieldset>
+</div>
+<div class="form-fieldset">
+  <fieldset>
+    <legend>{% trans "Usernames" %}</legend>
+
     {% form_row form.username_length_min %}
     {% form_row form.username_length_min %}
     {% form_row form.username_length_max %}
     {% form_row form.username_length_max %}
+    {% form_row form.anonymous_username %}
 
 
   </fieldset>
   </fieldset>
 </div>
 </div>
@@ -39,6 +48,31 @@
 </div>
 </div>
 <div class="form-fieldset">
 <div class="form-fieldset">
   <fieldset>
   <fieldset>
+    <legend>{% trans "Data downloads" %}</legend>
+
+    {% form_row form.allow_data_downloads %}
+    {% form_row form.data_downloads_expiration %}
+
+  </fieldset>
+</div>
+<div class="form-fieldset">
+  <fieldset>
+    <legend>{% trans "Own account deletion" %}</legend>
+
+    {% form_row form.allow_delete_own_account %}
+
+  </fieldset>
+</div>
+<div class="form-fieldset">
+  <fieldset>
+    <legend>{% trans "IP addresses" %}</legend>
+
+    {% form_row form.ip_storage_time %}
+
+  </fieldset>
+</div>
+<div class="form-fieldset">
+  <fieldset>
     <legend>{% trans "Default subscription preferences" %}</legend>
     <legend>{% trans "Default subscription preferences" %}</legend>
 
 
     {% form_row form.subscribe_start %}
     {% form_row form.subscribe_start %}
@@ -46,4 +80,22 @@
 
 
   </fieldset>
   </fieldset>
 </div>
 </div>
+<div class="form-fieldset">
+  <fieldset>
+    <legend>{% trans "Users lists" %}</legend>
+
+    {% form_row form.users_per_page %}
+    {% form_row form.users_per_page_orphans %}
+
+  </fieldset>
+</div>
+<div class="form-fieldset">
+  <fieldset>
+    <legend>{% trans "Top posters ranking" %}</legend>
+
+    {% form_row form.top_posters_ranking_size %}
+    {% form_row form.top_posters_ranking_length %}
+
+  </fieldset>
+</div>
 {% endblock form-body %}
 {% endblock form-body %}

+ 2 - 2
misago/templates/misago/admin/dashboard/checks.html

@@ -46,7 +46,7 @@
             </div>
             </div>
             <div class="media-body">
             <div class="media-body">
               <h5>{% trans "Forum address is not configured." %}</h5>
               <h5>{% trans "Forum address is not configured." %}</h5>
-              {% trans "Misago uses this setting to build links in e-mails sent to site users." %}
+              {% trans "Links in e-mails sent by Misago will be broken." %}
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>
@@ -79,7 +79,7 @@
                 {% endblocktrans %}
                 {% endblocktrans %}
               </div>
               </div>
               <div>
               <div>
-                {% trans "Misago uses this setting to build links in e-mails sent to site users." %}
+                {% trans "Links in e-mails sentby Misagoill be broken." %}
               </div>
               </div>
             </div>
             </div>
           </div>
           </div>

+ 7 - 7
misago/templates/misago/navbar.html

@@ -2,13 +2,13 @@
 <nav class="navbar navbar-misago navbar-default navbar-static-top" role="navigation">
 <nav class="navbar navbar-misago navbar-default navbar-static-top" role="navigation">
 
 
   <div class="container navbar-full navbar-desktop-nav">
   <div class="container navbar-full navbar-desktop-nav">
-    {% if settings.forum_branding_text or settings.forum_branding_logo %}
+    {% if settings.logo_text or settings.logo %}
       <a href="{% url 'misago:index' %}" class="navbar-brand">
       <a href="{% url 'misago:index' %}" class="navbar-brand">
-        {% if settings.forum_branding_logo %}
-          <img src="{{ settings.forum_branding_logo }}" alt="">
+        {% if settings.logo %}
+          <img src="{{ settings.logo }}" alt="">
         {% endif %}
         {% endif %}
-        {% if settings.forum_branding_text %}
-          <span>{{ settings.forum_branding_text}}</span>
+        {% if settings.logo_text %}
+          <span>{{ settings.logo_text}}</span>
         {% endif %}
         {% endif %}
       </a>
       </a>
     {% endif %}
     {% endif %}
@@ -48,10 +48,10 @@
   </div><!-- /full navbar -->
   </div><!-- /full navbar -->
 
 
   <ul class="nav navbar-nav navbar-compact-nav" itemscope itemtype="http://schema.org/SiteNavigationElement">
   <ul class="nav navbar-nav navbar-compact-nav" itemscope itemtype="http://schema.org/SiteNavigationElement">
-    {% if settings.forum_branding_icon %}
+    {% if settings.logo_small %}
       <li>
       <li>
         <a href="{% url 'misago:index' %}" class="brand-link">
         <a href="{% url 'misago:index' %}" class="brand-link">
-          <img src="{{ forum_branding_icon }}" alt="">
+          <img src="{{ settings.logo_small }}" alt="">
         </a>
         </a>
       </li>
       </li>
       {% if THREADS_ON_INDEX %}
       {% if THREADS_ON_INDEX %}

+ 4 - 1
misago/threads/admin/__init__.py

@@ -42,5 +42,8 @@ class MisagoAdminExtension:
         )
         )
 
 
         site.add_node(
         site.add_node(
-            name=_("Attachment types"), parent="settings", namespace="attachment-types"
+            name=_("Attachment types"),
+            description=_("Specify what files may be uploaded on the forum."),
+            parent="settings",
+            namespace="attachment-types",
         )
         )

+ 5 - 4
misago/threads/api/postendpoints/delete.py

@@ -1,6 +1,5 @@
 from rest_framework.response import Response
 from rest_framework.response import Response
 
 
-from ....conf import settings
 from ...moderation import posts as moderation
 from ...moderation import posts as moderation
 from ...permissions import (
 from ...permissions import (
     allow_delete_best_answer,
     allow_delete_best_answer,
@@ -9,8 +8,6 @@ from ...permissions import (
 )
 )
 from ...serializers import DeletePostsSerializer
 from ...serializers import DeletePostsSerializer
 
 
-DELETE_LIMIT = settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL
-
 
 
 def delete_post(request, thread, post):
 def delete_post(request, thread, post):
     if post.is_event:
     if post.is_event:
@@ -28,7 +25,11 @@ def delete_post(request, thread, post):
 def delete_bulk(request, thread):
 def delete_bulk(request, thread):
     serializer = DeletePostsSerializer(
     serializer = DeletePostsSerializer(
         data={"posts": request.data},
         data={"posts": request.data},
-        context={"thread": thread, "user_acl": request.user_acl},
+        context={
+            "settings": request.settings,
+            "thread": thread,
+            "user_acl": request.user_acl,
+        },
     )
     )
 
 
     if not serializer.is_valid():
     if not serializer.is_valid():

+ 6 - 1
misago/threads/api/postendpoints/merge.py

@@ -11,7 +11,12 @@ def posts_merge_endpoint(request, thread):
         raise PermissionDenied(_("You can't merge posts in this thread."))
         raise PermissionDenied(_("You can't merge posts in this thread."))
 
 
     serializer = MergePostsSerializer(
     serializer = MergePostsSerializer(
-        data=request.data, context={"thread": thread, "user_acl": request.user_acl}
+        data=request.data,
+        context={
+            "settings": request.settings,
+            "thread": thread,
+            "user_acl": request.user_acl,
+        },
     )
     )
 
 
     if not serializer.is_valid():
     if not serializer.is_valid():

+ 6 - 1
misago/threads/api/postendpoints/move.py

@@ -11,7 +11,12 @@ def posts_move_endpoint(request, thread, viewmodel):
 
 
     serializer = MovePostsSerializer(
     serializer = MovePostsSerializer(
         data=request.data,
         data=request.data,
-        context={"request": request, "thread": thread, "viewmodel": viewmodel},
+        context={
+            "request": request,
+            "settings": request.settings,
+            "thread": thread,
+            "viewmodel": viewmodel,
+        },
     )
     )
 
 
     if not serializer.is_valid():
     if not serializer.is_valid():

+ 17 - 7
misago/threads/api/postendpoints/patch_post.py

@@ -1,5 +1,5 @@
 from django.core.exceptions import PermissionDenied
 from django.core.exceptions import PermissionDenied
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext as _, ngettext
 from rest_framework import serializers
 from rest_framework import serializers
 from rest_framework.response import Response
 from rest_framework.response import Response
 
 
@@ -17,8 +17,6 @@ from ...permissions import (
     exclude_invisible_posts,
     exclude_invisible_posts,
 )
 )
 
 
-PATCH_LIMIT = settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL
-
 post_patch_dispatcher = ApiPatch()
 post_patch_dispatcher = ApiPatch()
 
 
 
 
@@ -140,7 +138,9 @@ def post_patch_endpoint(request, post):
 
 
 
 
 def bulk_patch_endpoint(request, thread):
 def bulk_patch_endpoint(request, thread):
-    serializer = BulkPatchSerializer(data=request.data)
+    serializer = BulkPatchSerializer(
+        data=request.data, context={"settings": request.settings}
+    )
     if not serializer.is_valid():
     if not serializer.is_valid():
         return Response(serializer.errors, status=400)
         return Response(serializer.errors, status=400)
 
 
@@ -184,10 +184,20 @@ def clean_posts_for_patch(request, thread, posts_ids):
 
 
 class BulkPatchSerializer(serializers.Serializer):
 class BulkPatchSerializer(serializers.Serializer):
     ids = serializers.ListField(
     ids = serializers.ListField(
-        child=serializers.IntegerField(min_value=1),
-        max_length=PATCH_LIMIT,
-        min_length=1,
+        child=serializers.IntegerField(min_value=1), min_length=1
     )
     )
     ops = serializers.ListField(
     ops = serializers.ListField(
         child=serializers.DictField(), min_length=1, max_length=10
         child=serializers.DictField(), min_length=1, max_length=10
     )
     )
+
+    def validate_ids(self, data):
+        settings = self.context["settings"]
+        limit = settings.posts_per_page + settings.posts_per_page_orphans
+        if len(data) > limit:
+            message = ngettext(
+                "No more than %(limit)s post can be updated at a single time.",
+                "No more than %(limit)s posts can be updated at a single time.",
+                limit,
+            )
+            raise serializers.ValidationError(message % {"limit": limit})
+        return data

+ 2 - 2
misago/threads/api/postendpoints/read.py

@@ -5,14 +5,14 @@ from ....readtracker.signals import thread_read
 
 
 
 
 def post_read_endpoint(request, thread, post):
 def post_read_endpoint(request, thread, post):
-    poststracker.make_read_aware(request.user, post)
+    poststracker.make_read_aware(request, post)
     if post.is_new:
     if post.is_new:
         poststracker.save_read(request.user, post)
         poststracker.save_read(request.user, post)
         if thread.subscription and thread.subscription.last_read_on < post.posted_on:
         if thread.subscription and thread.subscription.last_read_on < post.posted_on:
             thread.subscription.last_read_on = post.posted_on
             thread.subscription.last_read_on = post.posted_on
             thread.subscription.save()
             thread.subscription.save()
 
 
-    threadstracker.make_read_aware(request.user, request.user_acl, thread)
+    threadstracker.make_read_aware(request, thread)
 
 
     # send signal if post read marked thread as read
     # send signal if post read marked thread as read
     # used in some places, eg. syncing unread thread count
     # used in some places, eg. syncing unread thread count

+ 17 - 17
misago/threads/api/postingendpoint/attachments.py

@@ -5,7 +5,6 @@ from rest_framework.fields import empty
 
 
 from . import PostingEndpoint, PostingMiddleware
 from . import PostingEndpoint, PostingMiddleware
 from ....acl.objectacl import add_acl_to_obj
 from ....acl.objectacl import add_acl_to_obj
-from ....conf import settings
 from ...serializers import AttachmentSerializer
 from ...serializers import AttachmentSerializer
 
 
 
 
@@ -21,6 +20,7 @@ class AttachmentsMiddleware(PostingMiddleware):
                 "user": self.user,
                 "user": self.user,
                 "user_acl": self.user_acl,
                 "user_acl": self.user_acl,
                 "post": self.post,
                 "post": self.post,
+                "settings": self.settings,
             },
             },
         )
         )
 
 
@@ -42,12 +42,10 @@ class AttachmentsSerializer(serializers.Serializer):
     def validate_attachments(self, ids):
     def validate_attachments(self, ids):
         ids = list(set(ids))
         ids = list(set(ids))
 
 
-        validate_attachments_count(ids)
+        validate_attachments_count(ids, self.context["settings"])
 
 
-        attachments = self.get_initial_attachments(
-            self.context["mode"], self.context["user_acl"], self.context["post"]
-        )
-        new_attachments = self.get_new_attachments(self.context["user"], ids)
+        attachments = self.get_initial_attachments()
+        new_attachments = self.get_new_attachments(ids)
 
 
         if not attachments and not new_attachments:
         if not attachments and not new_attachments:
             return []  # no attachments
             return []  # no attachments
@@ -74,20 +72,22 @@ class AttachmentsSerializer(serializers.Serializer):
             self.final_attachments += new_attachments
             self.final_attachments += new_attachments
             self.final_attachments.sort(key=lambda a: a.pk, reverse=True)
             self.final_attachments.sort(key=lambda a: a.pk, reverse=True)
 
 
-    def get_initial_attachments(self, mode, user_acl, post):
+    def get_initial_attachments(self):
         attachments = []
         attachments = []
-        if mode == PostingEndpoint.EDIT:
-            queryset = post.attachment_set.select_related("filetype")
+        if self.context["mode"] == PostingEndpoint.EDIT:
+            queryset = self.context["post"].attachment_set.select_related("filetype")
             attachments = list(queryset)
             attachments = list(queryset)
-            add_acl_to_obj(user_acl, attachments)
+            add_acl_to_obj(self.context["user_acl"], attachments)
         return attachments
         return attachments
 
 
-    def get_new_attachments(self, user, ids):
+    def get_new_attachments(self, ids):
         if not ids:
         if not ids:
             return []
             return []
 
 
-        queryset = user.attachment_set.select_related("filetype").filter(
-            post__isnull=True, id__in=ids
+        queryset = (
+            self.context["user"]
+            .attachment_set.select_related("filetype")
+            .filter(post__isnull=True, id__in=ids)
         )
         )
 
 
         return list(queryset)
         return list(queryset)
@@ -124,19 +124,19 @@ class AttachmentsSerializer(serializers.Serializer):
         post.update_fields.append("attachments_cache")
         post.update_fields.append("attachments_cache")
 
 
 
 
-def validate_attachments_count(data):
+def validate_attachments_count(data, settings):
     total_attachments = len(data)
     total_attachments = len(data)
-    if total_attachments > settings.MISAGO_POST_ATTACHMENTS_LIMIT:
+    if total_attachments > settings.post_attachments_limit:
         # pylint: disable=line-too-long
         # pylint: disable=line-too-long
         message = ngettext(
         message = ngettext(
             "You can't attach more than %(limit_value)s file to single post (added %(show_value)s).",
             "You can't attach more than %(limit_value)s file to single post (added %(show_value)s).",
             "You can't attach more than %(limit_value)s flies to single post (added %(show_value)s).",
             "You can't attach more than %(limit_value)s flies to single post (added %(show_value)s).",
-            settings.MISAGO_POST_ATTACHMENTS_LIMIT,
+            settings.post_attachments_limit,
         )
         )
         raise serializers.ValidationError(
         raise serializers.ValidationError(
             message
             message
             % {
             % {
-                "limit_value": settings.MISAGO_POST_ATTACHMENTS_LIMIT,
+                "limit_value": settings.post_attachments_limit,
                 "show_value": total_attachments,
                 "show_value": total_attachments,
             }
             }
         )
         )

+ 10 - 9
misago/threads/api/postingendpoint/floodprotection.py

@@ -4,9 +4,8 @@ from django.utils import timezone
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from . import PostingEndpoint, PostingInterrupt, PostingMiddleware
 from . import PostingEndpoint, PostingInterrupt, PostingMiddleware
-from ....conf import settings
 
 
-MIN_POSTING_PAUSE = 3
+MIN_POSTING_INTERVAL = 3
 
 
 
 
 class FloodProtectionMiddleware(PostingMiddleware):
 class FloodProtectionMiddleware(PostingMiddleware):
@@ -21,7 +20,7 @@ class FloodProtectionMiddleware(PostingMiddleware):
 
 
         if self.user.last_posted_on:
         if self.user.last_posted_on:
             previous_post = now - self.user.last_posted_on
             previous_post = now - self.user.last_posted_on
-            if previous_post.total_seconds() < MIN_POSTING_PAUSE:
+            if previous_post.total_seconds() < MIN_POSTING_INTERVAL:
                 raise PostingInterrupt(
                 raise PostingInterrupt(
                     _("You can't post message so quickly after previous one.")
                     _("You can't post message so quickly after previous one.")
                 )
                 )
@@ -29,15 +28,17 @@ class FloodProtectionMiddleware(PostingMiddleware):
         self.user.last_posted_on = timezone.now()
         self.user.last_posted_on = timezone.now()
         self.user.update_fields.append("last_posted_on")
         self.user.update_fields.append("last_posted_on")
 
 
-        if settings.MISAGO_HOURLY_POST_LIMIT:
+        if self.settings.hourly_post_limit:
             cutoff = now - timedelta(hours=24)
             cutoff = now - timedelta(hours=24)
-            if self.is_limit_exceeded(cutoff, settings.MISAGO_HOURLY_POST_LIMIT):
-                raise PostingInterrupt(_("Your account has excceed hourly post limit."))
+            if self.is_limit_exceeded(cutoff, self.settings.hourly_post_limit):
+                raise PostingInterrupt(
+                    _("Your account has exceed an hourly post limit.")
+                )
 
 
-        if settings.MISAGO_DIALY_POST_LIMIT:
+        if self.settings.daily_post_limit:
             cutoff = now - timedelta(hours=1)
             cutoff = now - timedelta(hours=1)
-            if self.is_limit_exceeded(cutoff, settings.MISAGO_DIALY_POST_LIMIT):
-                raise PostingInterrupt(_("Your account has excceed dialy post limit."))
+            if self.is_limit_exceeded(cutoff, self.settings.daily_post_limit):
+                raise PostingInterrupt(_("Your account has exceed a daily post limit."))
 
 
     def is_limit_exceeded(self, cutoff, limit):
     def is_limit_exceeded(self, cutoff, limit):
         return self.user.post_set.filter(posted_on__gte=cutoff).count() >= limit
         return self.user.post_set.filter(posted_on__gte=cutoff).count() >= limit

+ 5 - 1
misago/threads/api/threadendpoints/delete.py

@@ -16,7 +16,11 @@ def delete_thread(request, thread):
 def delete_bulk(request, viewmodel):
 def delete_bulk(request, viewmodel):
     serializer = DeleteThreadsSerializer(
     serializer = DeleteThreadsSerializer(
         data={"threads": request.data},
         data={"threads": request.data},
-        context={"request": request, "viewmodel": viewmodel},
+        context={
+            "request": request,
+            "settings": request.settings,
+            "viewmodel": viewmodel,
+        },
     )
     )
 
 
     if not serializer.is_valid():
     if not serializer.is_valid():

+ 16 - 7
misago/threads/api/threadendpoints/patch.py

@@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model
 from django.core.exceptions import PermissionDenied, ValidationError
 from django.core.exceptions import PermissionDenied, ValidationError
 from django.http import Http404
 from django.http import Http404
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext as _, ngettext
 from rest_framework import serializers
 from rest_framework import serializers
 from rest_framework.response import Response
 from rest_framework.response import Response
 
 
@@ -42,8 +42,6 @@ from ...permissions import (
 from ...serializers import ThreadParticipantSerializer
 from ...serializers import ThreadParticipantSerializer
 from ...validators import validate_thread_title
 from ...validators import validate_thread_title
 
 
-PATCH_LIMIT = settings.MISAGO_THREADS_PER_PAGE
-
 User = get_user_model()
 User = get_user_model()
 
 
 thread_patch_dispatcher = ApiPatch()
 thread_patch_dispatcher = ApiPatch()
@@ -411,7 +409,9 @@ def thread_patch_endpoint(request, thread):
 def bulk_patch_endpoint(
 def bulk_patch_endpoint(
     request, viewmodel
     request, viewmodel
 ):  # pylint: disable=too-many-branches, too-many-locals
 ):  # pylint: disable=too-many-branches, too-many-locals
-    serializer = BulkPatchSerializer(data=request.data)
+    serializer = BulkPatchSerializer(
+        data=request.data, context={"settings": request.settings}
+    )
     if not serializer.is_valid():
     if not serializer.is_valid():
         return Response(serializer.errors, status=400)
         return Response(serializer.errors, status=400)
 
 
@@ -483,10 +483,19 @@ def clean_threads_for_patch(request, viewmodel, threads_ids):
 
 
 class BulkPatchSerializer(serializers.Serializer):
 class BulkPatchSerializer(serializers.Serializer):
     ids = serializers.ListField(
     ids = serializers.ListField(
-        child=serializers.IntegerField(min_value=1),
-        max_length=PATCH_LIMIT,
-        min_length=1,
+        child=serializers.IntegerField(min_value=1), min_length=1
     )
     )
     ops = serializers.ListField(
     ops = serializers.ListField(
         child=serializers.DictField(), min_length=1, max_length=10
         child=serializers.DictField(), min_length=1, max_length=10
     )
     )
+
+    def validate_ids(self, data):
+        limit = self.context["settings"].threads_per_page
+        if len(data) > limit:
+            message = ngettext(
+                "No more than %(limit)s thread can be updated at a single time.",
+                "No more than %(limit)s threads can be updated at a single time.",
+                limit,
+            )
+            raise ValidationError(message % {"limit": limit})
+        return data

+ 5 - 5
misago/threads/management/commands/clearattachments.py

@@ -4,7 +4,7 @@ from datetime import timedelta
 from django.core.management.base import BaseCommand
 from django.core.management.base import BaseCommand
 from django.utils import timezone
 from django.utils import timezone
 
 
-from ....conf import settings
+from ....conf.shortcuts import get_dynamic_settings
 from ....core.management.progressbar import show_progress
 from ....core.management.progressbar import show_progress
 from ....core.pgutils import chunk_queryset
 from ....core.pgutils import chunk_queryset
 from ...models import Attachment
 from ...models import Attachment
@@ -14,15 +14,15 @@ class Command(BaseCommand):
     help = "Deletes attachments unassociated with any posts"
     help = "Deletes attachments unassociated with any posts"
 
 
     def handle(self, *args, **options):
     def handle(self, *args, **options):
-        cutoff = timezone.now() - timedelta(
-            minutes=settings.MISAGO_ATTACHMENT_ORPHANED_EXPIRE
-        )
+        settings = get_dynamic_settings()
+
+        cutoff = timezone.now() - timedelta(hours=settings.unused_attachments_lifetime)
         queryset = Attachment.objects.filter(post__isnull=True, uploaded_on__lt=cutoff)
         queryset = Attachment.objects.filter(post__isnull=True, uploaded_on__lt=cutoff)
 
 
         attachments_to_sync = queryset.count()
         attachments_to_sync = queryset.count()
 
 
         if not attachments_to_sync:
         if not attachments_to_sync:
-            self.stdout.write("\n\nNo attachments were found")
+            self.stdout.write("\n\nNo unused attachments were cleared")
         else:
         else:
             self.sync_attachments(queryset, attachments_to_sync)
             self.sync_attachments(queryset, attachments_to_sync)
 
 

+ 6 - 6
misago/threads/search.py

@@ -11,8 +11,6 @@ from .serializers import FeedSerializer
 from .utils import add_categories_to_items
 from .utils import add_categories_to_items
 from .viewmodels import ThreadsRootCategory
 from .viewmodels import ThreadsRootCategory
 
 
-HITS_CEILING = settings.MISAGO_POSTS_PER_PAGE * 5
-
 
 
 class SearchThreads(SearchProvider):
 class SearchThreads(SearchProvider):
     name = _("Threads")
     name = _("Threads")
@@ -34,8 +32,8 @@ class SearchThreads(SearchProvider):
         list_page = paginate(
         list_page = paginate(
             results,
             results,
             page,
             page,
-            settings.MISAGO_POSTS_PER_PAGE,
-            settings.MISAGO_POSTS_TAIL,
+            self.request.settings.posts_per_page,
+            self.request.settings.posts_per_page_orphans,
             allow_explicit_first_page=True,
             allow_explicit_first_page=True,
         )
         )
         paginator = pagination_dict(list_page)
         paginator = pagination_dict(list_page)
@@ -66,6 +64,8 @@ class SearchThreads(SearchProvider):
 
 
 
 
 def search_threads(request, query, visible_threads):
 def search_threads(request, query, visible_threads):
+    max_hits = request.settings.posts_per_page * 5
+
     search_query = SearchQuery(
     search_query = SearchQuery(
         filter_search(query), config=settings.MISAGO_SEARCH_CONFIG
         filter_search(query), config=settings.MISAGO_SEARCH_CONFIG
     )
     )
@@ -81,8 +81,8 @@ def search_threads(request, query, visible_threads):
         search_vector=search_query,
         search_vector=search_query,
     )
     )
 
 
-    if queryset[: HITS_CEILING + 1].count() > HITS_CEILING:
-        queryset = queryset.order_by("-id")[:HITS_CEILING]
+    if queryset[: max_hits + 1].count() > max_hits:
+        queryset = queryset.order_by("-id")[:max_hits]
 
 
     return (
     return (
         Post.objects.filter(id__in=queryset.values("id"))
         Post.objects.filter(id__in=queryset.values("id"))

+ 40 - 33
misago/threads/serializers/moderation.py

@@ -27,9 +27,6 @@ from ..threadtypes import trees_map
 from ..utils import get_thread_id_from_url
 from ..utils import get_thread_id_from_url
 from ..validators import validate_category, validate_thread_title
 from ..validators import validate_category, validate_thread_title
 
 
-POSTS_LIMIT = settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL
-THREADS_LIMIT = settings.MISAGO_THREADS_PER_PAGE
-
 
 
 __all__ = [
 __all__ = [
     "DeletePostsSerializer",
     "DeletePostsSerializer",
@@ -43,6 +40,10 @@ __all__ = [
 ]
 ]
 
 
 
 
+def get_posts_limit(settings):
+    return settings.posts_per_page + settings.posts_per_page_orphans
+
+
 class DeletePostsSerializer(serializers.Serializer):
 class DeletePostsSerializer(serializers.Serializer):
     error_empty_or_required = gettext_lazy(
     error_empty_or_required = gettext_lazy(
         "You have to specify at least one post to delete."
         "You have to specify at least one post to delete."
@@ -63,13 +64,14 @@ class DeletePostsSerializer(serializers.Serializer):
     )
     )
 
 
     def validate_posts(self, data):
     def validate_posts(self, data):
-        if len(data) > POSTS_LIMIT:
+        limit = get_posts_limit(self.context["settings"])
+        if len(data) > limit:
             message = ngettext(
             message = ngettext(
-                "No more than %(limit)s post can be deleted at single time.",
-                "No more than %(limit)s posts can be deleted at single time.",
-                POSTS_LIMIT,
+                "No more than %(limit)s post can be deleted at a single time.",
+                "No more than %(limit)s posts can be deleted at a single time.",
+                limit,
             )
             )
-            raise ValidationError(message % {"limit": POSTS_LIMIT})
+            raise ValidationError(message % {"limit": limit})
 
 
         user_acl = self.context["user_acl"]
         user_acl = self.context["user_acl"]
         thread = self.context["thread"]
         thread = self.context["thread"]
@@ -116,17 +118,18 @@ class MergePostsSerializer(serializers.Serializer):
     )
     )
 
 
     def validate_posts(self, data):
     def validate_posts(self, data):
+        limit = get_posts_limit(self.context["settings"])
         data = list(set(data))
         data = list(set(data))
 
 
         if len(data) < 2:
         if len(data) < 2:
             raise serializers.ValidationError(self.error_empty_or_required)
             raise serializers.ValidationError(self.error_empty_or_required)
-        if len(data) > POSTS_LIMIT:
+        if len(data) > limit:
             message = ngettext(
             message = ngettext(
-                "No more than %(limit)s post can be merged at single time.",
-                "No more than %(limit)s posts can be merged at single time.",
-                POSTS_LIMIT,
+                "No more than %(limit)s post can be merged at a single time.",
+                "No more than %(limit)s posts can be merged at a single time.",
+                limit,
             )
             )
-            raise serializers.ValidationError(message % {"limit": POSTS_LIMIT})
+            raise serializers.ValidationError(message % {"limit": limit})
 
 
         user_acl = self.context["user_acl"]
         user_acl = self.context["user_acl"]
         thread = self.context["thread"]
         thread = self.context["thread"]
@@ -240,14 +243,15 @@ class MovePostsSerializer(serializers.Serializer):
         return new_thread
         return new_thread
 
 
     def validate_posts(self, data):
     def validate_posts(self, data):
+        limit = get_posts_limit(self.context["settings"])
         data = list(set(data))
         data = list(set(data))
-        if len(data) > POSTS_LIMIT:
+        if len(data) > limit:
             message = ngettext(
             message = ngettext(
-                "No more than %(limit)s post can be moved at single time.",
-                "No more than %(limit)s posts can be moved at single time.",
-                POSTS_LIMIT,
+                "No more than %(limit)s post can be moved at a single time.",
+                "No more than %(limit)s posts can be moved at a single time.",
+                limit,
             )
             )
-            raise serializers.ValidationError(message % {"limit": POSTS_LIMIT})
+            raise serializers.ValidationError(message % {"limit": limit})
 
 
         request = self.context["request"]
         request = self.context["request"]
         thread = self.context["thread"]
         thread = self.context["thread"]
@@ -367,13 +371,14 @@ class SplitPostsSerializer(NewThreadSerializer):
     )
     )
 
 
     def validate_posts(self, data):
     def validate_posts(self, data):
-        if len(data) > POSTS_LIMIT:
+        limit = get_posts_limit(self.context["settings"])
+        if len(data) > limit:
             message = ngettext(
             message = ngettext(
-                "No more than %(limit)s post can be split at single time.",
-                "No more than %(limit)s posts can be split at single time.",
-                POSTS_LIMIT,
+                "No more than %(limit)s post can be split at a single time.",
+                "No more than %(limit)s posts can be split at a single time.",
+                limit,
             )
             )
-            raise ValidationError(message % {"limit": POSTS_LIMIT})
+            raise ValidationError(message % {"limit": limit})
 
 
         thread = self.context["thread"]
         thread = self.context["thread"]
         user_acl = self.context["user_acl"]
         user_acl = self.context["user_acl"]
@@ -421,13 +426,14 @@ class DeleteThreadsSerializer(serializers.Serializer):
     )
     )
 
 
     def validate_threads(self, data):
     def validate_threads(self, data):
-        if len(data) > THREADS_LIMIT:
+        limit = self.context["settings"].threads_per_page
+        if len(data) > limit:
             message = ngettext(
             message = ngettext(
-                "No more than %(limit)s thread can be deleted at single time.",
-                "No more than %(limit)s threads can be deleted at single time.",
-                THREADS_LIMIT,
+                "No more than %(limit)s thread can be deleted at a single time.",
+                "No more than %(limit)s threads can be deleted at a single time.",
+                limit,
             )
             )
-            raise ValidationError(message % {"limit": THREADS_LIMIT})
+            raise ValidationError(message % {"limit": limit})
 
 
         request = self.context["request"]
         request = self.context["request"]
         viewmodel = self.context["viewmodel"]
         viewmodel = self.context["viewmodel"]
@@ -543,13 +549,14 @@ class MergeThreadsSerializer(NewThreadSerializer):
     )
     )
 
 
     def validate_threads(self, data):
     def validate_threads(self, data):
-        if len(data) > THREADS_LIMIT:
+        limit = self.context["settings"].threads_per_page
+        if len(data) > limit:
             message = ngettext(
             message = ngettext(
-                "No more than %(limit)s thread can be merged at single time.",
-                "No more than %(limit)s threads can be merged at single time.",
-                POSTS_LIMIT,
+                "No more than %(limit)s thread can be merged at a single time.",
+                "No more than %(limit)s threads can be merged at a single time.",
+                limit,
             )
             )
-            raise ValidationError(message % {"limit": THREADS_LIMIT})
+            raise ValidationError(message % {"limit": limit})
 
 
         threads_tree_id = trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
         threads_tree_id = trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
 
 

+ 8 - 8
misago/threads/tests/test_anonymize_data.py

@@ -45,7 +45,7 @@ class AnonymizeEventsTests(AuthenticatedUserTestCase):
         make_participants_aware(self.user, self.thread)
         make_participants_aware(self.user, self.thread)
         change_owner(request, self.thread, user)
         change_owner(request, self.thread, user)
 
 
-        user.anonymize_data()
+        user.anonymize_data(anonymous_username="Deleted")
 
 
         event = Post.objects.get(event_type="changed_owner")
         event = Post.objects.get(event_type="changed_owner")
         self.assertEqual(
         self.assertEqual(
@@ -68,7 +68,7 @@ class AnonymizeEventsTests(AuthenticatedUserTestCase):
         make_participants_aware(self.user, self.thread)
         make_participants_aware(self.user, self.thread)
         add_participant(request, self.thread, user)
         add_participant(request, self.thread, user)
 
 
-        user.anonymize_data()
+        user.anonymize_data(anonymous_username="Deleted")
 
 
         event = Post.objects.get(event_type="added_participant")
         event = Post.objects.get(event_type="added_participant")
         self.assertEqual(
         self.assertEqual(
@@ -94,7 +94,7 @@ class AnonymizeEventsTests(AuthenticatedUserTestCase):
         make_participants_aware(user, self.thread)
         make_participants_aware(user, self.thread)
         remove_participant(request, self.thread, user)
         remove_participant(request, self.thread, user)
 
 
-        user.anonymize_data()
+        user.anonymize_data(anonymous_username="Deleted")
 
 
         event = Post.objects.get(event_type="owner_left")
         event = Post.objects.get(event_type="owner_left")
         self.assertEqual(
         self.assertEqual(
@@ -120,7 +120,7 @@ class AnonymizeEventsTests(AuthenticatedUserTestCase):
         make_participants_aware(user, self.thread)
         make_participants_aware(user, self.thread)
         remove_participant(request, self.thread, user)
         remove_participant(request, self.thread, user)
 
 
-        user.anonymize_data()
+        user.anonymize_data(anonymous_username="Deleted")
 
 
         event = Post.objects.get(event_type="removed_owner")
         event = Post.objects.get(event_type="removed_owner")
         self.assertEqual(
         self.assertEqual(
@@ -146,7 +146,7 @@ class AnonymizeEventsTests(AuthenticatedUserTestCase):
         make_participants_aware(user, self.thread)
         make_participants_aware(user, self.thread)
         remove_participant(request, self.thread, user)
         remove_participant(request, self.thread, user)
 
 
-        user.anonymize_data()
+        user.anonymize_data(anonymous_username="Deleted")
 
 
         event = Post.objects.get(event_type="participant_left")
         event = Post.objects.get(event_type="participant_left")
         self.assertEqual(
         self.assertEqual(
@@ -172,7 +172,7 @@ class AnonymizeEventsTests(AuthenticatedUserTestCase):
         make_participants_aware(self.user, self.thread)
         make_participants_aware(self.user, self.thread)
         remove_participant(request, self.thread, user)
         remove_participant(request, self.thread, user)
 
 
-        user.anonymize_data()
+        user.anonymize_data(anonymous_username="Deleted")
 
 
         event = Post.objects.get(event_type="removed_participant")
         event = Post.objects.get(event_type="removed_participant")
         self.assertEqual(
         self.assertEqual(
@@ -211,7 +211,7 @@ class AnonymizeLikesTests(AuthenticatedUserTestCase):
         patch_is_liked(self.get_request(self.user), post, 1)
         patch_is_liked(self.get_request(self.user), post, 1)
         patch_is_liked(self.get_request(user), post, 1)
         patch_is_liked(self.get_request(user), post, 1)
 
 
-        user.anonymize_data()
+        user.anonymize_data(anonymous_username="Deleted")
 
 
         last_likes = Post.objects.get(pk=post.pk).last_likes
         last_likes = Post.objects.get(pk=post.pk).last_likes
         self.assertEqual(
         self.assertEqual(
@@ -242,7 +242,7 @@ class AnonymizePostsTests(AuthenticatedUserTestCase):
 
 
         user = create_test_user("OtherUser", "otheruser@example.com")
         user = create_test_user("OtherUser", "otheruser@example.com")
         post = test.reply_thread(thread, poster=user)
         post = test.reply_thread(thread, poster=user)
-        user.anonymize_data()
+        user.anonymize_data(anonymous_username="Deleted")
 
 
         anonymized_post = Post.objects.get(pk=post.pk)
         anonymized_post = Post.objects.get(pk=post.pk)
         self.assertTrue(anonymized_post.is_valid)
         self.assertTrue(anonymized_post.is_valid)

+ 290 - 298
misago/threads/tests/test_attachments_middleware.py

@@ -1,14 +1,10 @@
 from unittest.mock import Mock
 from unittest.mock import Mock
 
 
+import pytest
 from rest_framework import serializers
 from rest_framework import serializers
 
 
 from .. import test
 from .. import test
-from ...acl import useracl
-from ...acl.test import patch_user_acl
-from ...categories.models import Category
-from ...conf import settings
-from ...conftest import get_cache_versions
-from ...users.test import AuthenticatedUserTestCase
+from ...conf.test import override_dynamic_settings
 from ..api.postingendpoint import PostingEndpoint
 from ..api.postingendpoint import PostingEndpoint
 from ..api.postingendpoint.attachments import (
 from ..api.postingendpoint.attachments import (
     AttachmentsMiddleware,
     AttachmentsMiddleware,
@@ -16,295 +12,291 @@ from ..api.postingendpoint.attachments import (
 )
 )
 from ..models import Attachment, AttachmentType
 from ..models import Attachment, AttachmentType
 
 
-cache_versions = get_cache_versions()
-
-
-def patch_attachments_acl(acl_patch=None):
-    acl_patch = acl_patch or {}
-    acl_patch.setdefault("max_attachment_size", 1024)
-    return patch_user_acl(acl_patch)
-
-
-class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
-    def setUp(self):
-        super().setUp()
-
-        self.category = Category.objects.get(slug="first-category")
-        self.thread = test.post_thread(category=self.category)
-        self.post = self.thread.first_post
-
-        self.post.update_fields = []
-
-        self.filetype = AttachmentType.objects.order_by("id").last()
-
-    def mock_attachment(self, user=True, post=None):
-        return Attachment.objects.create(
-            secret=Attachment.generate_new_secret(),
-            filetype=self.filetype,
-            post=post,
-            size=1000,
-            uploader=self.user if user else None,
-            uploader_name=self.user.username,
-            uploader_slug=self.user.slug,
-            filename="testfile_%s.zip" % (Attachment.objects.count() + 1),
-        )
-
-    def test_use_this_middleware(self):
-        """use_this_middleware returns False if we can't upload attachments"""
-        with patch_user_acl({"max_attachment_size": 0}):
-            user_acl = useracl.get_user_acl(self.user, cache_versions)
-            middleware = AttachmentsMiddleware(user=self.user, user_acl=user_acl)
-            self.assertFalse(middleware.use_this_middleware())
-
-        with patch_user_acl({"max_attachment_size": 1024}):
-            user_acl = useracl.get_user_acl(self.user, cache_versions)
-            middleware = AttachmentsMiddleware(user=self.user, user_acl=user_acl)
-            self.assertTrue(middleware.use_this_middleware())
-
-    @patch_attachments_acl()
-    def test_middleware_is_optional(self):
-        """middleware is optional"""
-        INPUTS = [{}, {"attachments": []}]
-
-        user_acl = useracl.get_user_acl(self.user, cache_versions)
-
-        for test_input in INPUTS:
-            middleware = AttachmentsMiddleware(
-                request=Mock(data=test_input),
-                mode=PostingEndpoint.START,
-                user=self.user,
-                user_acl=user_acl,
-                post=self.post,
-            )
-
-            serializer = middleware.get_serializer()
-            self.assertTrue(serializer.is_valid())
-
-    @patch_attachments_acl()
-    def test_middleware_validates_ids(self):
-        """middleware validates attachments ids"""
-        INPUTS = [
-            "none",
-            ["a", "b", 123],
-            range(settings.MISAGO_POST_ATTACHMENTS_LIMIT + 1),
-        ]
-
-        user_acl = useracl.get_user_acl(self.user, cache_versions)
-
-        for test_input in INPUTS:
-            middleware = AttachmentsMiddleware(
-                request=Mock(data={"attachments": test_input}),
-                mode=PostingEndpoint.START,
-                user=self.user,
-                user_acl=user_acl,
-                post=self.post,
-            )
-
-            serializer = middleware.get_serializer()
-            self.assertFalse(
-                serializer.is_valid(), "%r shouldn't validate" % test_input
-            )
-
-    @patch_attachments_acl()
-    def test_get_initial_attachments(self):
-        """
-        get_initial_attachments returns list of attachments already existing on post
-        """
-        user_acl = useracl.get_user_acl(self.user, cache_versions)
-        middleware = AttachmentsMiddleware(
-            request=Mock(data={}),
-            mode=PostingEndpoint.EDIT,
-            user=self.user,
-            user_acl=user_acl,
-            post=self.post,
-        )
-
-        serializer = middleware.get_serializer()
-
-        attachments = serializer.get_initial_attachments(
-            middleware.mode, middleware.user, middleware.post
-        )
-        self.assertEqual(attachments, [])
-
-        attachment = self.mock_attachment(post=self.post)
-        attachments = serializer.get_initial_attachments(
-            middleware.mode, middleware.user_acl, middleware.post
-        )
-        self.assertEqual(attachments, [attachment])
-
-    @patch_attachments_acl()
-    def test_get_new_attachments(self):
-        """
-        get_initial_attachments returns list of attachments already existing on post
-        """
-        user_acl = useracl.get_user_acl(self.user, cache_versions)
-        middleware = AttachmentsMiddleware(
-            request=Mock(data={}),
-            mode=PostingEndpoint.EDIT,
-            user=self.user,
-            user_acl=user_acl,
-            post=self.post,
-        )
-
-        serializer = middleware.get_serializer()
-
-        attachments = serializer.get_new_attachments(middleware.user, [1, 2, 3])
-        self.assertEqual(attachments, [])
-
-        attachment = self.mock_attachment()
-        attachments = serializer.get_new_attachments(middleware.user, [attachment.pk])
-        self.assertEqual(attachments, [attachment])
-
-        # only own orphaned attachments may be assigned to posts
-        other_user_attachment = self.mock_attachment(user=False)
-        attachments = serializer.get_new_attachments(
-            middleware.user, [other_user_attachment.pk]
-        )
-        self.assertEqual(attachments, [])
-
-    @patch_attachments_acl({"can_delete_other_users_attachments": False})
-    def test_cant_delete_attachment(self):
-        """
-        middleware validates if we have permission to delete other users attachments
-        """
-        attachment = self.mock_attachment(user=False, post=self.post)
-        self.assertIsNone(attachment.uploader)
-
-        user_acl = useracl.get_user_acl(self.user, cache_versions)
-        serializer = AttachmentsMiddleware(
-            request=Mock(data={"attachments": []}),
-            mode=PostingEndpoint.EDIT,
-            user=self.user,
-            user_acl=user_acl,
-            post=self.post,
-        ).get_serializer()
-
-        self.assertFalse(serializer.is_valid())
-
-    @patch_attachments_acl()
-    def test_add_attachments(self):
-        """middleware adds attachments to post"""
-        attachments = [self.mock_attachment(), self.mock_attachment()]
-
-        user_acl = useracl.get_user_acl(self.user, cache_versions)
-        middleware = AttachmentsMiddleware(
-            request=Mock(data={"attachments": [a.pk for a in attachments]}),
-            mode=PostingEndpoint.EDIT,
-            user=self.user,
-            user_acl=user_acl,
-            post=self.post,
-        )
-
-        serializer = middleware.get_serializer()
-        self.assertTrue(serializer.is_valid())
-        middleware.save(serializer)
-
-        # attachments were associated with post
-        self.assertEqual(self.post.update_fields, ["attachments_cache"])
-        self.assertEqual(self.post.attachment_set.count(), 2)
-
-        attachments_filenames = list(reversed([a.filename for a in attachments]))
-        self.assertEqual(
-            [a["filename"] for a in self.post.attachments_cache], attachments_filenames
-        )
-
-    @patch_attachments_acl()
-    def test_remove_attachments(self):
-        """middleware removes attachment from post and db"""
-        attachments = [
-            self.mock_attachment(post=self.post),
-            self.mock_attachment(post=self.post),
-        ]
-
-        user_acl = useracl.get_user_acl(self.user, cache_versions)
-        middleware = AttachmentsMiddleware(
-            request=Mock(data={"attachments": [attachments[0].pk]}),
-            mode=PostingEndpoint.EDIT,
-            user=self.user,
-            user_acl=user_acl,
-            post=self.post,
-        )
-
-        serializer = middleware.get_serializer()
-        self.assertTrue(serializer.is_valid())
-        middleware.save(serializer)
-
-        # attachments were associated with post
-        self.assertEqual(self.post.update_fields, ["attachments_cache"])
-        self.assertEqual(self.post.attachment_set.count(), 1)
-
-        self.assertEqual(Attachment.objects.count(), 1)
-
-        attachments_filenames = [attachments[0].filename]
-        self.assertEqual(
-            [a["filename"] for a in self.post.attachments_cache], attachments_filenames
-        )
-
-    @patch_attachments_acl()
-    def test_steal_attachments(self):
-        """middleware validates if attachments are already assigned to other posts"""
-        other_post = test.reply_thread(self.thread)
-
-        attachments = [self.mock_attachment(post=other_post), self.mock_attachment()]
-
-        user_acl = useracl.get_user_acl(self.user, cache_versions)
-        middleware = AttachmentsMiddleware(
-            request=Mock(data={"attachments": [attachments[0].pk, attachments[1].pk]}),
-            mode=PostingEndpoint.EDIT,
-            user=self.user,
-            user_acl=user_acl,
-            post=self.post,
-        )
-
-        serializer = middleware.get_serializer()
-        self.assertTrue(serializer.is_valid())
-        middleware.save(serializer)
-
-        # only unassociated attachment was associated with post
-        self.assertEqual(self.post.update_fields, ["attachments_cache"])
-        self.assertEqual(self.post.attachment_set.count(), 1)
-
-        self.assertEqual(Attachment.objects.get(pk=attachments[0].pk).post, other_post)
-        self.assertEqual(Attachment.objects.get(pk=attachments[1].pk).post, self.post)
-
-    @patch_attachments_acl()
-    def test_edit_attachments(self):
-        """middleware removes and adds attachments to post"""
-        attachments = [
-            self.mock_attachment(post=self.post),
-            self.mock_attachment(post=self.post),
-            self.mock_attachment(),
-        ]
-
-        user_acl = useracl.get_user_acl(self.user, cache_versions)
-        middleware = AttachmentsMiddleware(
-            request=Mock(data={"attachments": [attachments[0].pk, attachments[2].pk]}),
-            mode=PostingEndpoint.EDIT,
-            user=self.user,
-            user_acl=user_acl,
-            post=self.post,
-        )
-
-        serializer = middleware.get_serializer()
-        self.assertTrue(serializer.is_valid())
-        middleware.save(serializer)
-
-        # attachments were associated with post
-        self.assertEqual(self.post.update_fields, ["attachments_cache"])
-        self.assertEqual(self.post.attachment_set.count(), 2)
-
-        attachments_filenames = [attachments[2].filename, attachments[0].filename]
-        self.assertEqual(
-            [a["filename"] for a in self.post.attachments_cache], attachments_filenames
-        )
-
-
-class ValidateAttachmentsCountTests(AuthenticatedUserTestCase):
-    def test_validate_attachments_count(self):
-        """too large count of attachments is rejected"""
-        validate_attachments_count(range(settings.MISAGO_POST_ATTACHMENTS_LIMIT))
-
-        with self.assertRaises(serializers.ValidationError):
-            validate_attachments_count(
-                range(settings.MISAGO_POST_ATTACHMENTS_LIMIT + 1)
-            )
+
+@pytest.fixture
+def context(default_category, dynamic_settings, user, user_acl):
+    thread = test.post_thread(category=default_category)
+    post = thread.first_post
+    post.update_fields = []
+
+    return {
+        "category": default_category,
+        "thread": thread,
+        "post": post,
+        "settings": dynamic_settings,
+        "user": user,
+        "user_acl": user_acl,
+    }
+
+
+def create_attachment(*, post=None, user=None):
+    return Attachment.objects.create(
+        secret=Attachment.generate_new_secret(),
+        filetype=AttachmentType.objects.order_by("id").last(),
+        post=post,
+        size=1000,
+        uploader=user if user else None,
+        uploader_name=user.username if user else "testuser",
+        uploader_slug=user.slug if user else "testuser",
+        filename="testfile_%s.zip" % (Attachment.objects.count() + 1),
+    )
+
+
+def test_middleware_is_used_if_user_has_permission_to_upload_attachments(context):
+    context["user_acl"]["max_attachment_size"] = 1024
+    middleware = AttachmentsMiddleware(**context)
+    assert middleware.use_this_middleware()
+
+
+def test_middleware_is_not_used_if_user_has_no_permission_to_upload_attachments(
+    context
+):
+    context["user_acl"]["max_attachment_size"] = 0
+    middleware = AttachmentsMiddleware(**context)
+    assert not middleware.use_this_middleware()
+
+
+def test_middleware_handles_no_data(context):
+    middleware = AttachmentsMiddleware(
+        request=Mock(data={}), mode=PostingEndpoint.START, **context
+    )
+
+    serializer = middleware.get_serializer()
+    assert serializer.is_valid()
+
+
+def test_middleware_handles_empty_data(context):
+    middleware = AttachmentsMiddleware(
+        request=Mock(data={"attachments": []}), mode=PostingEndpoint.START, **context
+    )
+
+    serializer = middleware.get_serializer()
+    assert serializer.is_valid()
+
+
+def test_data_validation_fails_if_attachments_data_is_not_iterable(context):
+    middleware = AttachmentsMiddleware(
+        request=Mock(data={"attachments": "none"}),
+        mode=PostingEndpoint.START,
+        **context
+    )
+
+    serializer = middleware.get_serializer()
+    assert not serializer.is_valid()
+
+
+def test_data_validation_fails_if_attachments_data_has_non_int_values(context):
+    middleware = AttachmentsMiddleware(
+        request=Mock(data={"attachments": [1, "b"]}),
+        mode=PostingEndpoint.START,
+        **context
+    )
+
+    serializer = middleware.get_serializer()
+    assert not serializer.is_valid()
+
+
+@override_dynamic_settings(post_attachments_limit=2)
+def test_data_validation_fails_if_attachments_data_is_longer_than_allowed(context):
+    middleware = AttachmentsMiddleware(
+        request=Mock(data={"attachments": range(5)}),
+        mode=PostingEndpoint.START,
+        **context
+    )
+
+    serializer = middleware.get_serializer()
+    assert not serializer.is_valid()
+
+
+def test_middleware_adds_attachment_to_new_post(context):
+    new_attachment = create_attachment(user=context["user"])
+    middleware = AttachmentsMiddleware(
+        request=Mock(data={"attachments": [new_attachment.id]}),
+        mode=PostingEndpoint.START,
+        **context
+    )
+    serializer = middleware.get_serializer()
+    serializer.is_valid()
+    middleware.save(serializer)
+
+    new_attachment.refresh_from_db()
+    assert new_attachment.post == context["post"]
+
+
+def test_middleware_adds_attachment_to_attachments_cache(context):
+    new_attachment = create_attachment(user=context["user"])
+    middleware = AttachmentsMiddleware(
+        request=Mock(data={"attachments": [new_attachment.id]}),
+        mode=PostingEndpoint.START,
+        **context
+    )
+    serializer = middleware.get_serializer()
+    serializer.is_valid()
+    middleware.save(serializer)
+
+    attachments_cache = context["post"].attachments_cache
+    assert len(attachments_cache) == 1
+    assert attachments_cache[0]["id"] == new_attachment.id
+
+
+def test_middleware_adds_attachment_to_existing_post(context):
+    new_attachment = create_attachment(user=context["user"])
+    middleware = AttachmentsMiddleware(
+        request=Mock(data={"attachments": [new_attachment.id]}),
+        mode=PostingEndpoint.EDIT,
+        **context
+    )
+    serializer = middleware.get_serializer()
+    serializer.is_valid()
+    middleware.save(serializer)
+
+    new_attachment.refresh_from_db()
+    assert new_attachment.post == context["post"]
+
+
+def test_middleware_adds_attachment_to_post_with_existing_attachment(context):
+    old_attachment = create_attachment(post=context["post"])
+    new_attachment = create_attachment(user=context["user"])
+    middleware = AttachmentsMiddleware(
+        request=Mock(data={"attachments": [old_attachment.id, new_attachment.id]}),
+        mode=PostingEndpoint.EDIT,
+        **context
+    )
+    serializer = middleware.get_serializer()
+    serializer.is_valid()
+    middleware.save(serializer)
+
+    new_attachment.refresh_from_db()
+    assert new_attachment.post == context["post"]
+
+    old_attachment.refresh_from_db()
+    assert old_attachment.post == context["post"]
+
+
+def test_middleware_adds_attachment_to_existing_attachments_cache(context):
+    old_attachment = create_attachment(post=context["post"])
+    new_attachment = create_attachment(user=context["user"])
+    middleware = AttachmentsMiddleware(
+        request=Mock(data={"attachments": [old_attachment.id, new_attachment.id]}),
+        mode=PostingEndpoint.EDIT,
+        **context
+    )
+    serializer = middleware.get_serializer()
+    serializer.is_valid()
+    middleware.save(serializer)
+
+    attachments_cache = context["post"].attachments_cache
+    assert len(attachments_cache) == 2
+    assert attachments_cache[0]["id"] == new_attachment.id
+    assert attachments_cache[1]["id"] == old_attachment.id
+
+
+def test_other_user_attachment_cant_be_added_to_post(context):
+    attachment = create_attachment()
+    middleware = AttachmentsMiddleware(
+        request=Mock(data={"attachments": [attachment.id]}),
+        mode=PostingEndpoint.EDIT,
+        **context
+    )
+    serializer = middleware.get_serializer()
+    serializer.is_valid()
+    middleware.save(serializer)
+
+    attachment.refresh_from_db()
+    assert not attachment.post
+
+
+def test_other_post_attachment_cant_be_added_to_new_post(context, default_category):
+    post = test.post_thread(category=default_category).first_post
+    attachment = create_attachment(post=post, user=context["user"])
+    middleware = AttachmentsMiddleware(
+        request=Mock(data={"attachments": [attachment.id]}),
+        mode=PostingEndpoint.EDIT,
+        **context
+    )
+    serializer = middleware.get_serializer()
+    serializer.is_valid()
+    middleware.save(serializer)
+
+    attachment.refresh_from_db()
+    assert attachment.post == post
+
+
+def test_middleware_removes_attachment_from_post(context):
+    attachment = create_attachment(post=context["post"], user=context["user"])
+    middleware = AttachmentsMiddleware(
+        request=Mock(data={"attachments": []}), mode=PostingEndpoint.EDIT, **context
+    )
+    serializer = middleware.get_serializer()
+    serializer.is_valid()
+    middleware.save(serializer)
+
+    context["post"].refresh_from_db()
+    assert not context["post"].attachment_set.exists()
+
+
+def test_middleware_removes_attachment_from_attachments_cache(context):
+    attachment = create_attachment(post=context["post"], user=context["user"])
+    middleware = AttachmentsMiddleware(
+        request=Mock(data={"attachments": []}), mode=PostingEndpoint.EDIT, **context
+    )
+    serializer = middleware.get_serializer()
+    serializer.is_valid()
+    middleware.save(serializer)
+
+    assert not context["post"].attachments_cache
+
+
+def test_middleware_deletes_attachment_removed_from_post(context):
+    attachment = create_attachment(post=context["post"], user=context["user"])
+    middleware = AttachmentsMiddleware(
+        request=Mock(data={"attachments": []}), mode=PostingEndpoint.EDIT, **context
+    )
+    serializer = middleware.get_serializer()
+    serializer.is_valid()
+    middleware.save(serializer)
+
+    with pytest.raises(Attachment.DoesNotExist):
+        attachment.refresh_from_db()
+
+
+def test_middleware_blocks_user_from_removing_other_user_attachment_without_permission(
+    context
+):
+    attachment = create_attachment(post=context["post"])
+    middleware = AttachmentsMiddleware(
+        request=Mock(data={"attachments": []}), mode=PostingEndpoint.EDIT, **context
+    )
+    serializer = middleware.get_serializer()
+    assert not serializer.is_valid()
+    middleware.save(serializer)
+
+    attachment.refresh_from_db()
+    assert attachment.post == context["post"]
+
+
+def test_middleware_allows_user_with_permission_to_remove_other_user_attachment(
+    context
+):
+    context["user_acl"]["can_delete_other_users_attachments"] = True
+    attachment = create_attachment(post=context["post"])
+    middleware = AttachmentsMiddleware(
+        request=Mock(data={"attachments": []}), mode=PostingEndpoint.EDIT, **context
+    )
+    serializer = middleware.get_serializer()
+    serializer.is_valid()
+    middleware.save(serializer)
+
+    context["post"].refresh_from_db()
+    assert not context["post"].attachment_set.exists()
+
+
+def test_attachments_count_validator_allows_attachments_within_limit():
+    settings = Mock(post_attachments_limit=5)
+    validate_attachments_count(range(5), settings)
+
+
+def test_attachments_count_validator_raises_validation_error_on_too_many_attachmes():
+    settings = Mock(post_attachments_limit=2)
+    with pytest.raises(serializers.ValidationError):
+        validate_attachments_count(range(5), settings)

+ 273 - 0
misago/threads/tests/test_attachments_proxy.py

@@ -0,0 +1,273 @@
+import pytest
+from django.urls import reverse
+
+from ...acl.models import Role
+from ...acl.test import patch_user_acl
+from ...conf import settings
+from ...conf.test import override_dynamic_settings
+from ..models import Attachment, AttachmentType
+from ..test import post_thread
+
+
+@pytest.fixture
+def attachment_type(db):
+    return AttachmentType.objects.order_by("id").first()
+
+
+@pytest.fixture
+def attachment(attachment_type, post, user):
+    return Attachment.objects.create(
+        secret="secret",
+        filetype=attachment_type,
+        post=post,
+        uploader=user,
+        uploader_name=user.username,
+        uploader_slug=user.slug,
+        filename="test.txt",
+        file="test.txt",
+        size=1000,
+    )
+
+
+@pytest.fixture
+def image(post, user):
+    return Attachment.objects.create(
+        secret="secret",
+        filetype=AttachmentType.objects.get(mimetypes="image/png"),
+        post=post,
+        uploader=user,
+        uploader_name=user.username,
+        uploader_slug=user.slug,
+        filename="test.png",
+        image="test.png",
+        size=1000,
+    )
+
+
+@pytest.fixture
+def image_with_thumbnail(post, user):
+    return Attachment.objects.create(
+        secret="secret",
+        filetype=AttachmentType.objects.get(mimetypes="image/png"),
+        post=post,
+        uploader=user,
+        uploader_name=user.username,
+        uploader_slug=user.slug,
+        filename="test.png",
+        image="test.png",
+        thumbnail="test-thumbnail.png",
+        size=1000,
+    )
+
+
+@pytest.fixture
+def other_users_attachment(attachment, other_user):
+    attachment.uploader = other_user
+    attachment.save()
+    return attachment
+
+
+@pytest.fixture
+def orphaned_attachment(attachment):
+    attachment.post = None
+    attachment.save()
+    return attachment
+
+
+@pytest.fixture
+def other_users_orphaned_attachment(other_users_attachment):
+    other_users_attachment.post = None
+    other_users_attachment.save()
+    return other_users_attachment
+
+
+def assert_403(response):
+    assert response.status_code == 302
+    assert response["location"].endswith(settings.MISAGO_ATTACHMENT_403_IMAGE)
+
+
+def assert_404(response):
+    assert response.status_code == 302
+    assert response["location"].endswith(settings.MISAGO_ATTACHMENT_404_IMAGE)
+
+
+def test_proxy_redirects_client_to_attachment_file(client, attachment):
+    response = client.get(attachment.get_absolute_url())
+    assert response.status_code == 302
+    assert response["location"].endswith("test.txt")
+
+
+def test_proxy_redirects_client_to_attachment_image(client, image):
+    response = client.get(image.get_absolute_url())
+    assert response.status_code == 302
+    assert response["location"].endswith("test.png")
+
+
+def test_proxy_redirects_client_to_attachment_thumbnail(client, image_with_thumbnail):
+    response = client.get(image_with_thumbnail.get_thumbnail_url())
+    assert response.status_code == 302
+    assert response["location"].endswith("test-thumbnail.png")
+
+
+def test_proxy_redirects_to_404_image_for_nonexistant_attachment(db, client):
+    response = client.get(
+        reverse("misago:attachment", kwargs={"pk": 1, "secret": "secret"})
+    )
+    assert_404(response)
+
+
+def test_proxy_redirects_to_404_image_for_url_with_invalid_attachment_secret(
+    client, attachment
+):
+    response = client.get(
+        reverse("misago:attachment", kwargs={"pk": attachment.id, "secret": "invalid"})
+    )
+    assert_404(response)
+
+
+@patch_user_acl({"can_download_other_users_attachments": False})
+def test_proxy_redirects_to_403_image_for_user_without_permission_to_see_attachment(
+    user_client, other_users_attachment
+):
+    response = user_client.get(other_users_attachment.get_absolute_url())
+    assert_403(response)
+
+
+def test_thumbnail_proxy_redirects_to_404_for_non_image_attachment(client, attachment):
+    response = client.get(
+        reverse(
+            "misago:attachment-thumbnail",
+            kwargs={"pk": attachment.pk, "secret": attachment.secret},
+        )
+    )
+    assert_404(response)
+
+
+def test_thumbnail_proxy_redirects_to_regular_image_for_image_without_thumbnail(
+    client, image
+):
+    response = client.get(
+        reverse(
+            "misago:attachment-thumbnail",
+            kwargs={"pk": image.pk, "secret": image.secret},
+        )
+    )
+    assert response.status_code == 302
+    assert response["location"].endswith("test.png")
+
+
+def test_thumbnail_proxy_redirects_to_thumbnail_image(client, image_with_thumbnail):
+    response = client.get(
+        reverse(
+            "misago:attachment-thumbnail",
+            kwargs={
+                "pk": image_with_thumbnail.pk,
+                "secret": image_with_thumbnail.secret,
+            },
+        )
+    )
+    assert response.status_code == 302
+    assert response["location"].endswith("test-thumbnail.png")
+
+
+def test_proxy_blocks_user_from_their_orphaned_attachment(
+    user_client, orphaned_attachment
+):
+    response = user_client.get(orphaned_attachment.get_absolute_url())
+    assert_404(response)
+
+
+def test_proxy_redirects_user_to_their_orphaned_attachment_if_link_has_shva_key(
+    user_client, orphaned_attachment
+):
+    response = user_client.get("%s?shva=1" % orphaned_attachment.get_absolute_url())
+    assert response.status_code == 302
+    assert response["location"].endswith("test.txt")
+
+
+def test_proxy_blocks_user_from_other_users_orphaned_attachment(
+    user_client, other_users_orphaned_attachment
+):
+    response = user_client.get(other_users_orphaned_attachment.get_absolute_url())
+    assert_404(response)
+
+
+def test_proxy_blocks_user_from_other_users_orphaned_attachment_if_link_has_shva_key(
+    user_client, other_users_orphaned_attachment
+):
+    response = user_client.get(
+        "%s?shva=1" % other_users_orphaned_attachment.get_absolute_url()
+    )
+    assert_404(response)
+
+
+def test_proxy_redirects_staff_to_other_users_orphaned_attachment(
+    staff_client, orphaned_attachment
+):
+    response = staff_client.get("%s?shva=1" % orphaned_attachment.get_absolute_url())
+    assert response.status_code == 302
+    assert response["location"].endswith("test.txt")
+
+
+def test_proxy_blocks_user_from_attachment_with_disabled_type(
+    user_client, attachment, attachment_type
+):
+    attachment_type.status = AttachmentType.DISABLED
+    attachment_type.save()
+
+    response = user_client.get(attachment.get_absolute_url())
+    assert_403(response)
+
+
+@pytest.fixture
+def role(db):
+    return Role.objects.create(name="Test")
+
+
+@pytest.fixture
+def limited_attachment_type(attachment_type, role):
+    attachment_type.limit_downloads_to.add(role)
+    return attachment_type
+
+
+def test_proxy_blocks_user_without_role_from_attachment_with_limited_type(
+    user_client, attachment, limited_attachment_type
+):
+    response = user_client.get(attachment.get_absolute_url())
+    assert_403(response)
+
+
+def test_proxy_allows_user_with_role_to_download_attachment_with_limited_type(
+    user, user_client, role, attachment, limited_attachment_type
+):
+    user.roles.add(role)
+    response = user_client.get(attachment.get_absolute_url())
+    assert response.status_code == 302
+    assert response["location"].endswith("test.txt")
+
+
+def test_proxy_allows_staff_user_without_role_to_download_attachment_with_limited_type(
+    staff_client, role, attachment, limited_attachment_type
+):
+    response = staff_client.get(attachment.get_absolute_url())
+    assert response.status_code == 302
+    assert response["location"].endswith("test.txt")
+
+
+@override_dynamic_settings(attachment_403_image="custom-403-image.png")
+@patch_user_acl({"can_download_other_users_attachments": False})
+def test_proxy_uses_custom_permission_denied_image_if_one_is_set(
+    user_client, other_users_attachment
+):
+    response = user_client.get(other_users_attachment.get_absolute_url())
+    assert response.status_code == 302
+    assert response["location"].endswith("custom-403-image.png")
+
+
+@override_dynamic_settings(attachment_404_image="custom-404-image.png")
+def test_proxy_uses_custom_not_found_image_if_one_is_set(db, client):
+    response = client.get(
+        reverse("misago:attachment", kwargs={"pk": 1, "secret": "secret"})
+    )
+    assert response.status_code == 302
+    assert response["location"].endswith("custom-404-image.png")

+ 0 - 238
misago/threads/tests/test_attachmentview.py

@@ -1,238 +0,0 @@
-import os
-
-from django.urls import reverse
-
-from .. import test
-from ...acl.models import Role
-from ...acl.test import patch_user_acl
-from ...categories.models import Category
-from ...conf import settings
-from ...users.test import AuthenticatedUserTestCase
-from ..models import Attachment, AttachmentType
-
-TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "testfiles")
-TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, "document.pdf")
-TEST_SMALLJPG_PATH = os.path.join(TESTFILES_DIR, "small.jpg")
-
-
-def patch_attachments_acl(acl_patch=None):
-    acl_patch = acl_patch or {}
-    acl_patch.setdefault("max_attachment_size", 1024)
-    acl_patch.setdefault("can_download_other_users_attachments", True)
-    return patch_user_acl(acl_patch)
-
-
-class AttachmentViewTestCase(AuthenticatedUserTestCase):
-    def setUp(self):
-        super().setUp()
-
-        AttachmentType.objects.all().delete()
-
-        self.category = Category.objects.get(slug="first-category")
-        self.post = test.post_thread(category=self.category).first_post
-
-        self.api_link = reverse("misago:api:attachment-list")
-
-        self.attachment_type_jpg = AttachmentType.objects.create(
-            name="JPG", extensions="jpeg,jpg"
-        )
-        self.attachment_type_pdf = AttachmentType.objects.create(
-            name="PDF", extensions="pdf"
-        )
-
-    def upload_document(self, is_orphaned=False, by_other_user=False):
-        with open(TEST_DOCUMENT_PATH, "rb") as upload:
-            response = self.client.post(self.api_link, data={"upload": upload})
-        self.assertEqual(response.status_code, 200)
-
-        attachment = Attachment.objects.order_by("id").last()
-
-        if not is_orphaned:
-            attachment.post = self.post
-            attachment.save()
-        if by_other_user:
-            attachment.uploader = None
-            attachment.save()
-
-        return attachment
-
-    def upload_image(self):
-        with open(TEST_SMALLJPG_PATH, "rb") as upload:
-            response = self.client.post(self.api_link, data={"upload": upload})
-        self.assertEqual(response.status_code, 200)
-
-        return Attachment.objects.order_by("id").last()
-
-    @patch_attachments_acl()
-    def assertIs404(self, response):
-        self.assertEqual(response.status_code, 302)
-        self.assertTrue(response["location"].endswith(settings.MISAGO_404_IMAGE))
-
-    @patch_attachments_acl()
-    def assertIs403(self, response):
-        self.assertEqual(response.status_code, 302)
-        self.assertTrue(response["location"].endswith(settings.MISAGO_403_IMAGE))
-
-    @patch_attachments_acl()
-    def assertSuccess(self, response):
-        self.assertEqual(response.status_code, 302)
-        self.assertFalse(response["location"].endswith(settings.MISAGO_404_IMAGE))
-        self.assertFalse(response["location"].endswith(settings.MISAGO_403_IMAGE))
-
-    @patch_attachments_acl()
-    def test_nonexistant_file(self):
-        """user tries to retrieve nonexistant file"""
-        response = self.client.get(
-            reverse("misago:attachment", kwargs={"pk": 123, "secret": "qwertyuiop"})
-        )
-
-        self.assertIs404(response)
-
-    @patch_attachments_acl()
-    def test_invalid_secret(self):
-        """user tries to retrieve existing file using invalid secret"""
-        attachment = self.upload_document()
-
-        response = self.client.get(
-            reverse(
-                "misago:attachment",
-                kwargs={"pk": attachment.pk, "secret": "qwertyuiop"},
-            )
-        )
-
-        self.assertIs404(response)
-
-    @patch_attachments_acl({"can_download_other_users_attachments": False})
-    def test_other_user_file_no_permission(self):
-        """user tries to retrieve other user's file without perm"""
-        attachment = self.upload_document(by_other_user=True)
-
-        response = self.client.get(attachment.get_absolute_url())
-        self.assertIs403(response)
-
-    @patch_attachments_acl({"can_download_other_users_attachments": False})
-    def test_other_user_orphaned_file(self):
-        """user tries to retrieve other user's orphaned file"""
-        attachment = self.upload_document(is_orphaned=True, by_other_user=True)
-
-        response = self.client.get(attachment.get_absolute_url())
-        self.assertIs404(response)
-
-        response = self.client.get(attachment.get_absolute_url() + "?shva=1")
-        self.assertIs404(response)
-
-    @patch_attachments_acl()
-    def test_document_thumbnail(self):
-        """user tries to retrieve thumbnail from non-image attachment"""
-        attachment = self.upload_document()
-
-        response = self.client.get(
-            reverse(
-                "misago:attachment-thumbnail",
-                kwargs={"pk": attachment.pk, "secret": attachment.secret},
-            )
-        )
-        self.assertIs404(response)
-
-    @patch_attachments_acl()
-    def test_no_role(self):
-        """user tries to retrieve attachment without perm to its type"""
-        attachment = self.upload_document()
-
-        user_roles = (r.pk for r in self.user.get_roles())
-        self.attachment_type_pdf.limit_downloads_to.set(
-            Role.objects.exclude(id__in=user_roles)
-        )
-
-        response = self.client.get(attachment.get_absolute_url())
-        self.assertIs403(response)
-
-    @patch_attachments_acl()
-    def test_type_disabled(self):
-        """user tries to retrieve attachment the type disabled downloads"""
-        attachment = self.upload_document()
-
-        self.attachment_type_pdf.status = AttachmentType.DISABLED
-        self.attachment_type_pdf.save()
-
-        response = self.client.get(attachment.get_absolute_url())
-        self.assertIs403(response)
-
-    @patch_attachments_acl()
-    def test_locked_type(self):
-        """user retrieves own locked file"""
-        attachment = self.upload_document()
-
-        self.attachment_type_pdf.status = AttachmentType.LOCKED
-        self.attachment_type_pdf.save()
-
-        response = self.client.get(attachment.get_absolute_url())
-        self.assertSuccess(response)
-
-    @patch_attachments_acl()
-    def test_own_file(self):
-        """user retrieves own file"""
-        attachment = self.upload_document()
-
-        response = self.client.get(attachment.get_absolute_url())
-        self.assertSuccess(response)
-
-    @patch_attachments_acl()
-    def test_other_user_file(self):
-        """user retrieves other user's file with perm"""
-        attachment = self.upload_document(by_other_user=True)
-
-        response = self.client.get(attachment.get_absolute_url())
-        self.assertSuccess(response)
-
-    @patch_attachments_acl()
-    def test_other_user_orphaned_file_is_staff(self):
-        """user retrieves other user's orphaned file because he is staff"""
-        attachment = self.upload_document(is_orphaned=True, by_other_user=True)
-
-        self.user.is_staff = True
-        self.user.save()
-
-        response = self.client.get(attachment.get_absolute_url())
-        self.assertIs404(response)
-
-        response = self.client.get(attachment.get_absolute_url() + "?shva=1")
-        self.assertSuccess(response)
-
-    @patch_attachments_acl()
-    def test_orphaned_file_is_uploader(self):
-        """user retrieves orphaned file because he is its uploader"""
-        attachment = self.upload_document(is_orphaned=True)
-
-        response = self.client.get(attachment.get_absolute_url())
-        self.assertIs404(response)
-
-        response = self.client.get(attachment.get_absolute_url() + "?shva=1")
-        self.assertSuccess(response)
-
-    @patch_attachments_acl()
-    def test_has_role(self):
-        """user retrieves file he has roles to download"""
-        attachment = self.upload_document()
-
-        user_roles = self.user.get_roles()
-        self.attachment_type_pdf.limit_downloads_to.set(user_roles)
-
-        response = self.client.get(attachment.get_absolute_url() + "?shva=1")
-        self.assertSuccess(response)
-
-    @patch_attachments_acl()
-    def test_image(self):
-        """user retrieves """
-        attachment = self.upload_image()
-
-        response = self.client.get(attachment.get_absolute_url() + "?shva=1")
-        self.assertSuccess(response)
-
-    @patch_attachments_acl()
-    def test_image_thumb(self):
-        """user retrieves image's thumbnail"""
-        attachment = self.upload_image()
-
-        response = self.client.get(attachment.get_absolute_url() + "?shva=1")
-        self.assertSuccess(response)

+ 58 - 73
misago/threads/tests/test_clearattachments.py

@@ -1,82 +1,67 @@
 from datetime import timedelta
 from datetime import timedelta
 from io import StringIO
 from io import StringIO
 
 
-from django.core.management import call_command
-from django.test import TestCase
+import pytest
+from django.core import management
 from django.utils import timezone
 from django.utils import timezone
 
 
-from .. import test
-from ...categories.models import Category
-from ...conf import settings
+from ...conf.test import override_dynamic_settings
 from ..management.commands import clearattachments
 from ..management.commands import clearattachments
 from ..models import Attachment, AttachmentType
 from ..models import Attachment, AttachmentType
 
 
 
 
-class ClearAttachmentsTests(TestCase):
-    def test_no_attachments_sync(self):
-        """command works when there are no attachments"""
-        command = clearattachments.Command()
-
-        out = StringIO()
-        call_command(command, stdout=out)
-        command_output = out.getvalue().strip()
-
-        self.assertEqual(command_output, "No attachments were found")
-
-    def test_attachments_sync(self):
-        """command synchronizes attachments"""
-        filetype = AttachmentType.objects.order_by("id").last()
-
-        # create 5 expired orphaned attachments
-        cutoff = timezone.now() - timedelta(
-            minutes=settings.MISAGO_ATTACHMENT_ORPHANED_EXPIRE
-        )
-        cutoff -= timedelta(minutes=5)
-
-        for _ in range(5):
-            Attachment.objects.create(
-                secret=Attachment.generate_new_secret(),
-                filetype=filetype,
-                size=1000,
-                uploaded_on=cutoff,
-                uploader_name="User",
-                uploader_slug="user",
-                filename="testfile_%s.zip" % (Attachment.objects.count() + 1),
-            )
-
-        # create 5 expired non-orphaned attachments
-        category = Category.objects.get(slug="first-category")
-        post = test.post_thread(category).first_post
-
-        for _ in range(5):
-            Attachment.objects.create(
-                secret=Attachment.generate_new_secret(),
-                filetype=filetype,
-                size=1000,
-                uploaded_on=cutoff,
-                post=post,
-                uploader_name="User",
-                uploader_slug="user",
-                filename="testfile_%s.zip" % (Attachment.objects.count() + 1),
-            )
-
-        # create 5 fresh orphaned attachments
-        for _ in range(5):
-            Attachment.objects.create(
-                secret=Attachment.generate_new_secret(),
-                filetype=filetype,
-                size=1000,
-                uploader_name="User",
-                uploader_slug="user",
-                filename="testfile_%s.zip" % (Attachment.objects.count() + 1),
-            )
-
-        command = clearattachments.Command()
-
-        out = StringIO()
-        call_command(command, stdout=out)
-
-        command_output = out.getvalue().splitlines()[-1].strip()
-        self.assertEqual(command_output, "Cleared 5 attachments")
-
-        self.assertEqual(Attachment.objects.count(), 10)
+@pytest.fixture
+def attachment_type(db):
+    return AttachmentType.objects.order_by("id").last()
+
+
+def create_attachment(attachment_type, uploaded_on, post=None):
+    return Attachment.objects.create(
+        secret=Attachment.generate_new_secret(),
+        post=post,
+        filetype=attachment_type,
+        size=1000,
+        uploaded_on=uploaded_on,
+        uploader_name="User",
+        uploader_slug="user",
+        filename="testfile_%s.zip" % (Attachment.objects.count() + 1),
+    )
+
+
+def call_command():
+    command = clearattachments.Command()
+
+    out = StringIO()
+    management.call_command(command, stdout=out)
+    return out.getvalue().strip().splitlines()[-1].strip()
+
+
+def test_command_works_if_there_are_no_attachments(db):
+    command_output = call_command()
+    assert command_output == "No unused attachments were cleared"
+
+
+@override_dynamic_settings(unused_attachments_lifetime=2)
+def test_recent_attachment_is_not_cleared(attachment_type):
+    attachment = create_attachment(attachment_type, timezone.now())
+    command_output = call_command()
+    assert command_output == "No unused attachments were cleared"
+
+
+@override_dynamic_settings(unused_attachments_lifetime=2)
+def test_old_used_attachment_is_not_cleared(attachment_type, post):
+    uploaded_on = timezone.now() - timedelta(hours=3)
+    attachment = create_attachment(attachment_type, uploaded_on, post)
+    command_output = call_command()
+    assert command_output == "No unused attachments were cleared"
+
+
+@override_dynamic_settings(unused_attachments_lifetime=2)
+def test_old_unused_attachment_is_cleared(attachment_type):
+    uploaded_on = timezone.now() - timedelta(hours=3)
+    attachment = create_attachment(attachment_type, uploaded_on)
+    command_output = call_command()
+    assert command_output == "Cleared 1 attachments"
+
+    with pytest.raises(Attachment.DoesNotExist):
+        attachment.refresh_from_db()

+ 10 - 10
misago/threads/tests/test_emailnotification_middleware.py

@@ -128,6 +128,7 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
 
 
         self.assertEqual(len(mail.outbox), 0)
         self.assertEqual(len(mail.outbox), 0)
 
 
+    @override_dynamic_settings(forum_address="http://test.com/")
     @patch_category_acl({"can_reply_threads": True})
     @patch_category_acl({"can_reply_threads": True})
     def test_other_notified(self):
     def test_other_notified(self):
         """email is sent to subscriber"""
         """email is sent to subscriber"""
@@ -138,11 +139,10 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             send_email=True,
             send_email=True,
         )
         )
 
 
-        with override_dynamic_settings(forum_address="http://test.com/"):
-            response = self.client.post(
-                self.api_link, data={"post": "This is test response!"}
-            )
-            self.assertEqual(response.status_code, 200)
+        response = self.client.post(
+            self.api_link, data={"post": "This is test response!"}
+        )
+        self.assertEqual(response.status_code, 200)
 
 
         self.assertEqual(len(mail.outbox), 1)
         self.assertEqual(len(mail.outbox), 1)
         last_email = mail.outbox[-1]
         last_email = mail.outbox[-1]
@@ -159,6 +159,7 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
         last_post = self.thread.post_set.order_by("id").last()
         last_post = self.thread.post_set.order_by("id").last()
         self.assertIn(last_post.get_absolute_url(), message)
         self.assertIn(last_post.get_absolute_url(), message)
 
 
+    @override_dynamic_settings(forum_address="http://test.com/")
     @patch_category_acl({"can_reply_threads": True})
     @patch_category_acl({"can_reply_threads": True})
     def test_other_notified_after_reading(self):
     def test_other_notified_after_reading(self):
         """email is sent to subscriber that had sub updated by read api"""
         """email is sent to subscriber that had sub updated by read api"""
@@ -169,11 +170,10 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             send_email=True,
             send_email=True,
         )
         )
 
 
-        with override_dynamic_settings(forum_address="http://test.com/"):
-            response = self.client.post(
-                self.api_link, data={"post": "This is test response!"}
-            )
-            self.assertEqual(response.status_code, 200)
+        response = self.client.post(
+            self.api_link, data={"post": "This is test response!"}
+        )
+        self.assertEqual(response.status_code, 200)
 
 
         self.assertEqual(len(mail.outbox), 1)
         self.assertEqual(len(mail.outbox), 1)
         last_email = mail.outbox[-1]
         last_email = mail.outbox[-1]

+ 185 - 29
misago/threads/tests/test_floodprotection_middleware.py

@@ -1,49 +1,205 @@
 from datetime import timedelta
 from datetime import timedelta
 
 
+import pytest
 from django.utils import timezone
 from django.utils import timezone
 
 
-from ...users.test import AuthenticatedUserTestCase
-from ..api.postingendpoint import PostingInterrupt
+from ...conf.test import override_dynamic_settings
+from ..api.postingendpoint import PostingEndpoint, PostingInterrupt
 from ..api.postingendpoint.floodprotection import FloodProtectionMiddleware
 from ..api.postingendpoint.floodprotection import FloodProtectionMiddleware
+from ..test import post_thread
 
 
-user_acl = {"can_omit_flood_protection": False}
+default_acl = {"can_omit_flood_protection": False}
+can_omit_flood_acl = {"can_omit_flood_protection": True}
 
 
 
 
-class FloodProtectionMiddlewareTests(AuthenticatedUserTestCase):
-    def test_flood_protection_middleware_on_no_posts(self):
-        """middleware sets last_posted_on on user"""
-        self.user.update_fields = []
-        self.assertIsNone(self.user.last_posted_on)
+def test_middleware_lets_users_first_post_through(dynamic_settings, user):
+    user.update_fields = []
+    middleware = FloodProtectionMiddleware(
+        settings=dynamic_settings, user=user, user_acl=default_acl
+    )
+    middleware.interrupt_posting(None)
 
 
-        middleware = FloodProtectionMiddleware(user=self.user, user_acl=user_acl)
+
+def test_middleware_updates_users_last_post_datetime(dynamic_settings, user):
+    user.update_fields = []
+    middleware = FloodProtectionMiddleware(
+        settings=dynamic_settings, user=user, user_acl=default_acl
+    )
+    middleware.interrupt_posting(None)
+    assert user.last_posted_on
+    assert "last_posted_on" in user.update_fields
+
+
+def test_middleware_interrupts_posting_because_previous_post_was_posted_too_recently(
+    dynamic_settings, user
+):
+    user.last_posted_on = timezone.now()
+    user.update_fields = []
+
+    middleware = FloodProtectionMiddleware(
+        mode=PostingEndpoint.START,
+        settings=dynamic_settings,
+        user=user,
+        user_acl=default_acl,
+    )
+    assert middleware.use_this_middleware()
+
+    with pytest.raises(PostingInterrupt):
         middleware.interrupt_posting(None)
         middleware.interrupt_posting(None)
 
 
-        self.assertIsNotNone(self.user.last_posted_on)
 
 
-    def test_flood_protection_middleware_old_posts(self):
-        """middleware is not interrupting if previous post is old"""
-        self.user.update_fields = []
+def test_middleware_lets_users_next_post_through_if_previous_post_is_not_recent(
+    dynamic_settings, user
+):
+    user.last_posted_on = timezone.now() - timedelta(seconds=10)
+    user.update_fields = []
+
+    middleware = FloodProtectionMiddleware(
+        mode=PostingEndpoint.START,
+        settings=dynamic_settings,
+        user=user,
+        user_acl=default_acl,
+    )
+    assert middleware.use_this_middleware()
+    middleware.interrupt_posting(None)
+
+
+def test_middleware_is_not_used_if_user_has_permission_to_omit_flood_protection(
+    dynamic_settings, user
+):
+    user.last_posted_on = timezone.now()
+    user.update_fields = []
+
+    middleware = FloodProtectionMiddleware(
+        mode=PostingEndpoint.START,
+        settings=dynamic_settings,
+        user=user,
+        user_acl=can_omit_flood_acl,
+    )
+    assert not middleware.use_this_middleware()
 
 
-        original_last_posted_on = timezone.now() - timedelta(days=1)
-        self.user.last_posted_on = original_last_posted_on
 
 
-        middleware = FloodProtectionMiddleware(user=self.user, user_acl=user_acl)
+def test_middleware_is_not_used_if_user_edits_post(dynamic_settings, user):
+    middleware = FloodProtectionMiddleware(
+        mode=PostingEndpoint.EDIT,
+        settings=dynamic_settings,
+        user=user,
+        user_acl=can_omit_flood_acl,
+    )
+    assert not middleware.use_this_middleware()
+
+
+@override_dynamic_settings(hourly_post_limit=3)
+def test_middleware_interrupts_posting_if_hourly_limit_was_met(
+    default_category, dynamic_settings, user
+):
+    user.update_fields = []
+
+    for _ in range(3):
+        post_thread(default_category, poster=user)
+
+    middleware = FloodProtectionMiddleware(
+        mode=PostingEndpoint.START,
+        settings=dynamic_settings,
+        user=user,
+        user_acl=default_acl,
+    )
+    assert middleware.use_this_middleware()
+
+    with pytest.raises(PostingInterrupt):
         middleware.interrupt_posting(None)
         middleware.interrupt_posting(None)
 
 
-        self.assertTrue(self.user.last_posted_on > original_last_posted_on)
 
 
-    def test_flood_protection_middleware_on_flood(self):
-        """middleware is interrupting flood"""
-        self.user.last_posted_on = timezone.now()
+@override_dynamic_settings(hourly_post_limit=0)
+def test_old_posts_dont_count_to_hourly_limit(default_category, dynamic_settings, user):
+    user.update_fields = []
+
+    for _ in range(3):
+        post_thread(
+            default_category,
+            poster=user,
+            started_on=timezone.now() - timedelta(hours=1),
+        )
+
+    middleware = FloodProtectionMiddleware(
+        mode=PostingEndpoint.START,
+        settings=dynamic_settings,
+        user=user,
+        user_acl=default_acl,
+    )
+    middleware.interrupt_posting(None)
 
 
-        with self.assertRaises(PostingInterrupt):
-            middleware = FloodProtectionMiddleware(user=self.user, user_acl=user_acl)
-            middleware.interrupt_posting(None)
 
 
-    def test_flood_permission(self):
-        """middleware is respects permission to flood for team members"""
-        can_omit_flood_protection_user_acl = {"can_omit_flood_protection": True}
-        middleware = FloodProtectionMiddleware(
-            user=self.user, user_acl=can_omit_flood_protection_user_acl
+@override_dynamic_settings(hourly_post_limit=0)
+def test_middleware_lets_post_through_if_hourly_limit_was_disabled(
+    default_category, dynamic_settings, user
+):
+    user.update_fields = []
+
+    for _ in range(3):
+        post_thread(default_category, poster=user)
+
+    middleware = FloodProtectionMiddleware(
+        mode=PostingEndpoint.START,
+        settings=dynamic_settings,
+        user=user,
+        user_acl=default_acl,
+    )
+    middleware.interrupt_posting(None)
+
+
+@override_dynamic_settings(daily_post_limit=3)
+def test_middleware_interrupts_posting_if_daily_limit_was_met(
+    default_category, dynamic_settings, user
+):
+    user.update_fields = []
+
+    for _ in range(3):
+        post_thread(default_category, poster=user)
+
+    middleware = FloodProtectionMiddleware(
+        mode=PostingEndpoint.START,
+        settings=dynamic_settings,
+        user=user,
+        user_acl=default_acl,
+    )
+    assert middleware.use_this_middleware()
+
+    with pytest.raises(PostingInterrupt):
+        middleware.interrupt_posting(None)
+
+
+@override_dynamic_settings(daily_post_limit=0)
+def test_old_posts_dont_count_to_daily_limit(default_category, dynamic_settings, user):
+    user.update_fields = []
+
+    for _ in range(3):
+        post_thread(
+            default_category, poster=user, started_on=timezone.now() - timedelta(days=1)
         )
         )
-        self.assertFalse(middleware.use_this_middleware())
+
+    middleware = FloodProtectionMiddleware(
+        mode=PostingEndpoint.START,
+        settings=dynamic_settings,
+        user=user,
+        user_acl=default_acl,
+    )
+    middleware.interrupt_posting(None)
+
+
+@override_dynamic_settings(daily_post_limit=0)
+def test_middleware_lets_post_through_if_daily_limit_was_disabled(
+    default_category, dynamic_settings, user
+):
+    user.update_fields = []
+
+    for _ in range(3):
+        post_thread(default_category, poster=user)
+
+    middleware = FloodProtectionMiddleware(
+        mode=PostingEndpoint.START,
+        settings=dynamic_settings,
+        user=user,
+        user_acl=default_acl,
+    )
+    middleware.interrupt_posting(None)

+ 53 - 17
misago/threads/tests/test_gotoviews.py

@@ -2,7 +2,7 @@ from django.utils import timezone
 
 
 from .. import test
 from .. import test
 from ...categories.models import Category
 from ...categories.models import Category
-from ...conf import settings
+from ...conf.test import override_dynamic_settings
 from ...readtracker.poststracker import save_read
 from ...readtracker.poststracker import save_read
 from ...users.test import AuthenticatedUserTestCase
 from ...users.test import AuthenticatedUserTestCase
 from ..test import patch_category_acl
 from ..test import patch_category_acl
@@ -10,6 +10,9 @@ from ..test import patch_category_acl
 GOTO_URL = "%s#post-%s"
 GOTO_URL = "%s#post-%s"
 GOTO_PAGE_URL = "%s%s/#post-%s"
 GOTO_PAGE_URL = "%s%s/#post-%s"
 
 
+POSTS_PER_PAGE = 7
+POSTS_PER_PAGE_ORPHANS = 3
+
 
 
 class GotoViewTestCase(AuthenticatedUserTestCase):
 class GotoViewTestCase(AuthenticatedUserTestCase):
     def setUp(self):
     def setUp(self):
@@ -32,9 +35,12 @@ class GotoPostTests(GotoViewTestCase):
         response = self.client.get(response["location"])
         response = self.client.get(response["location"])
         self.assertContains(response, self.thread.first_post.get_absolute_url())
         self.assertContains(response, self.thread.first_post.get_absolute_url())
 
 
+    @override_dynamic_settings(
+        posts_per_page=POSTS_PER_PAGE, posts_per_page_orphans=POSTS_PER_PAGE_ORPHANS
+    )
     def test_goto_last_post_on_page(self):
     def test_goto_last_post_on_page(self):
         """last post on page redirect url is valid"""
         """last post on page redirect url is valid"""
-        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
+        for _ in range(POSTS_PER_PAGE + POSTS_PER_PAGE_ORPHANS - 1):
             post = test.reply_thread(self.thread)
             post = test.reply_thread(self.thread)
 
 
         response = self.client.get(post.get_absolute_url())
         response = self.client.get(post.get_absolute_url())
@@ -46,9 +52,12 @@ class GotoPostTests(GotoViewTestCase):
         response = self.client.get(response["location"])
         response = self.client.get(response["location"])
         self.assertContains(response, post.get_absolute_url())
         self.assertContains(response, post.get_absolute_url())
 
 
+    @override_dynamic_settings(
+        posts_per_page=POSTS_PER_PAGE, posts_per_page_orphans=POSTS_PER_PAGE_ORPHANS
+    )
     def test_goto_first_post_on_next_page(self):
     def test_goto_first_post_on_next_page(self):
         """first post on next page redirect url is valid"""
         """first post on next page redirect url is valid"""
-        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
+        for _ in range(POSTS_PER_PAGE + POSTS_PER_PAGE_ORPHANS):
             post = test.reply_thread(self.thread)
             post = test.reply_thread(self.thread)
 
 
         response = self.client.get(post.get_absolute_url())
         response = self.client.get(post.get_absolute_url())
@@ -61,14 +70,17 @@ class GotoPostTests(GotoViewTestCase):
         response = self.client.get(response["location"])
         response = self.client.get(response["location"])
         self.assertContains(response, post.get_absolute_url())
         self.assertContains(response, post.get_absolute_url())
 
 
+    @override_dynamic_settings(
+        posts_per_page=POSTS_PER_PAGE, posts_per_page_orphans=POSTS_PER_PAGE_ORPHANS
+    )
     def test_goto_first_post_on_page_three_out_of_five(self):
     def test_goto_first_post_on_page_three_out_of_five(self):
         """first post on next page redirect url is valid"""
         """first post on next page redirect url is valid"""
         posts = []
         posts = []
-        for _ in range(settings.MISAGO_POSTS_PER_PAGE * 4 - 1):
+        for _ in range(POSTS_PER_PAGE * 4 - 1):
             post = test.reply_thread(self.thread)
             post = test.reply_thread(self.thread)
             posts.append(post)
             posts.append(post)
 
 
-        post = posts[settings.MISAGO_POSTS_PER_PAGE * 2 - 3]
+        post = posts[POSTS_PER_PAGE * 2 - 3]
 
 
         response = self.client.get(post.get_absolute_url())
         response = self.client.get(post.get_absolute_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(response.status_code, 302)
@@ -80,14 +92,17 @@ class GotoPostTests(GotoViewTestCase):
         response = self.client.get(response["location"])
         response = self.client.get(response["location"])
         self.assertContains(response, post.get_absolute_url())
         self.assertContains(response, post.get_absolute_url())
 
 
+    @override_dynamic_settings(
+        posts_per_page=POSTS_PER_PAGE, posts_per_page_orphans=POSTS_PER_PAGE_ORPHANS
+    )
     def test_goto_first_event_on_page_three_out_of_five(self):
     def test_goto_first_event_on_page_three_out_of_five(self):
         """event redirect url is valid"""
         """event redirect url is valid"""
         posts = []
         posts = []
-        for _ in range(settings.MISAGO_POSTS_PER_PAGE * 4 - 1):
+        for _ in range(POSTS_PER_PAGE * 4 - 1):
             post = test.reply_thread(self.thread)
             post = test.reply_thread(self.thread)
             posts.append(post)
             posts.append(post)
 
 
-        post = posts[settings.MISAGO_POSTS_PER_PAGE * 2 - 2]
+        post = posts[POSTS_PER_PAGE * 2 - 2]
 
 
         self.thread.has_events = True
         self.thread.has_events = True
         self.thread.save()
         self.thread.save()
@@ -119,9 +134,12 @@ class GotoLastTests(GotoViewTestCase):
         response = self.client.get(response["location"])
         response = self.client.get(response["location"])
         self.assertContains(response, self.thread.last_post.get_absolute_url())
         self.assertContains(response, self.thread.last_post.get_absolute_url())
 
 
+    @override_dynamic_settings(
+        posts_per_page=POSTS_PER_PAGE, posts_per_page_orphans=POSTS_PER_PAGE_ORPHANS
+    )
     def test_goto_last_post_on_page(self):
     def test_goto_last_post_on_page(self):
         """last post on page redirect url is valid"""
         """last post on page redirect url is valid"""
-        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
+        for _ in range(POSTS_PER_PAGE + POSTS_PER_PAGE_ORPHANS - 1):
             post = test.reply_thread(self.thread)
             post = test.reply_thread(self.thread)
 
 
         response = self.client.get(self.thread.get_last_post_url())
         response = self.client.get(self.thread.get_last_post_url())
@@ -144,12 +162,15 @@ class GotoNewTests(GotoViewTestCase):
             GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id),
             GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id),
         )
         )
 
 
+    @override_dynamic_settings(
+        posts_per_page=POSTS_PER_PAGE, posts_per_page_orphans=POSTS_PER_PAGE_ORPHANS
+    )
     def test_goto_first_new_post(self):
     def test_goto_first_new_post(self):
         """first unread post redirect url in already read thread is valid"""
         """first unread post redirect url in already read thread is valid"""
         save_read(self.user, self.thread.first_post)
         save_read(self.user, self.thread.first_post)
 
 
         post = test.reply_thread(self.thread, posted_on=timezone.now())
         post = test.reply_thread(self.thread, posted_on=timezone.now())
-        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
+        for _ in range(POSTS_PER_PAGE + POSTS_PER_PAGE_ORPHANS - 1):
             test.reply_thread(self.thread, posted_on=timezone.now())
             test.reply_thread(self.thread, posted_on=timezone.now())
 
 
         response = self.client.get(self.thread.get_new_post_url())
         response = self.client.get(self.thread.get_new_post_url())
@@ -158,16 +179,19 @@ class GotoNewTests(GotoViewTestCase):
             response["location"], GOTO_URL % (self.thread.get_absolute_url(), post.pk)
             response["location"], GOTO_URL % (self.thread.get_absolute_url(), post.pk)
         )
         )
 
 
+    @override_dynamic_settings(
+        posts_per_page=POSTS_PER_PAGE, posts_per_page_orphans=POSTS_PER_PAGE_ORPHANS
+    )
     def test_goto_first_new_post_on_next_page(self):
     def test_goto_first_new_post_on_next_page(self):
         """first unread post redirect url in already read multipage thread is valid"""
         """first unread post redirect url in already read multipage thread is valid"""
         save_read(self.user, self.thread.first_post)
         save_read(self.user, self.thread.first_post)
 
 
-        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
+        for _ in range(POSTS_PER_PAGE + POSTS_PER_PAGE_ORPHANS):
             last_post = test.reply_thread(self.thread, posted_on=timezone.now())
             last_post = test.reply_thread(self.thread, posted_on=timezone.now())
             save_read(self.user, last_post)
             save_read(self.user, last_post)
 
 
         post = test.reply_thread(self.thread, posted_on=timezone.now())
         post = test.reply_thread(self.thread, posted_on=timezone.now())
-        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
+        for _ in range(POSTS_PER_PAGE + POSTS_PER_PAGE_ORPHANS - 1):
             test.reply_thread(self.thread, posted_on=timezone.now())
             test.reply_thread(self.thread, posted_on=timezone.now())
 
 
         response = self.client.get(self.thread.get_new_post_url())
         response = self.client.get(self.thread.get_new_post_url())
@@ -177,11 +201,14 @@ class GotoNewTests(GotoViewTestCase):
             GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk),
             GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk),
         )
         )
 
 
+    @override_dynamic_settings(
+        posts_per_page=POSTS_PER_PAGE, posts_per_page_orphans=POSTS_PER_PAGE_ORPHANS
+    )
     def test_goto_first_new_post_in_read_thread(self):
     def test_goto_first_new_post_in_read_thread(self):
         """goto new in read thread points to last post"""
         """goto new in read thread points to last post"""
         save_read(self.user, self.thread.first_post)
         save_read(self.user, self.thread.first_post)
 
 
-        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
+        for _ in range(POSTS_PER_PAGE + POSTS_PER_PAGE_ORPHANS):
             post = test.reply_thread(self.thread, posted_on=timezone.now())
             post = test.reply_thread(self.thread, posted_on=timezone.now())
             save_read(self.user, post)
             save_read(self.user, post)
 
 
@@ -192,9 +219,12 @@ class GotoNewTests(GotoViewTestCase):
             GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk),
             GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk),
         )
         )
 
 
+    @override_dynamic_settings(
+        posts_per_page=POSTS_PER_PAGE, posts_per_page_orphans=POSTS_PER_PAGE_ORPHANS
+    )
     def test_guest_goto_first_new_post_in_thread(self):
     def test_guest_goto_first_new_post_in_thread(self):
         """guest goto new in read thread points to last post"""
         """guest goto new in read thread points to last post"""
-        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
+        for _ in range(POSTS_PER_PAGE + POSTS_PER_PAGE_ORPHANS):
             post = test.reply_thread(self.thread, posted_on=timezone.now())
             post = test.reply_thread(self.thread, posted_on=timezone.now())
 
 
         self.logout_user()
         self.logout_user()
@@ -217,16 +247,19 @@ class GotoBestAnswerTests(GotoViewTestCase):
             GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id),
             GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id),
         )
         )
 
 
+    @override_dynamic_settings(
+        posts_per_page=POSTS_PER_PAGE, posts_per_page_orphans=POSTS_PER_PAGE_ORPHANS
+    )
     def test_view_handles_best_answer(self):
     def test_view_handles_best_answer(self):
         """if thread has best answer, redirect to it"""
         """if thread has best answer, redirect to it"""
-        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
+        for _ in range(POSTS_PER_PAGE + POSTS_PER_PAGE_ORPHANS):
             test.reply_thread(self.thread, posted_on=timezone.now())
             test.reply_thread(self.thread, posted_on=timezone.now())
 
 
         best_answer = test.reply_thread(self.thread, posted_on=timezone.now())
         best_answer = test.reply_thread(self.thread, posted_on=timezone.now())
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
         self.thread.save()
 
 
-        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
+        for _ in range(POSTS_PER_PAGE + POSTS_PER_PAGE_ORPHANS - 1):
             test.reply_thread(self.thread, posted_on=timezone.now())
             test.reply_thread(self.thread, posted_on=timezone.now())
 
 
         response = self.client.get(self.thread.get_best_answer_url())
         response = self.client.get(self.thread.get_best_answer_url())
@@ -259,16 +292,19 @@ class GotoUnapprovedTests(GotoViewTestCase):
             GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id),
             GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id),
         )
         )
 
 
+    @override_dynamic_settings(
+        posts_per_page=POSTS_PER_PAGE, posts_per_page_orphans=POSTS_PER_PAGE_ORPHANS
+    )
     @patch_category_acl({"can_approve_content": True})
     @patch_category_acl({"can_approve_content": True})
     def test_view_handles_unapproved_posts(self):
     def test_view_handles_unapproved_posts(self):
         """if thread has unapproved posts, redirect to first of them"""
         """if thread has unapproved posts, redirect to first of them"""
-        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
+        for _ in range(POSTS_PER_PAGE + POSTS_PER_PAGE_ORPHANS):
             test.reply_thread(self.thread, posted_on=timezone.now())
             test.reply_thread(self.thread, posted_on=timezone.now())
 
 
         post = test.reply_thread(
         post = test.reply_thread(
             self.thread, is_unapproved=True, posted_on=timezone.now()
             self.thread, is_unapproved=True, posted_on=timezone.now()
         )
         )
-        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
+        for _ in range(POSTS_PER_PAGE + POSTS_PER_PAGE_ORPHANS - 1):
             test.reply_thread(self.thread, posted_on=timezone.now())
             test.reply_thread(self.thread, posted_on=timezone.now())
 
 
         response = self.client.get(self.thread.get_unapproved_post_url())
         response = self.client.get(self.thread.get_unapproved_post_url())

+ 10 - 10
misago/threads/tests/test_privatethread_start_api.py

@@ -308,18 +308,18 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
                 {"title": ["Thread title should contain alpha-numeric characters."]},
                 {"title": ["Thread title should contain alpha-numeric characters."]},
             )
             )
 
 
+    @override_dynamic_settings(forum_address="http://test.com/")
     def test_can_start_thread(self):
     def test_can_start_thread(self):
         """endpoint creates new thread"""
         """endpoint creates new thread"""
-        with override_dynamic_settings(forum_address="http://test.com/"):
-            response = self.client.post(
-                self.api_link,
-                data={
-                    "to": [self.other_user.username],
-                    "title": "Hello, I am test thread!",
-                    "post": "Lorem ipsum dolor met!",
-                },
-            )
-            self.assertEqual(response.status_code, 200)
+        response = self.client.post(
+            self.api_link,
+            data={
+                "to": [self.other_user.username],
+                "title": "Hello, I am test thread!",
+                "post": "Lorem ipsum dolor met!",
+            },
+        )
+        self.assertEqual(response.status_code, 200)
 
 
         thread = self.user.thread_set.all()[:1][0]
         thread = self.user.thread_set.all()[:1][0]
 
 

+ 4 - 2
misago/threads/tests/test_thread_bulkpatch_api.py

@@ -4,6 +4,7 @@ from django.urls import reverse
 
 
 from .. import test
 from .. import test
 from ...categories.models import Category
 from ...categories.models import Category
+from ...conf.test import override_dynamic_settings
 from ..models import Thread
 from ..models import Thread
 from ..test import patch_category_acl, patch_other_category_acl
 from ..test import patch_category_acl, patch_other_category_acl
 from .test_threads_api import ThreadsApiTestCase
 from .test_threads_api import ThreadsApiTestCase
@@ -94,18 +95,19 @@ class BulkPatchSerializerTests(ThreadsBulkPatchApiTestCase):
             {"ids": {"0": ["Ensure this value is greater than or equal to 1."]}},
             {"ids": {"0": ["Ensure this value is greater than or equal to 1."]}},
         )
         )
 
 
+    @override_dynamic_settings(threads_per_page=5)
     def test_too_large_input(self):
     def test_too_large_input(self):
         """api rejects too large input"""
         """api rejects too large input"""
         response = self.patch(
         response = self.patch(
             self.api_link,
             self.api_link,
-            {"ids": [i + 1 for i in range(200)], "ops": [{} for i in range(200)]},
+            {"ids": [i + 1 for i in range(6)], "ops": [{} for i in range(200)]},
         )
         )
 
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
         self.assertEqual(
             response.json(),
             response.json(),
             {
             {
-                "ids": ["Ensure this field has no more than 25 elements."],
+                "ids": ["No more than 5 threads can be updated at a single time."],
                 "ops": ["Ensure this field has no more than 10 elements."],
                 "ops": ["Ensure this field has no more than 10 elements."],
             },
             },
         )
         )

+ 2 - 2
misago/threads/tests/test_thread_model.py

@@ -401,9 +401,9 @@ class ThreadModelTests(TestCase):
         ThreadParticipant.objects.add_participants(self.thread, [user, other_user])
         ThreadParticipant.objects.add_participants(self.thread, [user, other_user])
         self.assertEqual(self.thread.participants.count(), 2)
         self.assertEqual(self.thread.participants.count(), 2)
 
 
-        user.delete()
+        user.delete(anonymous_username="Deleted")
         Thread.objects.get(id=self.thread.id)
         Thread.objects.get(id=self.thread.id)
 
 
-        other_user.delete()
+        other_user.delete(anonymous_username="Deleted")
         with self.assertRaises(Thread.DoesNotExist):
         with self.assertRaises(Thread.DoesNotExist):
             Thread.objects.get(id=self.thread.id)
             Thread.objects.get(id=self.thread.id)

+ 1 - 1
misago/threads/tests/test_thread_postbulkdelete_api.py

@@ -93,7 +93,7 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
         self.assertEqual(
             response.json(),
             response.json(),
-            {"detail": "No more than 24 posts can be deleted at single time."},
+            {"detail": "No more than 24 posts can be deleted at a single time."},
         )
         )
 
 
     @patch_category_acl({"can_hide_posts": 2})
     @patch_category_acl({"can_hide_posts": 2})

+ 4 - 2
misago/threads/tests/test_thread_postbulkpatch_api.py

@@ -4,6 +4,7 @@ from django.urls import reverse
 
 
 from .. import test
 from .. import test
 from ...categories.models import Category
 from ...categories.models import Category
+from ...conf.test import override_dynamic_settings
 from ...users.test import AuthenticatedUserTestCase
 from ...users.test import AuthenticatedUserTestCase
 from ..models import Post, Thread
 from ..models import Post, Thread
 from ..test import patch_category_acl
 from ..test import patch_category_acl
@@ -94,18 +95,19 @@ class BulkPatchSerializerTests(ThreadPostBulkPatchApiTestCase):
             {"ids": {"0": ["Ensure this value is greater than or equal to 1."]}},
             {"ids": {"0": ["Ensure this value is greater than or equal to 1."]}},
         )
         )
 
 
+    @override_dynamic_settings(posts_per_page=4, posts_per_page_orphans=3)
     def test_too_large_input(self):
     def test_too_large_input(self):
         """api rejects too large input"""
         """api rejects too large input"""
         response = self.patch(
         response = self.patch(
             self.api_link,
             self.api_link,
-            {"ids": [i + 1 for i in range(200)], "ops": [{} for i in range(200)]},
+            {"ids": [i + 1 for i in range(8)], "ops": [{} for i in range(200)]},
         )
         )
 
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
         self.assertEqual(
             response.json(),
             response.json(),
             {
             {
-                "ids": ["Ensure this field has no more than 24 elements."],
+                "ids": ["No more than 7 posts can be updated at a single time."],
                 "ops": ["Ensure this field has no more than 10 elements."],
                 "ops": ["Ensure this field has no more than 10 elements."],
             },
             },
         )
         )

+ 4 - 6
misago/threads/tests/test_thread_postmerge_api.py

@@ -4,10 +4,10 @@ from django.urls import reverse
 
 
 from .. import test
 from .. import test
 from ...categories.models import Category
 from ...categories.models import Category
+from ...conf.test import override_dynamic_settings
 from ...readtracker import poststracker
 from ...readtracker import poststracker
 from ...users.test import AuthenticatedUserTestCase
 from ...users.test import AuthenticatedUserTestCase
 from ..models import Post
 from ..models import Post
-from ..serializers.moderation import POSTS_LIMIT
 from ..test import patch_category_acl
 from ..test import patch_category_acl
 
 
 
 
@@ -157,21 +157,19 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             {"detail": "You have to select at least two posts to merge."},
             {"detail": "You have to select at least two posts to merge."},
         )
         )
 
 
+    @override_dynamic_settings(posts_per_page=5, posts_per_page_orphans=3)
     @patch_category_acl({"can_merge_posts": True})
     @patch_category_acl({"can_merge_posts": True})
     def test_merge_limit(self):
     def test_merge_limit(self):
         """api rejects more posts than merge limit"""
         """api rejects more posts than merge limit"""
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
-            json.dumps({"posts": list(range(POSTS_LIMIT + 1))}),
+            json.dumps({"posts": list(range(9))}),
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
         self.assertEqual(
             response.json(),
             response.json(),
-            {
-                "detail": "No more than %s posts can be merged at single time."
-                % POSTS_LIMIT
-            },
+            {"detail": "No more than 8 posts can be merged at a single time."},
         )
         )
 
 
     @patch_category_acl({"can_merge_posts": True})
     @patch_category_acl({"can_merge_posts": True})

+ 4 - 9
misago/threads/tests/test_thread_postmove_api.py

@@ -4,10 +4,10 @@ from django.urls import reverse
 
 
 from .. import test
 from .. import test
 from ...categories.models import Category
 from ...categories.models import Category
+from ...conf.test import override_dynamic_settings
 from ...readtracker import poststracker
 from ...readtracker import poststracker
 from ...users.test import AuthenticatedUserTestCase
 from ...users.test import AuthenticatedUserTestCase
 from ..models import Thread
 from ..models import Thread
-from ..serializers.moderation import POSTS_LIMIT
 from ..test import patch_category_acl, patch_other_category_acl
 from ..test import patch_category_acl, patch_other_category_acl
 
 
 
 
@@ -266,6 +266,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             response.json(), {"detail": "One or more post ids received were invalid."}
             response.json(), {"detail": "One or more post ids received were invalid."}
         )
         )
 
 
+    @override_dynamic_settings(posts_per_page=5, posts_per_page_orphans=3)
     @patch_category_acl({"can_move_posts": True})
     @patch_category_acl({"can_move_posts": True})
     def test_move_limit(self):
     def test_move_limit(self):
         """api rejects more posts than move limit"""
         """api rejects more posts than move limit"""
@@ -274,20 +275,14 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps(
             json.dumps(
-                {
-                    "new_thread": other_thread.get_absolute_url(),
-                    "posts": list(range(POSTS_LIMIT + 1)),
-                }
+                {"new_thread": other_thread.get_absolute_url(), "posts": list(range(9))}
             ),
             ),
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
         self.assertEqual(
             response.json(),
             response.json(),
-            {
-                "detail": "No more than %s posts can be moved at single time."
-                % POSTS_LIMIT
-            },
+            {"detail": "No more than 8 posts can be moved at a single time."},
         )
         )
 
 
     @patch_category_acl({"can_move_posts": True})
     @patch_category_acl({"can_move_posts": True})

+ 4 - 6
misago/threads/tests/test_thread_postsplit_api.py

@@ -4,10 +4,10 @@ from django.urls import reverse
 
 
 from .. import test
 from .. import test
 from ...categories.models import Category
 from ...categories.models import Category
+from ...conf.test import override_dynamic_settings
 from ...readtracker import poststracker
 from ...readtracker import poststracker
 from ...users.test import AuthenticatedUserTestCase
 from ...users.test import AuthenticatedUserTestCase
 from ..models import Post
 from ..models import Post
-from ..serializers.moderation import POSTS_LIMIT
 from ..test import patch_category_acl, patch_other_category_acl
 from ..test import patch_category_acl, patch_other_category_acl
 
 
 
 
@@ -157,21 +157,19 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             response.json(), {"detail": "One or more post ids received were invalid."}
             response.json(), {"detail": "One or more post ids received were invalid."}
         )
         )
 
 
+    @override_dynamic_settings(posts_per_page=5, posts_per_page_orphans=3)
     @patch_category_acl({"can_move_posts": True})
     @patch_category_acl({"can_move_posts": True})
     def test_split_limit(self):
     def test_split_limit(self):
         """api rejects more posts than split limit"""
         """api rejects more posts than split limit"""
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
-            json.dumps({"posts": list(range(POSTS_LIMIT + 1))}),
+            json.dumps({"posts": list(range(9))}),
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
         self.assertEqual(
             response.json(),
             response.json(),
-            {
-                "detail": "No more than %s posts can be split at single time."
-                % POSTS_LIMIT
-            },
+            {"detail": "No more than 8 posts can be split at a single time."},
         )
         )
 
 
     @patch_category_acl({"can_move_posts": True})
     @patch_category_acl({"can_move_posts": True})

+ 4 - 6
misago/threads/tests/test_threads_bulkdelete_api.py

@@ -5,8 +5,8 @@ from django.urls import reverse
 from .. import test
 from .. import test
 from ...categories import PRIVATE_THREADS_ROOT_NAME
 from ...categories import PRIVATE_THREADS_ROOT_NAME
 from ...categories.models import Category
 from ...categories.models import Category
+from ...conf.test import override_dynamic_settings
 from ..models import Thread
 from ..models import Thread
-from ..serializers.moderation import THREADS_LIMIT
 from ..test import patch_category_acl
 from ..test import patch_category_acl
 from ..threadtypes import trees_map
 from ..threadtypes import trees_map
 from .test_threads_api import ThreadsApiTestCase
 from .test_threads_api import ThreadsApiTestCase
@@ -69,17 +69,15 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
             response.json(), {"detail": "One or more thread ids received were invalid."}
             response.json(), {"detail": "One or more thread ids received were invalid."}
         )
         )
 
 
+    @override_dynamic_settings(threads_per_page=4)
     @patch_category_acl({"can_hide_threads": 2, "can_hide_own_threads": 2})
     @patch_category_acl({"can_hide_threads": 2, "can_hide_own_threads": 2})
     def test_validate_ids_length(self):
     def test_validate_ids_length(self):
         """api validates that ids are list of ints"""
         """api validates that ids are list of ints"""
-        response = self.delete(self.api_link, list(range(THREADS_LIMIT + 1)))
+        response = self.delete(self.api_link, list(range(5)))
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(
         self.assertEqual(
             response.json(),
             response.json(),
-            {
-                "detail": "No more than %s threads can be deleted at single time."
-                % THREADS_LIMIT
-            },
+            {"detail": "No more than 4 threads can be deleted at a single time."},
         )
         )
 
 
     @patch_category_acl({"can_hide_threads": 2, "can_hide_own_threads": 2})
     @patch_category_acl({"can_hide_threads": 2, "can_hide_own_threads": 2})

+ 4 - 6
misago/threads/tests/test_threads_merge_api.py

@@ -6,11 +6,11 @@ from .. import test
 from ...acl import useracl
 from ...acl import useracl
 from ...acl.objectacl import add_acl_to_obj
 from ...acl.objectacl import add_acl_to_obj
 from ...categories.models import Category
 from ...categories.models import Category
+from ...conf.test import override_dynamic_settings
 from ...conftest import get_cache_versions
 from ...conftest import get_cache_versions
 from ...readtracker import poststracker
 from ...readtracker import poststracker
 from ..models import Poll, PollVote, Post, Thread
 from ..models import Poll, PollVote, Post, Thread
 from ..serializers import ThreadsListSerializer
 from ..serializers import ThreadsListSerializer
-from ..serializers.moderation import THREADS_LIMIT
 from ..test import patch_category_acl, patch_other_category_acl
 from ..test import patch_category_acl, patch_other_category_acl
 from .test_threads_api import ThreadsApiTestCase
 from .test_threads_api import ThreadsApiTestCase
 
 
@@ -218,11 +218,12 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             ],
             ],
         )
         )
 
 
+    @override_dynamic_settings(threads_per_page=4)
     @patch_category_acl({"can_merge_threads": True})
     @patch_category_acl({"can_merge_threads": True})
     def test_merge_too_many_threads(self):
     def test_merge_too_many_threads(self):
         """api rejects too many threads to merge"""
         """api rejects too many threads to merge"""
         threads = []
         threads = []
-        for _ in range(THREADS_LIMIT + 1):
+        for _ in range(5):
             threads.append(test.post_thread(category=self.category).pk)
             threads.append(test.post_thread(category=self.category).pk)
 
 
         response = self.client.post(
         response = self.client.post(
@@ -233,10 +234,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(
         self.assertEqual(
             response.json(),
             response.json(),
-            {
-                "detail": "No more than %s threads can be merged at single time."
-                % THREADS_LIMIT
-            },
+            {"detail": "No more than 4 threads can be merged at a single time."},
         )
         )
 
 
     @patch_category_acl({"can_merge_threads": True})
     @patch_category_acl({"can_merge_threads": True})

+ 10 - 10
misago/threads/tests/test_threadslists.py

@@ -8,6 +8,7 @@ from .. import test
 from ...acl.test import patch_user_acl
 from ...acl.test import patch_user_acl
 from ...categories.models import Category
 from ...categories.models import Category
 from ...conf import settings
 from ...conf import settings
+from ...conf.test import override_dynamic_settings
 from ...readtracker import poststracker
 from ...readtracker import poststracker
 from ...users.test import AuthenticatedUserTestCase
 from ...users.test import AuthenticatedUserTestCase
 
 
@@ -332,16 +333,17 @@ class AllThreadsListTests(ThreadsListTestCase):
         self.assertTrue(positions["l"] > positions["g"])
         self.assertTrue(positions["l"] > positions["g"])
         self.assertTrue(positions["l"] > positions["s"])
         self.assertTrue(positions["l"] > positions["s"])
 
 
+    @override_dynamic_settings(threads_per_page=5)
     def test_noscript_pagination(self):
     def test_noscript_pagination(self):
         """threads list is paginated for users with js disabled"""
         """threads list is paginated for users with js disabled"""
-        threads_per_page = settings.MISAGO_THREADS_PER_PAGE
+        threads_per_page = 5
 
 
         # post and discard thread to move last_post_id count by one
         # post and discard thread to move last_post_id count by one
         test.post_thread(category=self.first_category).delete()
         test.post_thread(category=self.first_category).delete()
 
 
         # create test threads
         # create test threads
         threads = []
         threads = []
-        for _ in range(settings.MISAGO_THREADS_PER_PAGE * 2):
+        for _ in range(threads_per_page * 2):
             threads.append(test.post_thread(category=self.first_category))
             threads.append(test.post_thread(category=self.first_category))
 
 
         # threads starting with given one are on the list
         # threads starting with given one are on the list
@@ -355,11 +357,11 @@ class AllThreadsListTests(ThreadsListTestCase):
         self.assertContainsThread(response, threads[-2])
         self.assertContainsThread(response, threads[-2])
 
 
         # slice contains expected threads
         # slice contains expected threads
-        for visible_thread in threads[settings.MISAGO_THREADS_PER_PAGE - 1 : -1]:
+        for visible_thread in threads[threads_per_page - 1 : -1]:
             self.assertContainsThread(response, visible_thread)
             self.assertContainsThread(response, visible_thread)
 
 
         # threads after slice are hidden
         # threads after slice are hidden
-        for invisible_thread in threads[: settings.MISAGO_THREADS_PER_PAGE - 1]:
+        for invisible_thread in threads[: threads_per_page - 1]:
             self.assertNotContainsThread(response, invisible_thread)
             self.assertNotContainsThread(response, invisible_thread)
 
 
         # nonexisting start gives 404
         # nonexisting start gives 404
@@ -825,6 +827,7 @@ class NewThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json["results"]), 1)
         self.assertEqual(len(response_json["results"]), 1)
         self.assertEqual(response_json["results"][0]["id"], test_thread.pk)
         self.assertEqual(response_json["results"][0]["id"], test_thread.pk)
 
 
+    @override_dynamic_settings(readtracker_cutoff=3)
     @patch_categories_acl()
     @patch_categories_acl()
     def test_list_hides_global_cutoff_thread(self):
     def test_list_hides_global_cutoff_thread(self):
         """list hides thread started before global cutoff"""
         """list hides thread started before global cutoff"""
@@ -832,9 +835,7 @@ class NewThreadsListTests(ThreadsListTestCase):
         self.user.save()
         self.user.save()
 
 
         test_thread = test.post_thread(
         test_thread = test.post_thread(
-            category=self.category_a,
-            started_on=timezone.now()
-            - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF + 1),
+            category=self.category_a, started_on=timezone.now() - timedelta(days=5)
         )
         )
 
 
         response = self.client.get("/new/")
         response = self.client.get("/new/")
@@ -1052,6 +1053,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json["results"]), 0)
         self.assertEqual(len(response_json["results"]), 0)
 
 
+    @override_dynamic_settings(readtracker_cutoff=3)
     @patch_categories_acl()
     @patch_categories_acl()
     def test_list_hides_global_cutoff_thread(self):
     def test_list_hides_global_cutoff_thread(self):
         """list hides thread replied before global cutoff"""
         """list hides thread replied before global cutoff"""
@@ -1059,9 +1061,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         self.user.save()
         self.user.save()
 
 
         test_thread = test.post_thread(
         test_thread = test.post_thread(
-            category=self.category_a,
-            started_on=timezone.now()
-            - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF + 5),
+            category=self.category_a, started_on=timezone.now() - timedelta(days=5)
         )
         )
 
 
         poststracker.save_read(self.user, test_thread.first_post)
         poststracker.save_read(self.user, test_thread.first_post)

+ 8 - 9
misago/threads/tests/test_threadview.py

@@ -4,7 +4,7 @@ from .. import test
 from ...acl import useracl
 from ...acl import useracl
 from ...acl.test import patch_user_acl
 from ...acl.test import patch_user_acl
 from ...categories.models import Category
 from ...categories.models import Category
-from ...conf import settings
+from ...conf.test import override_dynamic_settings
 from ...conftest import get_cache_versions
 from ...conftest import get_cache_versions
 from ...users.test import AuthenticatedUserTestCase
 from ...users.test import AuthenticatedUserTestCase
 from ..checksums import update_post_checksum
 from ..checksums import update_post_checksum
@@ -280,12 +280,11 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
                 response = self.client.get(self.thread.get_absolute_url())
                 response = self.client.get(self.thread.get_absolute_url())
                 self.assertNotContains(response, event.get_absolute_url())
                 self.assertNotContains(response, event.get_absolute_url())
 
 
+    @override_dynamic_settings(events_per_page=4)
     def test_events_limit(self):
     def test_events_limit(self):
         """forum will trim oldest events if theres more than allowed by config"""
         """forum will trim oldest events if theres more than allowed by config"""
-        events_limit = settings.MISAGO_EVENTS_PER_PAGE
         events = []
         events = []
-
-        for _ in range(events_limit + 5):
+        for _ in range(5):
             request = Mock(user=self.user, user_ip="127.0.0.1")
             request = Mock(user=self.user, user_ip="127.0.0.1")
             event = record_event(request, self.thread, "closed")
             event = record_event(request, self.thread, "closed")
             events.append(event)
             events.append(event)
@@ -293,15 +292,15 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
         # test that only events within limits were rendered
         # test that only events within limits were rendered
         response = self.client.get(self.thread.get_absolute_url())
         response = self.client.get(self.thread.get_absolute_url())
 
 
-        for event in events[5:]:
+        for event in events[4:]:
             self.assertContains(response, event.get_absolute_url())
             self.assertContains(response, event.get_absolute_url())
-        for event in events[:5]:
-            self.assertNotContains(response, event.get_absolute_url())
+        self.assertNotContains(response, events[0].get_absolute_url())
 
 
+    @override_dynamic_settings(posts_per_page=10, events_per_page=4)
     def test_events_dont_take_space(self):
     def test_events_dont_take_space(self):
         """events dont take space away from posts"""
         """events dont take space away from posts"""
-        posts_limit = settings.MISAGO_POSTS_PER_PAGE
-        events_limit = settings.MISAGO_EVENTS_PER_PAGE
+        posts_limit = 10
+        events_limit = 4
         events = []
         events = []
 
 
         for _ in range(events_limit + 5):
         for _ in range(events_limit + 5):

+ 4 - 5
misago/threads/viewmodels/posts.py

@@ -1,5 +1,4 @@
 from ...acl.objectacl import add_acl_to_obj
 from ...acl.objectacl import add_acl_to_obj
-from ...conf import settings
 from ...core.shortcuts import paginate, pagination_dict
 from ...core.shortcuts import paginate, pagination_dict
 from ...readtracker.poststracker import make_read_aware
 from ...readtracker.poststracker import make_read_aware
 from ...users.online.utils import make_users_status_aware
 from ...users.online.utils import make_users_status_aware
@@ -20,8 +19,8 @@ class ViewModel:
 
 
         posts_queryset = self.get_posts_queryset(request, thread_model)
         posts_queryset = self.get_posts_queryset(request, thread_model)
 
 
-        posts_limit = settings.MISAGO_POSTS_PER_PAGE
-        posts_orphans = settings.MISAGO_POSTS_TAIL
+        posts_limit = request.settings.posts_per_page
+        posts_orphans = request.settings.posts_per_page_orphans
         list_page = paginate(
         list_page = paginate(
             posts_queryset, page, posts_limit, posts_orphans, paginator=PostsPaginator
             posts_queryset, page, posts_limit, posts_orphans, paginator=PostsPaginator
         )
         )
@@ -51,7 +50,7 @@ class ViewModel:
             if list_page.has_next():
             if list_page.has_next():
                 last_post = posts[-1]
                 last_post = posts[-1]
 
 
-            events_limit = settings.MISAGO_EVENTS_PER_PAGE
+            events_limit = request.settings.events_per_page
             posts += self.get_events_queryset(
             posts += self.get_events_queryset(
                 request, thread_model, events_limit, first_post, last_post
                 request, thread_model, events_limit, first_post, last_post
             )
             )
@@ -61,7 +60,7 @@ class ViewModel:
 
 
         # make posts and events ACL and reads aware
         # make posts and events ACL and reads aware
         add_acl_to_obj(request.user_acl, posts)
         add_acl_to_obj(request.user_acl, posts)
-        make_read_aware(request.user, posts)
+        make_read_aware(request, posts)
 
 
         self._user = request.user
         self._user = request.user
 
 

+ 1 - 1
misago/threads/viewmodels/thread.py

@@ -50,7 +50,7 @@ class ViewModel(BaseViewModel):
         add_acl_to_obj(request.user_acl, model)
         add_acl_to_obj(request.user_acl, model)
 
 
         if read_aware:
         if read_aware:
-            make_read_aware(request.user, request.user_acl, model)
+            make_read_aware(request, model)
         if subscription_aware:
         if subscription_aware:
             make_subscription_aware(request.user, model)
             make_subscription_aware(request.user, model)
 
 

+ 8 - 9
misago/threads/viewmodels/threads.py

@@ -6,10 +6,9 @@ from django.utils.translation import gettext as _
 from django.utils.translation import gettext_lazy
 from django.utils.translation import gettext_lazy
 
 
 from ...acl.objectacl import add_acl_to_obj
 from ...acl.objectacl import add_acl_to_obj
-from ...conf import settings
 from ...core.cursorpagination import get_page
 from ...core.cursorpagination import get_page
 from ...readtracker import threadstracker
 from ...readtracker import threadstracker
-from ...readtracker.dates import get_cutoff_date
+from ...readtracker.cutoffdate import get_cutoff_date
 from ..models import Post, Thread
 from ..models import Post, Thread
 from ..participants import make_participants_aware
 from ..participants import make_participants_aware
 from ..permissions import exclude_invisible_posts, exclude_invisible_threads
 from ..permissions import exclude_invisible_posts, exclude_invisible_threads
@@ -64,7 +63,7 @@ class ViewModel:
             list_page = get_page(
             list_page = get_page(
                 threads_queryset,
                 threads_queryset,
                 "-last_post_id",
                 "-last_post_id",
-                settings.MISAGO_THREADS_PER_PAGE,
+                request.settings.threads_per_page,
                 start,
                 start,
             )
             )
         except (EmptyPage, InvalidPage):
         except (EmptyPage, InvalidPage):
@@ -90,7 +89,7 @@ class ViewModel:
                 thread.is_read = False
                 thread.is_read = False
                 thread.is_new = True
                 thread.is_new = True
         else:
         else:
-            threadstracker.make_read_aware(request.user, request.user_acl, threads)
+            threadstracker.make_read_aware(request, threads)
 
 
         self.filter_threads(request, threads)
         self.filter_threads(request, threads)
 
 
@@ -208,16 +207,14 @@ def filter_threads_queryset(request, categories, list_type, queryset):
 
 
 def filter_read_threads_queryset(request, categories, list_type, queryset):
 def filter_read_threads_queryset(request, categories, list_type, queryset):
     # grab cutoffs for categories
     # grab cutoffs for categories
-    user = request.user
-
-    cutoff_date = get_cutoff_date(user)
+    cutoff_date = get_cutoff_date(request.settings, request.user)
 
 
     visible_posts = Post.objects.filter(posted_on__gt=cutoff_date)
     visible_posts = Post.objects.filter(posted_on__gt=cutoff_date)
     visible_posts = exclude_invisible_posts(request.user_acl, categories, visible_posts)
     visible_posts = exclude_invisible_posts(request.user_acl, categories, visible_posts)
 
 
     queryset = queryset.filter(id__in=visible_posts.distinct().values("thread"))
     queryset = queryset.filter(id__in=visible_posts.distinct().values("thread"))
 
 
-    read_posts = visible_posts.filter(id__in=user.postread_set.values("post"))
+    read_posts = visible_posts.filter(id__in=request.user.postread_set.values("post"))
 
 
     if list_type == "new":
     if list_type == "new":
         # new threads have no entry in reads table
         # new threads have no entry in reads table
@@ -225,7 +222,9 @@ def filter_read_threads_queryset(request, categories, list_type, queryset):
 
 
     if list_type == "unread":
     if list_type == "unread":
         # unread threads were read in past but have new posts
         # unread threads were read in past but have new posts
-        unread_posts = visible_posts.exclude(id__in=user.postread_set.values("post"))
+        unread_posts = visible_posts.exclude(
+            id__in=request.user.postread_set.values("post")
+        )
         queryset = queryset.filter(id__in=read_posts.distinct().values("thread"))
         queryset = queryset.filter(id__in=read_posts.distinct().values("thread"))
         queryset = queryset.filter(id__in=unread_posts.distinct().values("thread"))
         queryset = queryset.filter(id__in=unread_posts.distinct().values("thread"))
         return queryset
         return queryset

+ 8 - 7
misago/threads/views/attachment.py

@@ -1,3 +1,4 @@
+from django.templatetags.static import static
 from django.core.exceptions import PermissionDenied
 from django.core.exceptions import PermissionDenied
 from django.http import Http404
 from django.http import Http404
 from django.shortcuts import get_object_or_404, redirect
 from django.shortcuts import get_object_or_404, redirect
@@ -5,18 +6,18 @@ from django.shortcuts import get_object_or_404, redirect
 from ...conf import settings
 from ...conf import settings
 from ..models import Attachment, AttachmentType
 from ..models import Attachment, AttachmentType
 
 
-ATTACHMENT_404_URL = "".join((settings.STATIC_URL, settings.MISAGO_404_IMAGE))
-ATTACHMENT_403_URL = "".join((settings.STATIC_URL, settings.MISAGO_403_IMAGE))
+DEFAULT_403_URL = static(settings.MISAGO_ATTACHMENT_403_IMAGE)
+DEFAULT_404_URL = static(settings.MISAGO_ATTACHMENT_404_IMAGE)
 
 
 
 
 def attachment_server(request, pk, secret, thumbnail=False):
 def attachment_server(request, pk, secret, thumbnail=False):
     try:
     try:
         url = serve_file(request, pk, secret, thumbnail)
         url = serve_file(request, pk, secret, thumbnail)
         return redirect(url)
         return redirect(url)
-    except Http404:
-        return redirect(ATTACHMENT_404_URL)
     except PermissionDenied:
     except PermissionDenied:
-        return redirect(ATTACHMENT_403_URL)
+        return redirect(request.settings.attachment_403_image or DEFAULT_403_URL)
+    except Http404:
+        return redirect(request.settings.attachment_404_image or DEFAULT_404_URL)
 
 
 
 
 def serve_file(request, pk, secret, thumbnail):
 def serve_file(request, pk, secret, thumbnail):
@@ -24,7 +25,7 @@ def serve_file(request, pk, secret, thumbnail):
     attachment = get_object_or_404(queryset, pk=pk, secret=secret)
     attachment = get_object_or_404(queryset, pk=pk, secret=secret)
 
 
     if not attachment.post_id and request.GET.get("shva") != "1":
     if not attachment.post_id and request.GET.get("shva") != "1":
-        # if attachment is orphaned, don't run acl test unless explictly told so
+        # if attachment is orphaned, don't run acl test unless explicitly told so
         # this saves user suprise of deleted attachment still showing in posts/quotes
         # this saves user suprise of deleted attachment still showing in posts/quotes
         raise Http404()
         raise Http404()
 
 
@@ -32,7 +33,7 @@ def serve_file(request, pk, secret, thumbnail):
         allow_file_download(request, attachment)
         allow_file_download(request, attachment)
 
 
     if attachment.is_image:
     if attachment.is_image:
-        if thumbnail:
+        if thumbnail and attachment.thumbnail:
             return attachment.thumbnail.url
             return attachment.thumbnail.url
         return attachment.image.url
         return attachment.image.url
 
 

+ 5 - 4
misago/threads/views/goto.py

@@ -7,7 +7,7 @@ from django.utils.translation import gettext as _
 from django.views import View
 from django.views import View
 
 
 from ...conf import settings
 from ...conf import settings
-from ...readtracker.dates import get_cutoff_date
+from ...readtracker.cutoffdate import get_cutoff_date
 from ..permissions import exclude_invisible_posts
 from ..permissions import exclude_invisible_posts
 from ..viewmodels import ForumThread, PrivateThread
 from ..viewmodels import ForumThread, PrivateThread
 
 
@@ -55,8 +55,8 @@ class GotoView(View):
 
 
         post_position = previous_posts.count()
         post_position = previous_posts.count()
 
 
-        per_page = settings.MISAGO_POSTS_PER_PAGE - 1
-        orphans = settings.MISAGO_POSTS_TAIL
+        per_page = self.request.settings.posts_per_page - 1
+        orphans = self.request.settings.posts_per_page_orphans
         if orphans:
         if orphans:
             orphans += 1
             orphans += 1
 
 
@@ -90,7 +90,8 @@ class ThreadGotoLastView(GotoView):
 class GetFirstUnreadPostMixin:
 class GetFirstUnreadPostMixin:
     def get_first_unread_post(self, user, posts_queryset):
     def get_first_unread_post(self, user, posts_queryset):
         if user.is_authenticated:
         if user.is_authenticated:
-            expired_posts = Q(posted_on__lt=get_cutoff_date(user))
+            cutoff_date = get_cutoff_date(self.request.settings, user)
+            expired_posts = Q(posted_on__lt=cutoff_date)
             read_posts = Q(id__in=user.postread_set.values("post"))
             read_posts = Q(id__in=user.postread_set.values("post"))
 
 
             first_unread = (
             first_unread = (

+ 5 - 3
misago/users/activepostersranking.py

@@ -5,7 +5,7 @@ from django.db.models import Count
 from django.utils import timezone
 from django.utils import timezone
 
 
 from ..categories.models import Category
 from ..categories.models import Category
-from ..conf import settings
+from ..conf.shortcuts import get_dynamic_settings
 from .models import ActivityRanking
 from .models import ActivityRanking
 
 
 User = get_user_model()
 User = get_user_model()
@@ -23,7 +23,8 @@ def get_active_posters_ranking():
 
 
 
 
 def build_active_posters_ranking():
 def build_active_posters_ranking():
-    tracked_period = settings.MISAGO_RANKING_LENGTH
+    settings = get_dynamic_settings()
+    tracked_period = settings.top_posters_ranking_length
     tracked_since = timezone.now() - timedelta(days=tracked_period)
     tracked_since = timezone.now() - timedelta(days=tracked_period)
 
 
     ActivityRanking.objects.all().delete()
     ActivityRanking.objects.all().delete()
@@ -41,9 +42,10 @@ def build_active_posters_ranking():
         .annotate(score=Count("post"))
         .annotate(score=Count("post"))
         .filter(score__gt=0)
         .filter(score__gt=0)
         .order_by("-score")
         .order_by("-score")
-    )[: settings.MISAGO_RANKING_SIZE]
+    )[: settings.top_posters_ranking_size]
 
 
     new_ranking = []
     new_ranking = []
     for ranking in queryset.iterator():
     for ranking in queryset.iterator():
         new_ranking.append(ActivityRanking(user=ranking, score=ranking.score))
         new_ranking.append(ActivityRanking(user=ranking, score=ranking.score))
     ActivityRanking.objects.bulk_create(new_ranking)
     ActivityRanking.objects.bulk_create(new_ranking)
+    return new_ranking

+ 1 - 1
misago/users/admin/forms.py

@@ -638,7 +638,7 @@ class RequestDataDownloadsForm(forms.Form):
         if len(user_identifiers) > 20:
         if len(user_identifiers) > 20:
             raise forms.ValidationError(
             raise forms.ValidationError(
                 _(
                 _(
-                    "You may not enter more than 20 items at single time "
+                    "You may not enter more than 20 items at a single time "
                     "(You have entered %(show_value)s)."
                     "(You have entered %(show_value)s)."
                 )
                 )
                 % {"show_value": len(user_identifiers)}
                 % {"show_value": len(user_identifiers)}

+ 4 - 1
misago/users/admin/tasks.py

@@ -1,6 +1,8 @@
 from celery import shared_task
 from celery import shared_task
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 
 
+from ...conf.shortcuts import get_dynamic_settings
+
 User = get_user_model()
 User = get_user_model()
 
 
 
 
@@ -11,4 +13,5 @@ def delete_user_with_content(pk):
     except User.DoesNotExist:
     except User.DoesNotExist:
         pass
         pass
     else:
     else:
-        user.delete(delete_content=True)
+        settings = get_dynamic_settings()
+        user.delete(anonymous_username=settings.anonymous_username, delete_content=True)

+ 1 - 1
misago/users/admin/views/users.py

@@ -218,7 +218,7 @@ class UsersList(UserAdmin, generic.ListView):
                 raise generic.MassActionError(message)
                 raise generic.MassActionError(message)
 
 
         for user in users:
         for user in users:
-            user.delete()
+            user.delete(anonymous_username=request.settings.anonymous_username)
 
 
         messages.success(request, _("Selected users have been deleted."))
         messages.success(request, _("Selected users have been deleted."))
 
 

+ 5 - 4
misago/users/api/users.py

@@ -12,7 +12,6 @@ from rest_framework.response import Response
 
 
 from ...acl.objectacl import add_acl_to_obj
 from ...acl.objectacl import add_acl_to_obj
 from ...categories.models import Category
 from ...categories.models import Category
-from ...conf import settings
 from ...core.rest_permissions import IsAuthenticatedOrReadOnly
 from ...core.rest_permissions import IsAuthenticatedOrReadOnly
 from ...core.shortcuts import get_int_or_404
 from ...core.shortcuts import get_int_or_404
 from ...threads.moderation import hide_post, hide_thread
 from ...threads.moderation import hide_post, hide_thread
@@ -269,12 +268,14 @@ class UserViewSet(viewsets.GenericViewSet):
             request.user, pk, _("You can't request data downloads for other users.")
             request.user, pk, _("You can't request data downloads for other users.")
         )
         )
 
 
-        if not settings.MISAGO_ENABLE_DOWNLOAD_OWN_DATA:
+        if not request.settings.allow_data_downloads:
             raise PermissionDenied(_("You can't download your data."))
             raise PermissionDenied(_("You can't download your data."))
 
 
         if user_has_data_download_request(request.user):
         if user_has_data_download_request(request.user):
             raise PermissionDenied(
             raise PermissionDenied(
-                _("You can't have more than one data download request at single time.")
+                _(
+                    "You can't have more than one data download request at a single time."
+                )
             )
             )
 
 
         request_user_data_download(request.user)
         request_user_data_download(request.user)
@@ -316,7 +317,7 @@ class UserViewSet(viewsets.GenericViewSet):
                         category.synchronize()
                         category.synchronize()
                         category.save()
                         category.save()
 
 
-                profile.delete()
+                profile.delete(anonymous_username=request.settings.anonymous_username)
 
 
         return Response({})
         return Response({})
 
 

+ 20 - 14
misago/users/apps.py

@@ -44,21 +44,27 @@ class MisagoUsersConfig(AppConfig):
             icon="vpn_key",
             icon="vpn_key",
         )
         )
 
 
-        if settings.MISAGO_ENABLE_DOWNLOAD_OWN_DATA:
-            usercp.add_section(
-                link="misago:usercp-download-data",
-                name=_("Download data"),
-                component="download-data",
-                icon="save_alt",
-            )
+        def can_download_own_data(request):
+            return request.settings.allow_data_downloads
 
 
-        if settings.MISAGO_ENABLE_DELETE_OWN_ACCOUNT:
-            usercp.add_section(
-                link="misago:usercp-delete-account",
-                name=_("Delete account"),
-                component="delete-account",
-                icon="cancel",
-            )
+        usercp.add_section(
+            link="misago:usercp-download-data",
+            name=_("Download data"),
+            component="download-data",
+            icon="save_alt",
+            visible_if=can_download_own_data,
+        )
+
+        def can_delete_own_account(request):
+            return request.settings.allow_delete_own_account
+
+        usercp.add_section(
+            link="misago:usercp-delete-account",
+            name=_("Delete account"),
+            component="delete-account",
+            icon="cancel",
+            visible_if=can_delete_own_account,
+        )
 
 
     def register_default_users_list_pages(self):
     def register_default_users_list_pages(self):
         users_list.add_section(
         users_list.add_section(

+ 3 - 5
misago/users/datadownloads/__init__.py

@@ -25,23 +25,21 @@ def request_user_data_download(user, requester=None):
     )
     )
 
 
 
 
-def prepare_user_data_download(download, logger=None):
+def prepare_user_data_download(download, expires_in, logger=None):
     working_dir = settings.MISAGO_USER_DATA_DOWNLOADS_WORKING_DIR
     working_dir = settings.MISAGO_USER_DATA_DOWNLOADS_WORKING_DIR
     user = download.user
     user = download.user
     with DataArchive(user, working_dir) as archive:
     with DataArchive(user, working_dir) as archive:
         try:
         try:
             archive_user_data.send(user, archive=archive)
             archive_user_data.send(user, archive=archive)
             download.status = DataDownload.STATUS_READY
             download.status = DataDownload.STATUS_READY
-            download.expires_on = timezone.now() + timedelta(
-                hours=settings.MISAGO_USER_DATA_DOWNLOADS_EXPIRE_IN_HOURS
-            )
+            download.expires_on = timezone.now() + timedelta(hours=expires_in)
             download.file = archive.get_file()
             download.file = archive.get_file()
             download.save()
             download.save()
             # todo: send an e-mail with download link
             # todo: send an e-mail with download link
             return True
             return True
         except Exception as e:  # pylint: disable=broad-except
         except Exception as e:  # pylint: disable=broad-except
             if logger:
             if logger:
-                logger.exception(e)
+                logger.exception(e, exc_info=e)
             return False
             return False
 
 
 
 

+ 2 - 4
misago/users/management/commands/createsuperuser.py

@@ -12,8 +12,7 @@ from django.core.management.base import BaseCommand
 from django.db import DEFAULT_DB_ALIAS, IntegrityError
 from django.db import DEFAULT_DB_ALIAS, IntegrityError
 from django.utils.encoding import force_str
 from django.utils.encoding import force_str
 
 
-from ....cache.versions import get_cache_versions
-from ....conf.dynamicsettings import DynamicSettings
+from ....conf.shortcuts import get_dynamic_settings
 from ...setupnewuser import setup_new_user
 from ...setupnewuser import setup_new_user
 from ...validators import validate_email, validate_username
 from ...validators import validate_email, validate_username
 
 
@@ -82,8 +81,7 @@ class Command(BaseCommand):
         interactive = options.get("interactive")
         interactive = options.get("interactive")
         verbosity = int(options.get("verbosity", 1))
         verbosity = int(options.get("verbosity", 1))
 
 
-        cache_versions = get_cache_versions()
-        settings = DynamicSettings(cache_versions)
+        settings = get_dynamic_settings()
 
 
         # Validate initial inputs
         # Validate initial inputs
         if username is not None:
         if username is not None:

+ 7 - 6
misago/users/management/commands/deleteinactiveusers.py

@@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model
 from django.core.management.base import BaseCommand
 from django.core.management.base import BaseCommand
 from django.utils import timezone
 from django.utils import timezone
 
 
-from ....conf import settings
+from ....conf.shortcuts import get_dynamic_settings
 from ....core.pgutils import chunk_queryset
 from ....core.pgutils import chunk_queryset
 
 
 User = get_user_model()
 User = get_user_model()
@@ -14,16 +14,17 @@ class Command(BaseCommand):
     help = "Deletes inactive user accounts older than set time."
     help = "Deletes inactive user accounts older than set time."
 
 
     def handle(self, *args, **options):
     def handle(self, *args, **options):
-        if not settings.MISAGO_DELETE_NEW_INACTIVE_USERS_OLDER_THAN_DAYS:
+        settings = get_dynamic_settings()
+        if not settings.new_inactive_accounts_delete:
             self.stdout.write(
             self.stdout.write(
-                "Automatic deletion of inactive users is currently disabled."
+                "Automatic deletion of inactive user accounts is currently disabled."
             )
             )
             return
             return
 
 
         users_deleted = 0
         users_deleted = 0
 
 
         joined_on_cutoff = timezone.now() - timedelta(
         joined_on_cutoff = timezone.now() - timedelta(
-            days=settings.MISAGO_DELETE_NEW_INACTIVE_USERS_OLDER_THAN_DAYS
+            days=settings.new_inactive_accounts_delete
         )
         )
 
 
         queryset = User.objects.filter(
         queryset = User.objects.filter(
@@ -31,7 +32,7 @@ class Command(BaseCommand):
         )
         )
 
 
         for user in chunk_queryset(queryset):
         for user in chunk_queryset(queryset):
-            user.delete()
+            user.delete(anonymous_username=settings.anonymous_username)
             users_deleted += 1
             users_deleted += 1
 
 
-        self.stdout.write("Deleted users: %s" % users_deleted)
+        self.stdout.write("Deleted inactive user accounts: %s" % users_deleted)

+ 4 - 2
misago/users/management/commands/deletemarkedusers.py

@@ -1,6 +1,7 @@
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.core.management.base import BaseCommand
 from django.core.management.base import BaseCommand
 
 
+from ....conf.shortcuts import get_dynamic_settings
 from ....core.pgutils import chunk_queryset
 from ....core.pgutils import chunk_queryset
 from ...permissions import can_delete_own_account
 from ...permissions import can_delete_own_account
 
 
@@ -15,12 +16,13 @@ class Command(BaseCommand):
 
 
     def handle(self, *args, **options):
     def handle(self, *args, **options):
         users_deleted = 0
         users_deleted = 0
+        settings = get_dynamic_settings()
 
 
         queryset = User.objects.filter(is_deleting_account=True)
         queryset = User.objects.filter(is_deleting_account=True)
 
 
         for user in chunk_queryset(queryset):
         for user in chunk_queryset(queryset):
-            if can_delete_own_account(user, user):
-                user.delete()
+            if can_delete_own_account(settings, user, user):
+                user.delete(anonymous_username=settings.anonymous_username)
                 users_deleted += 1
                 users_deleted += 1
 
 
         self.stdout.write("Deleted users: %s" % users_deleted)
         self.stdout.write("Deleted users: %s" % users_deleted)

+ 4 - 6
misago/users/management/commands/prepareuserdatadownloads.py

@@ -3,9 +3,8 @@ import logging
 from django.core.management.base import BaseCommand
 from django.core.management.base import BaseCommand
 from django.utils.translation import gettext
 from django.utils.translation import gettext
 
 
-from ....cache.versions import get_cache_versions
 from ....conf import settings
 from ....conf import settings
-from ....conf.dynamicsettings import DynamicSettings
+from ....conf.shortcuts import get_dynamic_settings
 from ....core.mail import mail_user
 from ....core.mail import mail_user
 from ....core.pgutils import chunk_queryset
 from ....core.pgutils import chunk_queryset
 from ...datadownloads import prepare_user_data_download
 from ...datadownloads import prepare_user_data_download
@@ -27,15 +26,14 @@ class Command(BaseCommand):
             )
             )
             return
             return
 
 
-        cache_versions = get_cache_versions()
-        dynamic_settings = DynamicSettings(cache_versions)
-        expires_in = settings.MISAGO_USER_DATA_DOWNLOADS_EXPIRE_IN_HOURS
+        dynamic_settings = get_dynamic_settings()
+        expires_in = dynamic_settings.data_downloads_expiration
 
 
         downloads_prepared = 0
         downloads_prepared = 0
         queryset = DataDownload.objects.select_related("user")
         queryset = DataDownload.objects.select_related("user")
         queryset = queryset.filter(status=DataDownload.STATUS_PENDING)
         queryset = queryset.filter(status=DataDownload.STATUS_PENDING)
         for data_download in chunk_queryset(queryset):
         for data_download in chunk_queryset(queryset):
-            if prepare_user_data_download(data_download, logger):
+            if prepare_user_data_download(data_download, expires_in, logger):
                 user = data_download.user
                 user = data_download.user
                 subject = gettext("%(user)s, your data download is ready") % {
                 subject = gettext("%(user)s, your data download is ready") % {
                     "user": user
                     "user": user

+ 6 - 5
misago/users/management/commands/removeoldips.py

@@ -1,20 +1,21 @@
 from django.core.management import BaseCommand
 from django.core.management import BaseCommand
 
 
-from ....conf import settings
+from ....conf.shortcuts import get_dynamic_settings
 from ...signals import remove_old_ips
 from ...signals import remove_old_ips
 
 
 
 
 class Command(BaseCommand):
 class Command(BaseCommand):
-    help = "Removes users IPs stored for longer than set in MISAGO_IP_STORE_TIME."
+    help = "Removes users IPs stored for longer than configured by administrator."
 
 
     def handle(self, *args, **options):
     def handle(self, *args, **options):
-        if not settings.MISAGO_IP_STORE_TIME:
+        settings = get_dynamic_settings()
+        if not settings.ip_storage_time:
             self.stdout.write("Old IP removal is disabled.")
             self.stdout.write("Old IP removal is disabled.")
             return
             return
 
 
-        remove_old_ips.send(sender=self)
+        remove_old_ips.send(sender=self, ip_storage_time=settings.ip_storage_time)
 
 
         self.stdout.write(
         self.stdout.write(
             "IP addresses older than %s days have been removed."
             "IP addresses older than %s days have been removed."
-            % settings.MISAGO_IP_STORE_TIME
+            % settings.ip_storage_time
         )
         )

+ 7 - 3
misago/users/models/user.py

@@ -252,7 +252,11 @@ class User(AbstractBaseUser, PermissionsMixin):
         if kwargs.pop("delete_content", False):
         if kwargs.pop("delete_content", False):
             self.delete_content()
             self.delete_content()
 
 
-        self.anonymize_data()
+        username = kwargs.pop("anonymous_username", None)
+        if username:
+            self.anonymize_data(username)
+        else:
+            raise ValueError("user.delete() requires 'anonymous_username' argument")
 
 
         avatars.delete_avatar(self)
         avatars.delete_avatar(self)
 
 
@@ -268,13 +272,13 @@ class User(AbstractBaseUser, PermissionsMixin):
         self.is_deleting_account = True
         self.is_deleting_account = True
         self.save(update_fields=["is_active", "is_deleting_account"])
         self.save(update_fields=["is_active", "is_deleting_account"])
 
 
-    def anonymize_data(self):
+    def anonymize_data(self, anonymous_username):
         """Replaces username with anonymized one, then send anonymization signal.
         """Replaces username with anonymized one, then send anonymization signal.
 
 
         Items associated with this user then anonymize their user-specific data
         Items associated with this user then anonymize their user-specific data
         like username or IP addresses.
         like username or IP addresses.
         """
         """
-        self.username = settings.MISAGO_ANONYMOUS_USERNAME
+        self.username = anonymous_username
         self.slug = slugify(self.username)
         self.slug = slugify(self.username)
 
 
         from ..signals import anonymize_user_data
         from ..signals import anonymize_user_data

+ 3 - 4
misago/users/permissions/delete.py

@@ -10,7 +10,6 @@ from django.utils.translation import ngettext
 from ...acl import algebra
 from ...acl import algebra
 from ...acl.decorators import return_boolean
 from ...acl.decorators import return_boolean
 from ...acl.models import Role
 from ...acl.models import Role
-from ...conf import settings
 
 
 __all__ = [
 __all__ = [
     "allow_delete_user",
     "allow_delete_user",
@@ -100,11 +99,11 @@ def allow_delete_user(user_acl, target):
 can_delete_user = return_boolean(allow_delete_user)
 can_delete_user = return_boolean(allow_delete_user)
 
 
 
 
-def allow_delete_own_account(user, target):
-    if not settings.MISAGO_ENABLE_DELETE_OWN_ACCOUNT and not user.is_deleting_account:
-        raise PermissionDenied(_("You can't delete your account."))
+def allow_delete_own_account(settings, user, target):
     if user.id != target.id:
     if user.id != target.id:
         raise PermissionDenied(_("You can't delete other users accounts."))
         raise PermissionDenied(_("You can't delete other users accounts."))
+    if not settings.allow_delete_own_account and not user.is_deleting_account:
+        raise PermissionDenied(_("You can't delete your account."))
     if user.is_staff or user.is_superuser:
     if user.is_staff or user.is_superuser:
         raise PermissionDenied(
         raise PermissionDenied(
             _("You can't delete your account because you are an administrator.")
             _("You can't delete your account because you are an administrator.")

+ 1 - 1
misago/users/serializers/options.py

@@ -122,7 +122,7 @@ class DeleteOwnAccountSerializer(serializers.Serializer):
         deactivate it and sign user out.
         deactivate it and sign user out.
         """
         """
         profile = self.context["user"]
         profile = self.context["user"]
-        allow_delete_own_account(request.user, profile)
+        allow_delete_own_account(request.settings, request.user, profile)
 
 
         logout(request)
         logout(request)
         clear_tracking(request)
         clear_tracking(request)

+ 4 - 5
misago/users/signals.py

@@ -7,7 +7,6 @@ from django.dispatch import Signal, receiver
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from ..conf import settings
 from ..core.pgutils import chunk_queryset
 from ..core.pgutils import chunk_queryset
 from .models import AuditTrail, DataDownload
 from .models import AuditTrail, DataDownload
 from .profilefields import profilefields
 from .profilefields import profilefields
@@ -86,8 +85,8 @@ def handle_name_change(sender, **kwargs):
 
 
 
 
 @receiver(remove_old_ips)
 @receiver(remove_old_ips)
-def remove_old_registrations_ips(sender, **kwargs):
-    datetime_cutoff = timezone.now() - timedelta(days=settings.MISAGO_IP_STORE_TIME)
+def remove_old_registrations_ips(sender, *, ip_storage_time, **kwargs):
+    datetime_cutoff = timezone.now() - timedelta(days=ip_storage_time)
     ip_is_too_new = Q(joined_on__gt=datetime_cutoff)
     ip_is_too_new = Q(joined_on__gt=datetime_cutoff)
     ip_is_already_removed = Q(joined_from_ip__isnull=True)
     ip_is_already_removed = Q(joined_from_ip__isnull=True)
 
 
@@ -96,8 +95,8 @@ def remove_old_registrations_ips(sender, **kwargs):
 
 
 
 
 @receiver(remove_old_ips)
 @receiver(remove_old_ips)
-def remove_old_audit_trails(sender, **kwargs):
-    removal_cutoff = timezone.now() - timedelta(days=settings.MISAGO_IP_STORE_TIME)
+def remove_old_audit_trails(sender, *, ip_storage_time, **kwargs):
+    removal_cutoff = timezone.now() - timedelta(days=ip_storage_time)
     AuditTrail.objects.filter(created_on__lte=removal_cutoff).delete()
     AuditTrail.objects.filter(created_on__lte=removal_cutoff).delete()
 
 
 
 

+ 0 - 82
misago/users/tests/test_activepostersranking.py

@@ -1,82 +0,0 @@
-from datetime import timedelta
-
-from django.utils import timezone
-
-from ...categories.models import Category
-from ...threads.test import post_thread
-from ..activepostersranking import (
-    build_active_posters_ranking,
-    get_active_posters_ranking,
-)
-from ..test import AuthenticatedUserTestCase, create_test_user
-
-
-class TestActivePostersRanking(AuthenticatedUserTestCase):
-    def setUp(self):
-        super().setUp()
-
-        self.category = Category.objects.get(slug="first-category")
-
-    def test_get_active_posters_ranking(self):
-        """get_active_posters_ranking returns list of active posters"""
-        # no posts, empty tanking
-        empty_ranking = get_active_posters_ranking()
-
-        self.assertEqual(empty_ranking["users"], [])
-        self.assertEqual(empty_ranking["users_count"], 0)
-
-        # other user that will be posting
-        other_user = create_test_user("OtherUser", "otheruser@example.com")
-
-        # lurker user that won't post anything
-        create_test_user("Lurker", "lurker@example.com")
-
-        # unranked user that posted something 400 days ago
-        unranked_user = create_test_user("UnrankedUser", "unranked@example.com")
-
-        started_on = timezone.now() - timedelta(days=400)
-        post_thread(self.category, poster=unranked_user, started_on=started_on)
-
-        # Start testing scenarios
-        post_thread(self.category, poster=other_user)
-
-        build_active_posters_ranking()
-        ranking = get_active_posters_ranking()
-
-        self.assertEqual(ranking["users"], [other_user])
-        self.assertEqual(ranking["users_count"], 1)
-
-        # two users in ranking
-        post_thread(self.category, poster=self.user)
-        post_thread(self.category, poster=self.user)
-
-        build_active_posters_ranking()
-        ranking = get_active_posters_ranking()
-
-        self.assertEqual(ranking["users"], [self.user, other_user])
-        self.assertEqual(ranking["users_count"], 2)
-
-        self.assertEqual(ranking["users"][0].score, 2)
-        self.assertEqual(ranking["users"][1].score, 1)
-
-        # disabled users are not ranked
-        disabled = create_test_user("DisabledUser", "disableduser@example.com")
-
-        disabled.is_active = False
-        disabled.save()
-
-        post_thread(self.category, poster=disabled)
-        post_thread(self.category, poster=disabled)
-        post_thread(self.category, poster=disabled)
-
-        disabled.posts = 3
-        disabled.save()
-
-        build_active_posters_ranking()
-        ranking = get_active_posters_ranking()
-
-        self.assertEqual(ranking["users"], [self.user, other_user])
-        self.assertEqual(ranking["users_count"], 2)
-
-        self.assertEqual(ranking["users"][0].score, 2)
-        self.assertEqual(ranking["users"][1].score, 1)

+ 91 - 157
misago/users/tests/test_audittrail.py

@@ -1,186 +1,120 @@
 from datetime import timedelta
 from datetime import timedelta
 
 
-from django.contrib.auth import get_user_model
+import pytest
 from django.utils import timezone
 from django.utils import timezone
 
 
 from ..audittrail import create_audit_trail, create_user_audit_trail
 from ..audittrail import create_audit_trail, create_user_audit_trail
 from ..models import AuditTrail
 from ..models import AuditTrail
 from ..signals import remove_old_ips
 from ..signals import remove_old_ips
-from ..test import UserTestCase, create_test_user
-
-User = get_user_model()
 
 
 USER_IP = "13.41.51.41"
 USER_IP = "13.41.51.41"
 
 
 
 
-class MockRequest:
+class RequestMock:
     user_ip = USER_IP
     user_ip = USER_IP
 
 
     def __init__(self, user):
     def __init__(self, user):
         self.user = user
         self.user = user
 
 
 
 
-class CreateAuditTrailTests(UserTestCase):
-    def setUp(self):
-        super().setUp()
-
-        self.obj = create_test_user("OtherUser", "user@example.com")
-
-    def test_create_audit_require_model(self):
-        """create_audit_trail requires model instance"""
-        anonymous_user = self.get_anonymous_user()
-        request = MockRequest(anonymous_user)
-        with self.assertRaises(ValueError):
-            create_audit_trail(request, anonymous_user)
-        self.assertEqual(AuditTrail.objects.count(), 0)
-
-    def test_create_audit_trail_anonymous_user(self):
-        """create_audit_trail doesn't record anonymous users"""
-        user = self.get_anonymous_user()
-        request = MockRequest(user)
-        create_audit_trail(request, self.obj)
-        self.assertEqual(AuditTrail.objects.count(), 0)
-
-    def test_create_audit_trail(self):
-        """create_audit_trail creates new db record"""
-        user = self.get_authenticated_user()
-        request = MockRequest(user)
-        create_audit_trail(request, self.obj)
-        self.assertEqual(AuditTrail.objects.count(), 1)
-
-        audit_trail = user.audittrail_set.all()[0]
-        self.assertEqual(audit_trail.user, user)
-        self.assertEqual(audit_trail.ip_address, request.user_ip)
-        self.assertEqual(audit_trail.content_object, self.obj)
-
-    def test_delete_user_remove_audit_trail(self):
-        """audit trail is deleted together with user it belongs to"""
-        user = self.get_authenticated_user()
-        request = MockRequest(user)
-        create_audit_trail(request, self.obj)
-        self.assertEqual(AuditTrail.objects.count(), 1)
-
-        user.delete()
-        self.assertEqual(AuditTrail.objects.count(), 0)
-
-    def test_delete_obj_keep_audit_trail(self):
-        """audit trail is kept after with obj it points at is deleted"""
-        user = self.get_authenticated_user()
-        request = MockRequest(user)
-        create_audit_trail(request, self.obj)
-        self.assertEqual(AuditTrail.objects.count(), 1)
-
-        self.obj.delete()
-        self.assertEqual(AuditTrail.objects.count(), 1)
-
-        audit_trail = user.audittrail_set.all()[0]
-        self.assertEqual(audit_trail.user, user)
-        self.assertEqual(audit_trail.ip_address, request.user_ip)
-        self.assertIsNone(audit_trail.content_object)
-
-    def test_delete_audit_trail(self):
-        """audit trail deletion leaves other data untouched"""
-        user = self.get_authenticated_user()
-        request = MockRequest(user)
-        create_audit_trail(request, self.obj)
-        self.assertEqual(AuditTrail.objects.count(), 1)
-
-        audit_trail = user.audittrail_set.all()[0]
-        audit_trail.delete()
-
-        User.objects.get(id=user.id)
-        User.objects.get(id=self.obj.id)
-
-
-class CreateUserAuditTrailTests(UserTestCase):
-    def setUp(self):
-        super().setUp()
-
-        self.obj = create_test_user("OtherUser", "user@example.com")
-
-    def test_create_user_audit_require_model(self):
-        """create_user_audit_trail requires model instance"""
-        anonymous_user = self.get_anonymous_user()
-        with self.assertRaises(ValueError):
-            create_user_audit_trail(anonymous_user, USER_IP, anonymous_user)
-        self.assertEqual(AuditTrail.objects.count(), 0)
-
-    def test_create_user_audit_trail_anonymous_user(self):
-        """create_user_audit_trail doesn't record anonymous users"""
-        user = self.get_anonymous_user()
-        create_user_audit_trail(user, USER_IP, self.obj)
-        self.assertEqual(AuditTrail.objects.count(), 0)
-
-    def test_create_user_audit_trail(self):
-        """create_user_audit_trail creates new db record"""
-        user = self.get_authenticated_user()
-        create_user_audit_trail(user, USER_IP, self.obj)
-        self.assertEqual(AuditTrail.objects.count(), 1)
-
-        audit_trail = user.audittrail_set.all()[0]
-        self.assertEqual(audit_trail.user, user)
-        self.assertEqual(audit_trail.ip_address, USER_IP)
-        self.assertEqual(audit_trail.content_object, self.obj)
-
-    def test_delete_user_remove_audit_trail(self):
-        """audit trail is deleted together with user it belongs to"""
-        user = self.get_authenticated_user()
-        create_user_audit_trail(user, USER_IP, self.obj)
-        self.assertEqual(AuditTrail.objects.count(), 1)
-
-        user.delete()
-        self.assertEqual(AuditTrail.objects.count(), 0)
-
-    def test_delete_obj_keep_audit_trail(self):
-        """audit trail is kept after with obj it points at is deleted"""
-        user = self.get_authenticated_user()
-        create_user_audit_trail(user, USER_IP, self.obj)
-        self.assertEqual(AuditTrail.objects.count(), 1)
-
-        self.obj.delete()
-        self.assertEqual(AuditTrail.objects.count(), 1)
-
-        audit_trail = user.audittrail_set.all()[0]
-        self.assertEqual(audit_trail.user, user)
-        self.assertEqual(audit_trail.ip_address, USER_IP)
-        self.assertIsNone(audit_trail.content_object)
-
-    def test_delete_audit_trail(self):
-        """audit trail deletion leaves other data untouched"""
-        user = self.get_authenticated_user()
-        create_user_audit_trail(user, USER_IP, self.obj)
-        self.assertEqual(AuditTrail.objects.count(), 1)
+@pytest.fixture
+def request_mock(user):
+    return RequestMock(user)
+
+
+def test_audit_trail_creation_raises_value_error_if_target_is_not_model_instance(
+    request_mock, anonymous_user
+):
+    with pytest.raises(ValueError):
+        create_audit_trail(request_mock, anonymous_user)
+
+
+def test_audit_trail_is_not_created_for_anonymous_users(anonymous_user, user):
+    request_mock = RequestMock(anonymous_user)
+    create_audit_trail(request_mock, user)
+    assert not AuditTrail.objects.exists()
+
+
+def test_audit_trail_is_created(request_mock, other_user):
+    assert create_audit_trail(request_mock, other_user)
+    assert AuditTrail.objects.exists()
+
+
+def test_audit_trail_is_created_with_request_data(request_mock, user, other_user):
+    audit_trail = create_audit_trail(request_mock, other_user)
+
+    assert audit_trail.user == user
+    assert audit_trail.ip_address == USER_IP
+
+
+def test_audit_trail_is_created_with_generic_relation_to_target(
+    request_mock, user, other_user
+):
+    audit_trail = create_audit_trail(request_mock, other_user)
+    assert audit_trail.content_object == other_user
+
+
+def test_audit_trail_is_deleted_together_with_user(request_mock, user, other_user):
+    audit_trail = create_audit_trail(request_mock, other_user)
+    user.delete(anonymous_username="Deleted")
+    with pytest.raises(AuditTrail.DoesNotExist):
+        audit_trail.refresh_from_db()
+
+
+def test_audit_trail_is_kept_after_its_target_is_deleted(request_mock, other_user):
+    audit_trail = create_audit_trail(request_mock, other_user)
+    other_user.delete(anonymous_username="Deleted")
+    audit_trail.refresh_from_db()
+
+
+def test_deleting_audit_trail_leaves_user(request_mock, user, other_user):
+    audit_trail = create_audit_trail(request_mock, other_user)
+    audit_trail.delete()
+    user.refresh_from_db()
+
+
+def test_deleting_audit_trail_leaves_target(request_mock, user, other_user):
+    audit_trail = create_audit_trail(request_mock, other_user)
+    audit_trail.delete()
+    other_user.refresh_from_db()
+
+
+def test_audit_trail_can_be_created_without_request(user, other_user):
+    assert create_user_audit_trail(user, USER_IP, other_user)
+    assert AuditTrail.objects.exists()
 
 
-        audit_trail = user.audittrail_set.all()[0]
-        audit_trail.delete()
 
 
-        User.objects.get(id=user.id)
-        User.objects.get(id=self.obj.id)
+def test_audit_trail_creation_without_request_raises_value_error_if_target_is_not_model(
+    user, anonymous_user
+):
+    with pytest.raises(ValueError):
+        create_user_audit_trail(user, USER_IP, anonymous_user)
 
 
 
 
-class RemoveOldAuditTrailsTest(UserTestCase):
-    def setUp(self):
-        super().setUp()
+def test_audit_trail_without_request_is_created_with_explicit_data(user, other_user):
+    audit_trail = create_user_audit_trail(user, USER_IP, other_user)
 
 
-        self.obj = create_test_user("OtherUser", "user@example.com")
+    assert audit_trail.user == user
+    assert audit_trail.ip_address == USER_IP
 
 
-    def test_recent_audit_trail_is_kept(self):
-        """remove_old_ips keeps recent audit trails"""
-        user = self.get_authenticated_user()
-        create_user_audit_trail(user, USER_IP, self.obj)
 
 
-        remove_old_ips.send(None)
+def test_audit_trail_without_request_is_created_with_generic_relation_to_target(
+    user, other_user
+):
+    audit_trail = create_user_audit_trail(user, USER_IP, other_user)
+    assert audit_trail.content_object == other_user
 
 
-        self.assertEqual(user.audittrail_set.count(), 1)
 
 
-    def test_old_audit_trail_is_removed(self):
-        """remove_old_ips removes old audit trails"""
-        user = self.get_authenticated_user()
-        audit_trail = create_user_audit_trail(user, USER_IP, self.obj)
+def test_recent_audit_trail_is_not_deleted_on_signal(user, other_user):
+    create_user_audit_trail(user, USER_IP, other_user)
+    remove_old_ips.send(None, ip_storage_time=1)
+    assert user.audittrail_set.exists()
 
 
-        audit_trail.created_on = timezone.now() - timedelta(days=50)
-        audit_trail.save()
 
 
-        remove_old_ips.send(None)
+def test_old_audit_trail_is_deleted_on_signal(user, other_user):
+    audit_trail = create_user_audit_trail(user, USER_IP, other_user)
+    audit_trail.created_on = timezone.now() - timedelta(days=6)
+    audit_trail.save()
 
 
-        self.assertEqual(user.audittrail_set.count(), 0)
+    remove_old_ips.send(None, ip_storage_time=5)
+    assert not user.audittrail_set.exists()

+ 11 - 1
misago/users/tests/test_datadownloads.py

@@ -1,6 +1,8 @@
 import os
 import os
+from datetime import timedelta
 
 
 from django.core.files import File
 from django.core.files import File
+from django.utils import timezone
 
 
 from ...categories.models import Category
 from ...categories.models import Category
 from ...threads.models import AttachmentType
 from ...threads.models import AttachmentType
@@ -15,6 +17,7 @@ from ..datadownloads import (
 from ..models import DataDownload
 from ..models import DataDownload
 from ..test import AuthenticatedUserTestCase
 from ..test import AuthenticatedUserTestCase
 
 
+EXPIRATION = 4
 TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "testfiles")
 TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "testfiles")
 TEST_FILE_PATH = os.path.join(TESTFILES_DIR, "avatar.png")
 TEST_FILE_PATH = os.path.join(TESTFILES_DIR, "avatar.png")
 
 
@@ -65,7 +68,7 @@ class PrepareUserDataDownload(AuthenticatedUserTestCase):
         self.download = request_user_data_download(self.user)
         self.download = request_user_data_download(self.user)
 
 
     def assert_download_is_valid(self):
     def assert_download_is_valid(self):
-        result = prepare_user_data_download(self.download)
+        result = prepare_user_data_download(self.download, EXPIRATION)
         self.assertTrue(result)
         self.assertTrue(result)
 
 
         self.download.refresh_from_db()
         self.download.refresh_from_db()
@@ -75,6 +78,13 @@ class PrepareUserDataDownload(AuthenticatedUserTestCase):
         """function creates data download for basic user account"""
         """function creates data download for basic user account"""
         self.assert_download_is_valid()
         self.assert_download_is_valid()
 
 
+    def test_data_download_is_prepared_with_expiration_date(self):
+        """function creates data download with specified expiration date"""
+        expires_on = timezone.now() + timedelta(hours=EXPIRATION)
+        prepare_user_data_download(self.download, EXPIRATION)
+        self.download.refresh_from_db
+        self.assertGreater(self.download.expires_on, expires_on)
+
     def test_prepare_download_with_profle_fields(self):
     def test_prepare_download_with_profle_fields(self):
         """function creates data download for user with profile fields"""
         """function creates data download for user with profile fields"""
         self.user.profile_fields = {"real_name": "Bob Boberthon!"}
         self.user.profile_fields = {"real_name": "Bob Boberthon!"}

+ 14 - 13
misago/users/tests/test_deleteinactiveusers.py

@@ -3,9 +3,10 @@ from io import StringIO
 
 
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.core.management import call_command
 from django.core.management import call_command
-from django.test import TestCase, override_settings
+from django.test import TestCase
 from django.utils import timezone
 from django.utils import timezone
 
 
+from ...conf.test import override_dynamic_settings
 from ..management.commands import deleteinactiveusers
 from ..management.commands import deleteinactiveusers
 from ..test import create_test_user
 from ..test import create_test_user
 
 
@@ -16,7 +17,7 @@ class DeleteInactiveUsersTests(TestCase):
     def setUp(self):
     def setUp(self):
         self.user = create_test_user("User", "user@example.com")
         self.user = create_test_user("User", "user@example.com")
 
 
-    @override_settings(MISAGO_DELETE_NEW_INACTIVE_USERS_OLDER_THAN_DAYS=2)
+    @override_dynamic_settings(new_inactive_accounts_delete=2)
     def test_delete_user_activation_user(self):
     def test_delete_user_activation_user(self):
         """deletes user that didn't activate their account within required time"""
         """deletes user that didn't activate their account within required time"""
         self.user.joined_on = timezone.now() - timedelta(days=2)
         self.user.joined_on = timezone.now() - timedelta(days=2)
@@ -27,12 +28,12 @@ class DeleteInactiveUsersTests(TestCase):
         call_command(deleteinactiveusers.Command(), stdout=out)
         call_command(deleteinactiveusers.Command(), stdout=out)
         command_output = out.getvalue().splitlines()[0].strip()
         command_output = out.getvalue().splitlines()[0].strip()
 
 
-        self.assertEqual(command_output, "Deleted users: 1")
+        self.assertEqual(command_output, "Deleted inactive user accounts: 1")
 
 
         with self.assertRaises(User.DoesNotExist):
         with self.assertRaises(User.DoesNotExist):
             self.user.refresh_from_db()
             self.user.refresh_from_db()
 
 
-    @override_settings(MISAGO_DELETE_NEW_INACTIVE_USERS_OLDER_THAN_DAYS=2)
+    @override_dynamic_settings(new_inactive_accounts_delete=2)
     def test_delete_user_activation_admin(self):
     def test_delete_user_activation_admin(self):
         """deletes user that wasn't activated by admin within required time"""
         """deletes user that wasn't activated by admin within required time"""
         self.user.joined_on = timezone.now() - timedelta(days=2)
         self.user.joined_on = timezone.now() - timedelta(days=2)
@@ -43,12 +44,12 @@ class DeleteInactiveUsersTests(TestCase):
         call_command(deleteinactiveusers.Command(), stdout=out)
         call_command(deleteinactiveusers.Command(), stdout=out)
         command_output = out.getvalue().splitlines()[0].strip()
         command_output = out.getvalue().splitlines()[0].strip()
 
 
-        self.assertEqual(command_output, "Deleted users: 1")
+        self.assertEqual(command_output, "Deleted inactive user accounts: 1")
 
 
         with self.assertRaises(User.DoesNotExist):
         with self.assertRaises(User.DoesNotExist):
             self.user.refresh_from_db()
             self.user.refresh_from_db()
 
 
-    @override_settings(MISAGO_DELETE_NEW_INACTIVE_USERS_OLDER_THAN_DAYS=2)
+    @override_dynamic_settings(new_inactive_accounts_delete=2)
     def test_skip_new_user_activation_user(self):
     def test_skip_new_user_activation_user(self):
         """skips inactive user that is too new"""
         """skips inactive user that is too new"""
         self.user.joined_on = timezone.now() - timedelta(days=1)
         self.user.joined_on = timezone.now() - timedelta(days=1)
@@ -59,11 +60,11 @@ class DeleteInactiveUsersTests(TestCase):
         call_command(deleteinactiveusers.Command(), stdout=out)
         call_command(deleteinactiveusers.Command(), stdout=out)
         command_output = out.getvalue().splitlines()[0].strip()
         command_output = out.getvalue().splitlines()[0].strip()
 
 
-        self.assertEqual(command_output, "Deleted users: 0")
+        self.assertEqual(command_output, "Deleted inactive user accounts: 0")
 
 
         self.user.refresh_from_db()
         self.user.refresh_from_db()
 
 
-    @override_settings(MISAGO_DELETE_NEW_INACTIVE_USERS_OLDER_THAN_DAYS=2)
+    @override_dynamic_settings(new_inactive_accounts_delete=2)
     def test_skip_new_user_activation_admin(self):
     def test_skip_new_user_activation_admin(self):
         """skips admin-activated user that is too new"""
         """skips admin-activated user that is too new"""
         self.user.joined_on = timezone.now() - timedelta(days=1)
         self.user.joined_on = timezone.now() - timedelta(days=1)
@@ -74,11 +75,11 @@ class DeleteInactiveUsersTests(TestCase):
         call_command(deleteinactiveusers.Command(), stdout=out)
         call_command(deleteinactiveusers.Command(), stdout=out)
         command_output = out.getvalue().splitlines()[0].strip()
         command_output = out.getvalue().splitlines()[0].strip()
 
 
-        self.assertEqual(command_output, "Deleted users: 0")
+        self.assertEqual(command_output, "Deleted inactive user accounts: 0")
 
 
         self.user.refresh_from_db()
         self.user.refresh_from_db()
 
 
-    @override_settings(MISAGO_DELETE_NEW_INACTIVE_USERS_OLDER_THAN_DAYS=2)
+    @override_dynamic_settings(new_inactive_accounts_delete=2)
     def test_skip_active_user(self):
     def test_skip_active_user(self):
         """skips active user"""
         """skips active user"""
         self.user.joined_on = timezone.now() - timedelta(days=1)
         self.user.joined_on = timezone.now() - timedelta(days=1)
@@ -88,11 +89,11 @@ class DeleteInactiveUsersTests(TestCase):
         call_command(deleteinactiveusers.Command(), stdout=out)
         call_command(deleteinactiveusers.Command(), stdout=out)
         command_output = out.getvalue().splitlines()[0].strip()
         command_output = out.getvalue().splitlines()[0].strip()
 
 
-        self.assertEqual(command_output, "Deleted users: 0")
+        self.assertEqual(command_output, "Deleted inactive user accounts: 0")
 
 
         self.user.refresh_from_db()
         self.user.refresh_from_db()
 
 
-    @override_settings(MISAGO_DELETE_NEW_INACTIVE_USERS_OLDER_THAN_DAYS=0)
+    @override_dynamic_settings(new_inactive_accounts_delete=0)
     def test_delete_inactive_is_disabled(self):
     def test_delete_inactive_is_disabled(self):
         """skips active user"""
         """skips active user"""
         self.user.joined_on = timezone.now() - timedelta(days=1)
         self.user.joined_on = timezone.now() - timedelta(days=1)
@@ -105,7 +106,7 @@ class DeleteInactiveUsersTests(TestCase):
 
 
         self.assertEqual(
         self.assertEqual(
             command_output,
             command_output,
-            "Automatic deletion of inactive users is currently disabled.",
+            "Automatic deletion of inactive user accounts is currently disabled.",
         )
         )
 
 
         self.user.refresh_from_db()
         self.user.refresh_from_db()

+ 3 - 2
misago/users/tests/test_deletemarkedusers.py

@@ -2,8 +2,9 @@ from io import StringIO
 
 
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.core.management import call_command
 from django.core.management import call_command
-from django.test import TestCase, override_settings
+from django.test import TestCase
 
 
+from ...conf.test import override_dynamic_settings
 from ..management.commands import deletemarkedusers
 from ..management.commands import deletemarkedusers
 from ..test import create_test_user
 from ..test import create_test_user
 
 
@@ -26,7 +27,7 @@ class DeleteMarkedUsersTests(TestCase):
         with self.assertRaises(User.DoesNotExist):
         with self.assertRaises(User.DoesNotExist):
             self.user.refresh_from_db()
             self.user.refresh_from_db()
 
 
-    @override_settings(MISAGO_ENABLE_DELETE_OWN_ACCOUNT=False)
+    @override_dynamic_settings(allow_delete_own_account=False)
     def test_delete_disabled(self):
     def test_delete_disabled(self):
         """deletion respects user decision even if configuration has changed"""
         """deletion respects user decision even if configuration has changed"""
         out = StringIO()
         out = StringIO()

+ 2 - 2
misago/users/tests/test_prepareuserdatadownloads.py

@@ -11,14 +11,14 @@ from ..test import AuthenticatedUserTestCase
 
 
 
 
 class PrepareUserDataDownloadsTests(AuthenticatedUserTestCase):
 class PrepareUserDataDownloadsTests(AuthenticatedUserTestCase):
+    @override_dynamic_settings(forum_address="http://test.com/")
     def test_process_pending_data_download(self):
     def test_process_pending_data_download(self):
         """management command processes pending data download"""
         """management command processes pending data download"""
         data_download = request_user_data_download(self.user)
         data_download = request_user_data_download(self.user)
         self.assertEqual(data_download.status, DataDownload.STATUS_PENDING)
         self.assertEqual(data_download.status, DataDownload.STATUS_PENDING)
 
 
         out = StringIO()
         out = StringIO()
-        with override_dynamic_settings(forum_address="http://test.com/"):
-            call_command(prepareuserdatadownloads.Command(), stdout=out)
+        call_command(prepareuserdatadownloads.Command(), stdout=out)
 
 
         command_output = out.getvalue().splitlines()[0].strip()
         command_output = out.getvalue().splitlines()[0].strip()
         self.assertEqual(command_output, "Data downloads prepared: 1")
         self.assertEqual(command_output, "Data downloads prepared: 1")

+ 6 - 6
misago/users/tests/test_remove_old_ips_command.py

@@ -3,9 +3,9 @@ from io import StringIO
 
 
 import pytest
 import pytest
 from django.core.management import call_command
 from django.core.management import call_command
-from django.test import override_settings
 from django.utils import timezone
 from django.utils import timezone
 
 
+from ...conf.test import override_dynamic_settings
 from ..management.commands import removeoldips
 from ..management.commands import removeoldips
 
 
 IP_STORE_TIME = 2
 IP_STORE_TIME = 2
@@ -32,14 +32,14 @@ def test_recent_user_joined_ip_is_not_removed_by_command(user_with_ip):
     assert user_with_ip.joined_from_ip
     assert user_with_ip.joined_from_ip
 
 
 
 
-@override_settings(MISAGO_IP_STORE_TIME=IP_STORE_TIME)
+@override_dynamic_settings(ip_storage_time=IP_STORE_TIME)
 def test_old_user_joined_ip_is_removed_by_command(user_with_old_ip):
 def test_old_user_joined_ip_is_removed_by_command(user_with_old_ip):
     call_command(removeoldips.Command(), stdout=StringIO())
     call_command(removeoldips.Command(), stdout=StringIO())
     user_with_old_ip.refresh_from_db()
     user_with_old_ip.refresh_from_db()
     assert user_with_old_ip.joined_from_ip is None
     assert user_with_old_ip.joined_from_ip is None
 
 
 
 
-@override_settings(MISAGO_IP_STORE_TIME=None)
+@override_dynamic_settings(ip_storage_time=None)
 def test_old_user_joined_ip_is_not_removed_by_command_if_removal_is_disabled(
 def test_old_user_joined_ip_is_not_removed_by_command_if_removal_is_disabled(
     user_with_old_ip
     user_with_old_ip
 ):
 ):
@@ -48,7 +48,7 @@ def test_old_user_joined_ip_is_not_removed_by_command_if_removal_is_disabled(
     assert user_with_old_ip.joined_from_ip
     assert user_with_old_ip.joined_from_ip
 
 
 
 
-@override_settings(MISAGO_IP_STORE_TIME=IP_STORE_TIME)
+@override_dynamic_settings(ip_storage_time=IP_STORE_TIME)
 def test_command_displays_message_if_old_ip_removal_is_enabled(db):
 def test_command_displays_message_if_old_ip_removal_is_enabled(db):
     stdout = StringIO()
     stdout = StringIO()
     call_command(removeoldips.Command(), stdout=stdout)
     call_command(removeoldips.Command(), stdout=stdout)
@@ -57,8 +57,8 @@ def test_command_displays_message_if_old_ip_removal_is_enabled(db):
     assert command_output == "IP addresses older than 2 days have been removed."
     assert command_output == "IP addresses older than 2 days have been removed."
 
 
 
 
-@override_settings(MISAGO_IP_STORE_TIME=None)
-def test_command_displays_message_if_old_ip_removal_is_disabled():
+@override_dynamic_settings(ip_storage_time=None)
+def test_command_displays_message_if_old_ip_removal_is_disabled(db):
     stdout = StringIO()
     stdout = StringIO()
     call_command(removeoldips.Command(), stdout=stdout)
     call_command(removeoldips.Command(), stdout=stdout)
 
 

+ 1 - 1
misago/users/tests/test_search.py

@@ -69,7 +69,7 @@ class SearchApiTests(AuthenticatedUserTestCase):
                 self.assertEqual(len(results), 1)
                 self.assertEqual(len(results), 1)
                 self.assertEqual(results[0]["id"], self.user.id)
                 self.assertEqual(results[0]["id"], self.user.id)
 
 
-    def test_tail_match(self):
+    def test_orphans_match(self):
         """api handles last three chars match query"""
         """api handles last three chars match query"""
         response = self.client.get("%s?q=%s" % (self.api_link, self.user.username[-3:]))
         response = self.client.get("%s?q=%s" % (self.api_link, self.user.username[-3:]))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)

+ 98 - 0
misago/users/tests/test_stop_forum_spam_validator.py

@@ -0,0 +1,98 @@
+import json
+from unittest.mock import Mock
+
+import pytest
+from django.forms import ValidationError
+from requests.exceptions import Timeout
+
+from ...conf.test import override_dynamic_settings
+from ..validators import validate_with_sfs
+
+cleaned_data = {"email": "test@test.com"}
+
+
+@pytest.fixture
+def request_mock(dynamic_settings):
+    return Mock(settings=dynamic_settings, user_ip="127.0.0.1")
+
+
+@pytest.fixture
+def api_mock(mocker):
+    return mocker.patch(
+        "misago.users.validators.requests",
+        get=Mock(
+            return_value=Mock(
+                content=json.dumps(
+                    {"email": {"confidence": 55}, "ip": {"confidence": 45}}
+                )
+            )
+        ),
+    )
+
+
+@override_dynamic_settings(enable_stop_forum_spam=False)
+def test_api_is_not_called_if_sfs_is_disabled(api_mock, request_mock):
+    validate_with_sfs(request_mock, cleaned_data, None)
+    api_mock.get.assert_not_called()
+
+
+@override_dynamic_settings(enable_stop_forum_spam=True)
+def test_api_is_not_called_if_email_is_not_available(api_mock, request_mock):
+    validate_with_sfs(request_mock, {}, None)
+    api_mock.get.assert_not_called()
+
+
+@override_dynamic_settings(enable_stop_forum_spam=True, stop_forum_spam_confidence=90)
+def test_api_is_called_if_sfs_is_enabled_and_email_is_provided(api_mock, request_mock):
+    validate_with_sfs(request_mock, cleaned_data, None)
+    api_mock.get.assert_called_once()
+
+
+@override_dynamic_settings(enable_stop_forum_spam=True, stop_forum_spam_confidence=50)
+def test_validator_raises_error_if_ip_score_is_greater_than_confidence(
+    api_mock, request_mock
+):
+    with pytest.raises(ValidationError):
+        validate_with_sfs(request_mock, cleaned_data, None)
+
+
+@override_dynamic_settings(enable_stop_forum_spam=True, stop_forum_spam_confidence=52)
+def test_validator_raises_error_if_email_score_is_greater_than_confidence(
+    api_mock, request_mock
+):
+    with pytest.raises(ValidationError):
+        validate_with_sfs(request_mock, cleaned_data, None)
+
+
+@override_dynamic_settings(enable_stop_forum_spam=True)
+def test_validator_handles_api_error(mocker, request_mock):
+    failing_api_mock = mocker.patch(
+        "misago.users.validators.requests",
+        get=Mock(return_value=Mock(raise_for_status=Mock(side_effect=Timeout()))),
+    )
+
+    validate_with_sfs(request_mock, cleaned_data, None)
+    failing_api_mock.get.assert_called_once()
+
+
+@override_dynamic_settings(enable_stop_forum_spam=True)
+def test_validator_logs_api_error(mocker, request_mock):
+    failing_api_mock = mocker.patch(
+        "misago.users.validators.requests",
+        get=Mock(return_value=Mock(raise_for_status=Mock(side_effect=Timeout()))),
+    )
+    logger_mock = mocker.patch("misago.users.validators.logger", exception=Mock())
+
+    validate_with_sfs(request_mock, cleaned_data, None)
+    failing_api_mock.get.assert_called_once()
+    logger_mock.exception.assert_called_once()
+
+
+@override_dynamic_settings(enable_stop_forum_spam=True)
+def test_validator_handles_malformed_api_response(mocker, request_mock):
+    failing_api_mock = mocker.patch(
+        "misago.users.validators.requests", get=Mock(return_value=Mock(content="{}"))
+    )
+
+    validate_with_sfs(request_mock, cleaned_data, None)
+    failing_api_mock.get.assert_called_once()

+ 77 - 0
misago/users/tests/test_top_posters_ranking.py

@@ -0,0 +1,77 @@
+from datetime import timedelta
+
+from django.utils import timezone
+
+from ...conf.test import override_dynamic_settings
+from ...threads.test import reply_thread
+from ..activepostersranking import (
+    build_active_posters_ranking,
+    get_active_posters_ranking,
+)
+from ..models import ActivityRanking
+from ..test import create_test_user
+
+
+def test_ranking_is_emptied_if_no_users_exist(post):
+    assert not build_active_posters_ranking()
+
+
+def test_ranking_is_emptied_if_no_posts_exist(user):
+    assert not build_active_posters_ranking()
+
+
+@override_dynamic_settings(top_posters_ranking_length=5)
+def test_recent_post_by_user_counts_to_ranking(thread, user):
+    reply_thread(thread, poster=user)
+    assert build_active_posters_ranking()
+
+
+@override_dynamic_settings(top_posters_ranking_length=5)
+def test_recent_post_by_removed_user_doesnt_count_to_ranking(thread):
+    reply_thread(thread)
+    assert not build_active_posters_ranking()
+
+
+@override_dynamic_settings(top_posters_ranking_length=5)
+def test_old_post_by_user_doesnt_count_to_ranking(thread, user):
+    reply_thread(thread, poster=user, posted_on=timezone.now() - timedelta(days=6))
+    assert not build_active_posters_ranking()
+
+
+@override_dynamic_settings(top_posters_ranking_size=2)
+def test_ranking_size_is_limited(thread):
+    for i in range(3):
+        user = create_test_user("User%s" % i, "user%s@example.com" % i)
+        reply_thread(thread, poster=user)
+    assert len(build_active_posters_ranking()) == 2
+
+
+@override_dynamic_settings(top_posters_ranking_size=2)
+def test_old_ranking_is_removed_during_build(user):
+    ActivityRanking.objects.create(user=user, score=1)
+    build_active_posters_ranking()
+    assert not ActivityRanking.objects.exists()
+
+
+def test_empty_ranking_is_returned_from_db(db):
+    assert get_active_posters_ranking() == {"users": [], "users_count": 0}
+
+
+def test_ranking_is_returned_from_db(user):
+    ActivityRanking.objects.create(user=user, score=1)
+    assert get_active_posters_ranking() == {"users": [user], "users_count": 1}
+
+
+def test_ranked_user_is_annotated_with_score(user):
+    ActivityRanking.objects.create(user=user, score=1)
+    ranked_user = get_active_posters_ranking()["users"][0]
+    assert ranked_user.score == 1
+
+
+def test_ranked_users_are_ordered_by_score(user, other_user):
+    ActivityRanking.objects.create(user=user, score=1)
+    ActivityRanking.objects.create(user=other_user, score=2)
+    assert get_active_posters_ranking() == {
+        "users": [other_user, user],
+        "users_count": 2,
+    }

+ 10 - 10
misago/users/tests/test_user_changeemail_api.py

@@ -73,15 +73,15 @@ class UserChangeEmailTests(AuthenticatedUserTestCase):
             response.json(), {"new_email": ["This e-mail address is not available."]}
             response.json(), {"new_email": ["This e-mail address is not available."]}
         )
         )
 
 
+    @override_dynamic_settings(forum_address="http://test.com/")
     def test_change_email(self):
     def test_change_email(self):
         """api allows users to change their e-mail addresses"""
         """api allows users to change their e-mail addresses"""
         new_email = "new@email.com"
         new_email = "new@email.com"
 
 
-        with override_dynamic_settings(forum_address="http://test.com/"):
-            response = self.client.post(
-                self.link, data={"new_email": new_email, "password": self.USER_PASSWORD}
-            )
-            self.assertEqual(response.status_code, 200)
+        response = self.client.post(
+            self.link, data={"new_email": new_email, "password": self.USER_PASSWORD}
+        )
+        self.assertEqual(response.status_code, 200)
 
 
         self.assertIn("Confirm e-mail change", mail.outbox[0].subject)
         self.assertIn("Confirm e-mail change", mail.outbox[0].subject)
         for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
         for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
@@ -100,6 +100,7 @@ class UserChangeEmailTests(AuthenticatedUserTestCase):
         self.reload_user()
         self.reload_user()
         self.assertEqual(self.user.email, new_email)
         self.assertEqual(self.user.email, new_email)
 
 
+    @override_dynamic_settings(forum_address="http://test.com/")
     def test_change_email_user_password_whitespace(self):
     def test_change_email_user_password_whitespace(self):
         """api supports users with whitespace around their passwords"""
         """api supports users with whitespace around their passwords"""
         user_password = " old password "
         user_password = " old password "
@@ -110,11 +111,10 @@ class UserChangeEmailTests(AuthenticatedUserTestCase):
 
 
         self.login_user(self.user)
         self.login_user(self.user)
 
 
-        with override_dynamic_settings(forum_address="http://test.com/"):
-            response = self.client.post(
-                self.link, data={"new_email": new_email, "password": user_password}
-            )
-            self.assertEqual(response.status_code, 200)
+        response = self.client.post(
+            self.link, data={"new_email": new_email, "password": user_password}
+        )
+        self.assertEqual(response.status_code, 200)
 
 
         self.assertIn("Confirm e-mail change", mail.outbox[0].subject)
         self.assertIn("Confirm e-mail change", mail.outbox[0].subject)
         for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
         for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:

+ 11 - 11
misago/users/tests/test_user_changepassword_api.py

@@ -68,16 +68,16 @@ class UserChangePasswordTests(AuthenticatedUserTestCase):
             },
             },
         )
         )
 
 
+    @override_dynamic_settings(forum_address="http://test.com/")
     def test_change_password(self):
     def test_change_password(self):
         """api allows users to change their passwords"""
         """api allows users to change their passwords"""
         new_password = "N3wP@55w0rd"
         new_password = "N3wP@55w0rd"
 
 
-        with override_dynamic_settings(forum_address="http://test.com/"):
-            response = self.client.post(
-                self.link,
-                data={"new_password": new_password, "password": self.USER_PASSWORD},
-            )
-            self.assertEqual(response.status_code, 200)
+        response = self.client.post(
+            self.link,
+            data={"new_password": new_password, "password": self.USER_PASSWORD},
+        )
+        self.assertEqual(response.status_code, 200)
 
 
         self.assertIn("Confirm password change", mail.outbox[0].subject)
         self.assertIn("Confirm password change", mail.outbox[0].subject)
         for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
         for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
@@ -96,6 +96,7 @@ class UserChangePasswordTests(AuthenticatedUserTestCase):
         self.reload_user()
         self.reload_user()
         self.assertTrue(self.user.check_password(new_password))
         self.assertTrue(self.user.check_password(new_password))
 
 
+    @override_dynamic_settings(forum_address="http://test.com/")
     def test_change_password_with_whitespaces(self):
     def test_change_password_with_whitespaces(self):
         """api handles users with whitespaces around their passwords"""
         """api handles users with whitespaces around their passwords"""
         old_password = " old password "
         old_password = " old password "
@@ -106,11 +107,10 @@ class UserChangePasswordTests(AuthenticatedUserTestCase):
 
 
         self.login_user(self.user)
         self.login_user(self.user)
 
 
-        with override_dynamic_settings(forum_address="http://test.com/"):
-            response = self.client.post(
-                self.link, data={"new_password": new_password, "password": old_password}
-            )
-            self.assertEqual(response.status_code, 200)
+        response = self.client.post(
+            self.link, data={"new_password": new_password, "password": old_password}
+        )
+        self.assertEqual(response.status_code, 200)
 
 
         self.assertIn("Confirm password change", mail.outbox[0].subject)
         self.assertIn("Confirm password change", mail.outbox[0].subject)
         for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
         for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:

+ 7 - 7
misago/users/tests/test_user_model.py

@@ -10,10 +10,10 @@ from ..models import Avatar, DataDownload, User
 from ..utils import hash_email
 from ..utils import hash_email
 
 
 
 
-def test_username_and_slug_is_removed_by_anonymization(user):
-    user.anonymize_data()
-    assert user.username == settings.MISAGO_ANONYMOUS_USERNAME
-    assert user.slug == slugify(settings.MISAGO_ANONYMOUS_USERNAME)
+def test_username_and_slug_is_anonymized(user):
+    user.anonymize_data(anonymous_username="Deleted")
+    assert user.username == "Deleted"
+    assert user.slug == slugify("Deleted")
 
 
 
 
 def test_user_avatar_files_are_deleted_during_user_deletion(user):
 def test_user_avatar_files_are_deleted_during_user_deletion(user):
@@ -28,7 +28,7 @@ def test_user_avatar_files_are_deleted_during_user_deletion(user):
         user_avatars.append(avatar)
         user_avatars.append(avatar)
     assert user_avatars
     assert user_avatars
 
 
-    user.delete()
+    user.delete(anonymous_username="Deleted")
 
 
     for removed_avatar in user_avatars:
     for removed_avatar in user_avatars:
         avatar_path = Path(removed_avatar.image.path)
         avatar_path = Path(removed_avatar.image.path)
@@ -85,7 +85,7 @@ def test_marking_user_for_deletion_deactivates_their_account_in_db(user):
 
 
 def test_user_data_downloads_are_removed_by_anonymization(user):
 def test_user_data_downloads_are_removed_by_anonymization(user):
     data_download = request_user_data_download(user)
     data_download = request_user_data_download(user)
-    user.anonymize_data()
+    user.anonymize_data(anonymous_username="Deleted")
 
 
     with pytest.raises(DataDownload.DoesNotExist):
     with pytest.raises(DataDownload.DoesNotExist):
         data_download.refresh_from_db()
         data_download.refresh_from_db()
@@ -93,7 +93,7 @@ def test_user_data_downloads_are_removed_by_anonymization(user):
 
 
 def test_deleting_user_also_deletes_their_data_downloads(user):
 def test_deleting_user_also_deletes_their_data_downloads(user):
     data_download = request_user_data_download(user)
     data_download = request_user_data_download(user)
-    user.delete()
+    user.delete(anonymous_username="Deleted")
 
 
     with pytest.raises(DataDownload.DoesNotExist):
     with pytest.raises(DataDownload.DoesNotExist):
         data_download.refresh_from_db()
         data_download.refresh_from_db()

+ 3 - 4
misago/users/tests/test_user_requestdatadownload_api.py

@@ -1,5 +1,4 @@
-from django.test.utils import override_settings
-
+from ...conf.test import override_dynamic_settings
 from ..datadownloads import request_user_data_download
 from ..datadownloads import request_user_data_download
 from ..test import AuthenticatedUserTestCase
 from ..test import AuthenticatedUserTestCase
 
 
@@ -31,7 +30,7 @@ class UserRequestDataDownload(AuthenticatedUserTestCase):
             {"detail": "You can't request data downloads for other users."},
             {"detail": "You can't request data downloads for other users."},
         )
         )
 
 
-    @override_settings(MISAGO_ENABLE_DOWNLOAD_OWN_DATA=False)
+    @override_dynamic_settings(allow_data_downloads=False)
     def test_request_download_disabled(self):
     def test_request_download_disabled(self):
         """request to api fails if own data downloads are disabled"""
         """request to api fails if own data downloads are disabled"""
         response = self.client.post(self.link)
         response = self.client.post(self.link)
@@ -48,7 +47,7 @@ class UserRequestDataDownload(AuthenticatedUserTestCase):
             response.json(),
             response.json(),
             {
             {
                 "detail": (
                 "detail": (
-                    "You can't have more than one data download request at single time."
+                    "You can't have more than one data download request at a single time."
                 )
                 )
             },
             },
         )
         )

+ 6 - 2
misago/users/tests/test_users_api.py

@@ -2,11 +2,11 @@ import json
 from datetime import timedelta
 from datetime import timedelta
 
 
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.test import override_settings
 from django.urls import reverse
 from django.urls import reverse
 
 
 from ...acl.test import patch_user_acl
 from ...acl.test import patch_user_acl
 from ...categories.models import Category
 from ...categories.models import Category
+from ...conf.test import override_dynamic_settings
 from ...threads.models import Post, Thread
 from ...threads.models import Post, Thread
 from ...threads.test import post_thread
 from ...threads.test import post_thread
 from ..activepostersranking import build_active_posters_ranking
 from ..activepostersranking import build_active_posters_ranking
@@ -463,7 +463,7 @@ class UserDeleteOwnAccountTests(AuthenticatedUserTestCase):
         super().setUp()
         super().setUp()
         self.api_link = "/api/users/%s/delete-own-account/" % self.user.pk
         self.api_link = "/api/users/%s/delete-own-account/" % self.user.pk
 
 
-    @override_settings(MISAGO_ENABLE_DELETE_OWN_ACCOUNT=False)
+    @override_dynamic_settings(allow_delete_own_account=False)
     def test_delete_own_account_feature_disabled(self):
     def test_delete_own_account_feature_disabled(self):
         """
         """
         raises 403 error when attempting to delete own account but feature is disabled
         raises 403 error when attempting to delete own account but feature is disabled
@@ -477,6 +477,7 @@ class UserDeleteOwnAccountTests(AuthenticatedUserTestCase):
         self.assertTrue(self.user.is_active)
         self.assertTrue(self.user.is_active)
         self.assertFalse(self.user.is_deleting_account)
         self.assertFalse(self.user.is_deleting_account)
 
 
+    @override_dynamic_settings(allow_delete_own_account=True)
     def test_delete_own_account_is_staff(self):
     def test_delete_own_account_is_staff(self):
         """raises 403 error when attempting to delete own account as admin"""
         """raises 403 error when attempting to delete own account as admin"""
         self.user.is_staff = True
         self.user.is_staff = True
@@ -497,6 +498,7 @@ class UserDeleteOwnAccountTests(AuthenticatedUserTestCase):
         self.assertTrue(self.user.is_active)
         self.assertTrue(self.user.is_active)
         self.assertFalse(self.user.is_deleting_account)
         self.assertFalse(self.user.is_deleting_account)
 
 
+    @override_dynamic_settings(allow_delete_own_account=True)
     def test_delete_own_account_is_superuser(self):
     def test_delete_own_account_is_superuser(self):
         """raises 403 error when attempting to delete own account as superadmin"""
         """raises 403 error when attempting to delete own account as superadmin"""
         self.user.is_superuser = True
         self.user.is_superuser = True
@@ -517,6 +519,7 @@ class UserDeleteOwnAccountTests(AuthenticatedUserTestCase):
         self.assertTrue(self.user.is_active)
         self.assertTrue(self.user.is_active)
         self.assertFalse(self.user.is_deleting_account)
         self.assertFalse(self.user.is_deleting_account)
 
 
+    @override_dynamic_settings(allow_delete_own_account=True)
     def test_delete_own_account_invalid_password(self):
     def test_delete_own_account_invalid_password(self):
         """
         """
         raises 400 error when attempting to delete own account with invalid password
         raises 400 error when attempting to delete own account with invalid password
@@ -531,6 +534,7 @@ class UserDeleteOwnAccountTests(AuthenticatedUserTestCase):
         self.assertTrue(self.user.is_active)
         self.assertTrue(self.user.is_active)
         self.assertFalse(self.user.is_deleting_account)
         self.assertFalse(self.user.is_deleting_account)
 
 
+    @override_dynamic_settings(allow_delete_own_account=True)
     def test_delete_own_account(self):
     def test_delete_own_account(self):
         """deactivates account and marks it for deletion"""
         """deactivates account and marks it for deletion"""
         response = self.client.post(self.api_link, {"password": self.USER_PASSWORD})
         response = self.client.post(self.api_link, {"password": self.USER_PASSWORD})

+ 22 - 15
misago/users/validators.py

@@ -1,4 +1,5 @@
 import json
 import json
+import logging
 import re
 import re
 
 
 import requests
 import requests
@@ -9,12 +10,15 @@ from django.utils.encoding import force_str
 from django.utils.module_loading import import_string
 from django.utils.module_loading import import_string
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import ngettext
 from django.utils.translation import ngettext
+from requests.exceptions import RequestException
 
 
 from ..conf import settings
 from ..conf import settings
 from .bans import get_email_ban, get_username_ban
 from .bans import get_email_ban, get_username_ban
 
 
 USERNAME_RE = re.compile(r"^[0-9a-z]+$", re.IGNORECASE)
 USERNAME_RE = re.compile(r"^[0-9a-z]+$", re.IGNORECASE)
 
 
+logger = logging.getLogger("misago.users.validators")
+
 User = get_user_model()
 User = get_user_model()
 
 
 
 
@@ -107,26 +111,29 @@ SFS_API_URL = (
 
 
 
 
 def validate_with_sfs(request, cleaned_data, add_error):
 def validate_with_sfs(request, cleaned_data, add_error):
-    if settings.MISAGO_USE_STOP_FORUM_SPAM and cleaned_data.get("email"):
-        _real_validate_with_sfs(request.user_ip, cleaned_data["email"])
-
+    if request.settings.enable_stop_forum_spam and cleaned_data.get("email"):
+        try:
+            _real_validate_with_sfs(
+                request.user_ip,
+                cleaned_data["email"],
+                request.settings.stop_forum_spam_confidence,
+            )
+        except RequestException as error:
+            logger.exception(error, exc_info=error)
 
 
-def _real_validate_with_sfs(ip, email):
-    try:
-        r = requests.get(SFS_API_URL % {"email": email, "ip": ip}, timeout=5)
 
 
-        r.raise_for_status()
+def _real_validate_with_sfs(ip, email, confidence):
+    r = requests.get(SFS_API_URL % {"email": email, "ip": ip}, timeout=5)
+    r.raise_for_status()
 
 
-        api_response = json.loads(force_str(r.content))
-        ip_score = api_response.get("ip", {}).get("confidence", 0)
-        email_score = api_response.get("email", {}).get("confidence", 0)
+    api_response = json.loads(force_str(r.content))
+    ip_score = api_response.get("ip", {}).get("confidence", 0)
+    email_score = api_response.get("email", {}).get("confidence", 0)
 
 
-        api_score = max((ip_score, email_score))
+    api_score = max((ip_score, email_score))
 
 
-        if api_score > settings.MISAGO_STOP_FORUM_SPAM_MIN_CONFIDENCE:
-            raise ValidationError(_("Data entered was found in spammers database."))
-    except requests.exceptions.RequestException:
-        pass  # todo: log those somewhere
+    if api_score > confidence:
+        raise ValidationError(_("Data entered was found in spammers database."))
 
 
 
 
 def validate_gmail_email(request, cleaned_data, add_error):
 def validate_gmail_email(request, cleaned_data, add_error):

+ 1 - 2
misago/users/viewmodels/activeposters.py

@@ -1,4 +1,3 @@
-from ...conf import settings
 from ..activepostersranking import get_active_posters_ranking
 from ..activepostersranking import get_active_posters_ranking
 from ..online.utils import make_users_status_aware
 from ..online.utils import make_users_status_aware
 from ..serializers import UserCardSerializer
 from ..serializers import UserCardSerializer
@@ -10,7 +9,7 @@ class ActivePosters:
         make_users_status_aware(request, ranking["users"], fetch_state=True)
         make_users_status_aware(request, ranking["users"], fetch_state=True)
 
 
         self.count = ranking["users_count"]
         self.count = ranking["users_count"]
-        self.tracked_period = settings.MISAGO_RANKING_LENGTH
+        self.tracked_period = request.settings.top_posters_ranking_length
         self.users = ranking["users"]
         self.users = ranking["users"]
 
 
     def get_frontend_context(self):
     def get_frontend_context(self):

+ 6 - 2
misago/users/viewmodels/followers.py

@@ -1,6 +1,5 @@
 from django.http import Http404
 from django.http import Http404
 
 
-from ...conf import settings
 from ...core.shortcuts import paginate, pagination_dict
 from ...core.shortcuts import paginate, pagination_dict
 from ..online.utils import make_users_status_aware
 from ..online.utils import make_users_status_aware
 from ..serializers import UserCardSerializer
 from ..serializers import UserCardSerializer
@@ -20,7 +19,12 @@ class Followers:
             else:
             else:
                 raise Http404()
                 raise Http404()
 
 
-        list_page = paginate(queryset, page, settings.MISAGO_USERS_PER_PAGE, 4)
+        list_page = paginate(
+            queryset,
+            page,
+            request.settings.users_per_page,
+            request.settings.users_per_page_orphans,
+        )
         make_users_status_aware(request, list_page.object_list)
         make_users_status_aware(request, list_page.object_list)
 
 
         self.users = list_page.object_list
         self.users = list_page.object_list

+ 6 - 2
misago/users/viewmodels/rankusers.py

@@ -1,4 +1,3 @@
-from ...conf import settings
 from ...core.shortcuts import paginate, pagination_dict
 from ...core.shortcuts import paginate, pagination_dict
 from ..online.utils import make_users_status_aware
 from ..online.utils import make_users_status_aware
 from ..serializers import UserCardSerializer
 from ..serializers import UserCardSerializer
@@ -13,7 +12,12 @@ class RankUsers:
         if not request.user.is_staff:
         if not request.user.is_staff:
             queryset = queryset.filter(is_active=True)
             queryset = queryset.filter(is_active=True)
 
 
-        list_page = paginate(queryset, page, settings.MISAGO_USERS_PER_PAGE, 4)
+        list_page = paginate(
+            queryset,
+            page,
+            request.settings.users_per_page,
+            request.settings.users_per_page_orphans,
+        )
         make_users_status_aware(request, list_page.object_list)
         make_users_status_aware(request, list_page.object_list)
 
 
         self.users = list_page.object_list
         self.users = list_page.object_list

+ 1 - 2
misago/users/viewmodels/threads.py

@@ -2,7 +2,6 @@ from django.core.paginator import EmptyPage, InvalidPage
 from django.http import Http404
 from django.http import Http404
 
 
 from ...acl.objectacl import add_acl_to_obj
 from ...acl.objectacl import add_acl_to_obj
-from ...conf import settings
 from ...core.cursorpagination import get_page
 from ...core.cursorpagination import get_page
 from ...core.shortcuts import paginate, pagination_dict
 from ...core.shortcuts import paginate, pagination_dict
 from ...threads.permissions import exclude_invisible_threads
 from ...threads.permissions import exclude_invisible_threads
@@ -28,7 +27,7 @@ class UserThreads:
 
 
         try:
         try:
             list_page = get_page(
             list_page = get_page(
-                posts_queryset, "-id", settings.MISAGO_POSTS_PER_PAGE, start
+                posts_queryset, "-id", request.settings.posts_per_page, start
             )
             )
         except (EmptyPage, InvalidPage):
         except (EmptyPage, InvalidPage):
             raise Http404()
             raise Http404()