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

Add google analytics (#1237)

* Add google analytics settings

* Add google verification page and analytics token
Rafał Pitoń 6 лет назад
Родитель
Сommit
e94b1f8ab7

+ 1 - 0
devproject/settings.py

@@ -178,6 +178,7 @@ INSTALLED_APPS = [
     # Misago apps
     "misago.admin",
     "misago.acl",
+    "misago.analytics",
     "misago.cache",
     "misago.core",
     "misago.conf",

+ 0 - 0
misago/analytics/__init__.py


+ 0 - 0
misago/analytics/tests/__init__.py


+ 15 - 0
misago/analytics/tests/test_google_analytics_html.py

@@ -0,0 +1,15 @@
+from ...conf.test import override_dynamic_settings
+from ...test import assert_contains, assert_not_contains
+
+
+@override_dynamic_settings(google_tracking_id=None)
+def test_tracking_script_is_not_included_if_tracking_id_is_not_set(db, client):
+    response = client.get("/")
+    assert_not_contains(response, "googletagmanager.com/gtag/js")
+
+
+@override_dynamic_settings(google_tracking_id="UA-TEST")
+def test_tracking_script_is_included_if_tracking_id_is_not_set(db, client):
+    response = client.get("/")
+    assert_contains(response, "googletagmanager.com/gtag/js")
+    assert_contains(response, "UA-TEST")

+ 40 - 0
misago/analytics/tests/test_google_verification_page.py

@@ -0,0 +1,40 @@
+from django.urls import reverse
+
+from ...conf.test import override_dynamic_settings
+from ...test import assert_contains
+
+
+@override_dynamic_settings(google_site_verification=None)
+def test_verification_page_returns_404_if_verification_token_is_not_set(db, client):
+    verification_link = reverse(
+        "misago:google-site-verification", kwargs={"token": "t0k3n"}
+    )
+    response = client.get(verification_link)
+    assert response.status_code == 404
+
+
+@override_dynamic_settings(google_site_verification="t0k3n")
+def test_verification_page_returns_404_if_verification_token_is_invalid(db, client):
+    verification_link = reverse(
+        "misago:google-site-verification", kwargs={"token": "inv4l1d"}
+    )
+    response = client.get(verification_link)
+    assert response.status_code == 404
+
+
+@override_dynamic_settings(google_site_verification="t0k3n")
+def test_verification_page_returns_200_if_verification_token_is_correct(db, client):
+    verification_link = reverse(
+        "misago:google-site-verification", kwargs={"token": "t0k3n"}
+    )
+    response = client.get(verification_link)
+    assert response.status_code == 200
+
+
+@override_dynamic_settings(google_site_verification="t0k3n")
+def test_verification_page_contains_token(db, client):
+    verification_link = reverse(
+        "misago:google-site-verification", kwargs={"token": "t0k3n"}
+    )
+    response = client.get(verification_link)
+    assert_contains(response, "googlet0k3n.html")

+ 11 - 0
misago/analytics/urls.py

@@ -0,0 +1,11 @@
+from django.conf.urls import url
+
+from .views import google_site_verification
+
+urlpatterns = [
+    url(
+        r"^google(?P<token>[a-z0-9]+)\.html$",
+        google_site_verification,
+        name="google-site-verification",
+    )
+]

+ 8 - 0
misago/analytics/views.py

@@ -0,0 +1,8 @@
+from django.http import Http404, HttpResponse
+
+
+def google_site_verification(request, token):
+    if token != request.settings.google_site_verification:
+        raise Http404()
+
+    return HttpResponse("google-site-verification: google%s.html" % token)

+ 15 - 1
misago/conf/admin/__init__.py

@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
 
 from .views import index
 from .views import (
+    ChangeAnalyticsSettingsView,
     ChangeCaptchaSettingsView,
     ChangeGeneralSettingsView,
     ChangeThreadsSettingsView,
@@ -17,6 +18,12 @@ class MisagoAdminExtension:
         urlpatterns.patterns("settings", url(r"^$", index, name="index"))
 
         urlpatterns.single_pattern(
+            r"^analytics/",
+            "analytics",
+            "settings",
+            ChangeAnalyticsSettingsView.as_view(),
+        )
+        urlpatterns.single_pattern(
             r"^captcha/", "captcha", "settings", ChangeCaptchaSettingsView.as_view()
         )
         urlpatterns.single_pattern(
@@ -62,9 +69,16 @@ class MisagoAdminExtension:
             after="users:index",
         )
         site.add_node(
+            name=_("Analytics"),
+            description=_("Enable Google Analytics or setup Google Site Verification."),
+            parent="settings",
+            namespace="analytics",
+            after="captcha:index",
+        )
+        site.add_node(
             name=_("Threads"),
             description=_("Those settings control threads and posts."),
             parent="settings",
             namespace="threads",
-            after="captcha:index",
+            after="analytics:index",
         )

+ 0 - 329
misago/conf/admin/forms.py

@@ -1,329 +0,0 @@
-from django import forms
-from django.utils.translation import gettext_lazy as _
-
-from ...admin.forms import YesNoSwitch
-from ..cache import clear_settings_cache
-
-
-class ChangeSettingsForm(forms.Form):
-    settings = []
-
-    def __init__(self, *args, **kwargs):
-        self.request = kwargs.pop("request")
-        super().__init__(*args, **kwargs)
-
-    def save(self, settings):
-        self.save_settings(settings)
-        self.clear_cache()
-
-    def save_settings(self, settings):
-        for setting in self.settings:
-            setting_obj = settings[setting]
-            new_value = self.cleaned_data.get(setting)
-            if setting_obj.python_type == "image":
-                if new_value and new_value != self.initial.get(setting):
-                    self.save_image(setting_obj, new_value)
-                elif self.cleaned_data.get("%s_delete" % setting):
-                    self.delete_image(setting_obj)
-            else:
-                self.save_setting(setting_obj, new_value)
-
-    def delete_image(self, setting):
-        if setting.image:
-            setting.image.delete()
-
-    def save_image(self, setting, value):
-        if setting.image:
-            setting.image.delete(save=False)
-        setting.value = value
-        setting.save()
-
-    def save_setting(self, setting, value):
-        setting.value = value
-        setting.save()
-
-    def clear_cache(self):
-        clear_settings_cache()
-
-
-class ChangeCaptchaSettingsForm(ChangeSettingsForm):
-    settings = [
-        "captcha_type",
-        "recaptcha_site_key",
-        "recaptcha_secret_key",
-        "qa_question",
-        "qa_help_text",
-        "qa_answers",
-    ]
-
-    captcha_type = forms.ChoiceField(
-        label=_("Enable CAPTCHA"),
-        choices=[
-            ("no", _("No CAPTCHA")),
-            ("re", _("reCaptcha")),
-            ("qa", _("Question and answer")),
-        ],
-        widget=forms.RadioSelect(),
-    )
-    recaptcha_site_key = forms.CharField(
-        label=_("Site key"), max_length=100, required=False
-    )
-    recaptcha_secret_key = forms.CharField(
-        label=_("Secret key"), max_length=100, required=False
-    )
-    qa_question = forms.CharField(
-        label=_("Test question"), max_length=100, required=False
-    )
-    qa_help_text = forms.CharField(
-        label=_("Question help text"), max_length=250, required=False
-    )
-    qa_answers = forms.CharField(
-        label=_("Valid answers"),
-        help_text=_("Enter each answer in new line. Answers are case-insensitive."),
-        widget=forms.Textarea({"rows": 4}),
-        max_length=250,
-        required=False,
-    )
-
-    def clean(self):
-        cleaned_data = super().clean()
-
-        if cleaned_data.get("captcha_type") == "re":
-            if not cleaned_data.get("recaptcha_site_key"):
-                self.add_error(
-                    "recaptcha_site_key",
-                    _(
-                        "You need to enter site key if "
-                        "selected CAPTCHA type is reCaptcha."
-                    ),
-                )
-
-            if not cleaned_data.get("recaptcha_secret_key"):
-                self.add_error(
-                    "recaptcha_secret_key",
-                    _(
-                        "You need to enter secret key if "
-                        "selected CAPTCHA type is reCaptcha."
-                    ),
-                )
-
-        if cleaned_data.get("captcha_type") == "qa":
-            if not cleaned_data.get("qa_question"):
-                self.add_error(
-                    "qa_question",
-                    _("You need to set question if selected CAPTCHA type is Q&A."),
-                )
-
-            if not cleaned_data.get("qa_answers"):
-                self.add_error(
-                    "qa_answers",
-                    _(
-                        "You need to set question answers if "
-                        "selected CAPTCHA type is Q&A."
-                    ),
-                )
-
-        return cleaned_data
-
-
-class ChangeGeneralSettingsForm(ChangeSettingsForm):
-    settings = [
-        "forum_name",
-        "forum_address",
-        "index_header",
-        "index_title",
-        "index_meta_description",
-        "logo",
-        "logo_small",
-        "logo_text",
-        "forum_footnote",
-        "email_footer",
-    ]
-
-    forum_name = forms.CharField(label=_("Forum name"), min_length=2, max_length=255)
-    forum_address = forms.URLField(label=_("Forum address"), max_length=255)
-
-    index_header = forms.CharField(
-        label=_("Header text"),
-        help_text=_("This text will replace forum name in page header."),
-        max_length=255,
-        required=False,
-    )
-    index_title = forms.CharField(label=_("Page title"), max_length=255, required=False)
-    index_meta_description = forms.CharField(
-        label=_("Meta Description"),
-        help_text=_(
-            "Short description of your forum that search and social sites may "
-            "display next to link to your forum's index."
-        ),
-        max_length=255,
-        required=False,
-    )
-
-    logo = forms.ImageField(
-        label=_("Logo"),
-        help_text=_("Image that will displayed in forum navbar."),
-        required=False,
-    )
-    logo_delete = forms.BooleanField(label=_("Delete current logo"), required=False)
-    logo_small = forms.ImageField(
-        label=_("Small logo"),
-        help_text=_(
-            "Image that will be displayed in compact forum navbar. "
-            "When set, it will replace icon pointing to forum index."
-        ),
-        required=False,
-    )
-    logo_small_delete = forms.BooleanField(
-        label=_("Delete current small logo"), required=False
-    )
-    logo_text = forms.CharField(
-        label=_("Text"),
-        help_text=_(
-            "Text displayed in forum navbar. If logo image was uploaded, text will "
-            "be displayed right next to it. Never displayed by the compact navbar."
-        ),
-        max_length=255,
-        required=False,
-    )
-
-    forum_footnote = forms.CharField(
-        label=_("Forum footnote"),
-        help_text=_("Short message displayed in forum footer."),
-        max_length=300,
-        required=False,
-    )
-    email_footer = forms.CharField(
-        label=_("E-mails footer"),
-        help_text=_(
-            "Optional short message included at the end of e-mails sent by forum."
-        ),
-        max_length=255,
-        required=False,
-    )
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        address = self.request.build_absolute_uri("/")
-        self["forum_address"].help_text = _(
-            "Misago uses this setting to build links in e-mails sent to site "
-            'users. Address under which site is running appears to be "%(address)s".'
-        ) % {"address": address}
-
-    def clean_forum_address(self):
-        return self.cleaned_data["forum_address"].lower()
-
-
-class ChangeThreadsSettingsForm(ChangeSettingsForm):
-    settings = [
-        "post_length_max",
-        "post_length_min",
-        "thread_title_length_max",
-        "thread_title_length_min",
-    ]
-
-    post_length_max = forms.IntegerField(
-        label=_("Maximum allowed post length"), min_value=0
-    )
-    post_length_min = forms.IntegerField(
-        label=_("Minimum required post length"), min_value=1
-    )
-    thread_title_length_max = forms.IntegerField(
-        label=_("Maximum allowed thread title length"), min_value=2, max_value=255
-    )
-    thread_title_length_min = forms.IntegerField(
-        label=_("Minimum required thread title length"), min_value=2, max_value=255
-    )
-
-
-class ChangeUsersSettingsForm(ChangeSettingsForm):
-    settings = [
-        "account_activation",
-        "allow_custom_avatars",
-        "avatar_upload_limit",
-        "default_avatar",
-        "default_gravatar_fallback",
-        "signature_length_max",
-        "subscribe_reply",
-        "subscribe_start",
-        "username_length_max",
-        "username_length_min",
-    ]
-
-    account_activation = forms.ChoiceField(
-        label=_("Require new accounts activation"),
-        choices=[
-            ("none", _("No activation required")),
-            ("user", _("Activation token sent to user e-mail")),
-            ("admin", _("Activation by administrator")),
-            ("closed", _("Disable new registrations")),
-        ],
-        widget=forms.RadioSelect(),
-    )
-    username_length_min = forms.IntegerField(
-        label=_("Minimum allowed username length"), min_value=2, max_value=20
-    )
-    username_length_max = forms.IntegerField(
-        label=_("Maximum allowed username length"), min_value=2, max_value=20
-    )
-    allow_custom_avatars = YesNoSwitch(
-        label=_("Allow custom avatar uploads"),
-        help_text=_(
-            "Turning this option off will forbid forum users from uploading custom "
-            "avatars. Good for forums adressed at young users."
-        ),
-    )
-    avatar_upload_limit = forms.IntegerField(
-        label=_("Maximum size of uploaded avatar"),
-        help_text=_("Enter maximum allowed file size (in KB) for avatar uploads."),
-        min_value=0,
-    )
-    default_avatar = forms.ChoiceField(
-        label=_("Default avatar"),
-        choices=[
-            ("dynamic", _("Individual")),
-            ("gravatar", _("Gravatar")),
-            ("gallery", _("Random avatar from gallery")),
-        ],
-        widget=forms.RadioSelect(),
-    )
-    default_gravatar_fallback = forms.ChoiceField(
-        label=_("Fallback for default gravatar"),
-        help_text=_(
-            "Select which avatar to use when user has no gravatar associated with "
-            "their e-mail address."
-        ),
-        choices=[
-            ("dynamic", _("Individual")),
-            ("gallery", _("Random avatar from gallery")),
-        ],
-        widget=forms.RadioSelect(),
-    )
-    signature_length_max = forms.IntegerField(
-        label=_("Maximum allowed signature length"), min_value=10, max_value=5000
-    )
-    subscribe_start = forms.ChoiceField(
-        label=_("Started threads"),
-        choices=[
-            ("no", _("Don't watch")),
-            ("watch", _("Put on watched threads list")),
-            (
-                "watch_email",
-                _("Put on watched threads list and e-mail user when somebody replies"),
-            ),
-        ],
-        widget=forms.RadioSelect(),
-    )
-    subscribe_reply = forms.ChoiceField(
-        label=_("Replied threads"),
-        choices=[
-            ("no", _("Don't watch")),
-            ("watch", _("Put on watched threads list")),
-            (
-                "watch_email",
-                _("Put on watched threads list and e-mail user when somebody replies"),
-            ),
-        ],
-        widget=forms.RadioSelect(),
-    )

+ 6 - 0
misago/conf/admin/forms/__init__.py

@@ -0,0 +1,6 @@
+from .analytics import ChangeAnalyticsSettingsForm
+from .base import ChangeSettingsForm
+from .captcha import ChangeCaptchaSettingsForm
+from .general import ChangeGeneralSettingsForm
+from .threads import ChangeThreadsSettingsForm
+from .users import ChangeUsersSettingsForm

+ 64 - 0
misago/conf/admin/forms/analytics.py

@@ -0,0 +1,64 @@
+import re
+
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from .base import ChangeSettingsForm
+
+GOOGLE_SITE_VERIFICATION = re.compile(
+    "^google-site-verification: google([0-9a-z]+)\.html$"
+)
+
+
+class ChangeAnalyticsSettingsForm(ChangeSettingsForm):
+    settings = ["google_tracking_id", "google_site_verification"]
+
+    google_tracking_id = forms.CharField(
+        label=_("Tracking ID"),
+        help_text=_(
+            "Setting the Tracking ID will result in gtag.js file being included in "
+            "your site's HTML markup, enabling Google Analytics integration."
+        ),
+        required=False,
+    )
+    google_site_verification = forms.CharField(
+        label=_("Site verification token"),
+        help_text=_(
+            "This token was extracted from uploaded site verification file. "
+            "To change it, upload new verification file."
+        ),
+        required=False,
+        disabled=True,
+    )
+    google_site_verification_file = forms.FileField(
+        label=_("Upload site verification file"),
+        help_text=_(
+            "Site verification file can be downloaded from Search Console's "
+            '"Ownership verification" page.'
+        ),
+        required=False,
+    )
+
+    def clean_google_site_verification_file(self):
+        upload = self.cleaned_data.get("google_site_verification_file")
+        if not upload:
+            return None
+
+        if upload.content_type != "text/html":
+            raise forms.ValidationError(_("Uploaded file type is not HTML."))
+
+        file_content = upload.read().decode("utf-8")
+        content_match = GOOGLE_SITE_VERIFICATION.match(file_content)
+        if not content_match:
+            raise forms.ValidationError(
+                _("Uploaded file did not contain a verification code.")
+            )
+
+        return content_match.group(1)
+
+    def clean(self):
+        cleaned_data = super().clean()
+        if cleaned_data.get("google_site_verification_file"):
+            new_verification = cleaned_data.pop("google_site_verification_file")
+            cleaned_data["google_site_verification"] = new_verification
+        return cleaned_data

+ 44 - 0
misago/conf/admin/forms/base.py

@@ -0,0 +1,44 @@
+from django import forms
+
+from ...cache import clear_settings_cache
+
+
+class ChangeSettingsForm(forms.Form):
+    settings = []
+
+    def __init__(self, *args, **kwargs):
+        self.request = kwargs.pop("request")
+        super().__init__(*args, **kwargs)
+
+    def save(self, settings):
+        self.save_settings(settings)
+        self.clear_cache()
+
+    def save_settings(self, settings):
+        for setting in self.settings:
+            setting_obj = settings[setting]
+            new_value = self.cleaned_data.get(setting)
+            if setting_obj.python_type == "image":
+                if new_value and new_value != self.initial.get(setting):
+                    self.save_image(setting_obj, new_value)
+                elif self.cleaned_data.get("%s_delete" % setting):
+                    self.delete_image(setting_obj)
+            else:
+                self.save_setting(setting_obj, new_value)
+
+    def delete_image(self, setting):
+        if setting.image:
+            setting.image.delete()
+
+    def save_image(self, setting, value):
+        if setting.image:
+            setting.image.delete(save=False)
+        setting.value = value
+        setting.save()
+
+    def save_setting(self, setting, value):
+        setting.value = value
+        setting.save()
+
+    def clear_cache(self):
+        clear_settings_cache()

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

@@ -0,0 +1,85 @@
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from ....admin.forms import YesNoSwitch
+from .base import ChangeSettingsForm
+
+
+class ChangeCaptchaSettingsForm(ChangeSettingsForm):
+    settings = [
+        "captcha_type",
+        "recaptcha_site_key",
+        "recaptcha_secret_key",
+        "qa_question",
+        "qa_help_text",
+        "qa_answers",
+    ]
+
+    captcha_type = forms.ChoiceField(
+        label=_("Enable CAPTCHA"),
+        choices=[
+            ("no", _("No CAPTCHA")),
+            ("re", _("reCaptcha")),
+            ("qa", _("Question and answer")),
+        ],
+        widget=forms.RadioSelect(),
+    )
+    recaptcha_site_key = forms.CharField(
+        label=_("Site key"), max_length=100, required=False
+    )
+    recaptcha_secret_key = forms.CharField(
+        label=_("Secret key"), max_length=100, required=False
+    )
+    qa_question = forms.CharField(
+        label=_("Test question"), max_length=100, required=False
+    )
+    qa_help_text = forms.CharField(
+        label=_("Question help text"), max_length=250, required=False
+    )
+    qa_answers = forms.CharField(
+        label=_("Valid answers"),
+        help_text=_("Enter each answer in new line. Answers are case-insensitive."),
+        widget=forms.Textarea({"rows": 4}),
+        max_length=250,
+        required=False,
+    )
+
+    def clean(self):
+        cleaned_data = super().clean()
+
+        if cleaned_data.get("captcha_type") == "re":
+            if not cleaned_data.get("recaptcha_site_key"):
+                self.add_error(
+                    "recaptcha_site_key",
+                    _(
+                        "You need to enter site key if "
+                        "selected CAPTCHA type is reCaptcha."
+                    ),
+                )
+
+            if not cleaned_data.get("recaptcha_secret_key"):
+                self.add_error(
+                    "recaptcha_secret_key",
+                    _(
+                        "You need to enter secret key if "
+                        "selected CAPTCHA type is reCaptcha."
+                    ),
+                )
+
+        if cleaned_data.get("captcha_type") == "qa":
+            if not cleaned_data.get("qa_question"):
+                self.add_error(
+                    "qa_question",
+                    _("You need to set question if selected CAPTCHA type is Q&A."),
+                )
+
+            if not cleaned_data.get("qa_answers"):
+                self.add_error(
+                    "qa_answers",
+                    _(
+                        "You need to set question answers if "
+                        "selected CAPTCHA type is Q&A."
+                    ),
+                )
+
+        return cleaned_data

+ 94 - 0
misago/conf/admin/forms/general.py

@@ -0,0 +1,94 @@
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from ....admin.forms import YesNoSwitch
+from .base import ChangeSettingsForm
+
+
+class ChangeGeneralSettingsForm(ChangeSettingsForm):
+    settings = [
+        "forum_name",
+        "forum_address",
+        "index_header",
+        "index_title",
+        "index_meta_description",
+        "logo",
+        "logo_small",
+        "logo_text",
+        "forum_footnote",
+        "email_footer",
+    ]
+
+    forum_name = forms.CharField(label=_("Forum name"), min_length=2, max_length=255)
+    forum_address = forms.URLField(label=_("Forum address"), max_length=255)
+
+    index_header = forms.CharField(
+        label=_("Header text"),
+        help_text=_("This text will replace forum name in page header."),
+        max_length=255,
+        required=False,
+    )
+    index_title = forms.CharField(label=_("Page title"), max_length=255, required=False)
+    index_meta_description = forms.CharField(
+        label=_("Meta Description"),
+        help_text=_(
+            "Short description of your forum that search and social sites may "
+            "display next to link to your forum's index."
+        ),
+        max_length=255,
+        required=False,
+    )
+
+    logo = forms.ImageField(
+        label=_("Logo"),
+        help_text=_("Image that will displayed in forum navbar."),
+        required=False,
+    )
+    logo_delete = forms.BooleanField(label=_("Delete current logo"), required=False)
+    logo_small = forms.ImageField(
+        label=_("Small logo"),
+        help_text=_(
+            "Image that will be displayed in compact forum navbar. "
+            "When set, it will replace icon pointing to forum index."
+        ),
+        required=False,
+    )
+    logo_small_delete = forms.BooleanField(
+        label=_("Delete current small logo"), required=False
+    )
+    logo_text = forms.CharField(
+        label=_("Text"),
+        help_text=_(
+            "Text displayed in forum navbar. If logo image was uploaded, text will "
+            "be displayed right next to it. Never displayed by the compact navbar."
+        ),
+        max_length=255,
+        required=False,
+    )
+
+    forum_footnote = forms.CharField(
+        label=_("Forum footnote"),
+        help_text=_("Short message displayed in forum footer."),
+        max_length=300,
+        required=False,
+    )
+    email_footer = forms.CharField(
+        label=_("E-mails footer"),
+        help_text=_(
+            "Optional short message included at the end of e-mails sent by forum."
+        ),
+        max_length=255,
+        required=False,
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        address = self.request.build_absolute_uri("/")
+        self["forum_address"].help_text = _(
+            "Misago uses this setting to build links in e-mails sent to site "
+            'users. Address under which site is running appears to be "%(address)s".'
+        ) % {"address": address}
+
+    def clean_forum_address(self):
+        return self.cleaned_data["forum_address"].lower()

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

@@ -0,0 +1,27 @@
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from ....admin.forms import YesNoSwitch
+from .base import ChangeSettingsForm
+
+
+class ChangeThreadsSettingsForm(ChangeSettingsForm):
+    settings = [
+        "post_length_max",
+        "post_length_min",
+        "thread_title_length_max",
+        "thread_title_length_min",
+    ]
+
+    post_length_max = forms.IntegerField(
+        label=_("Maximum allowed post length"), min_value=0
+    )
+    post_length_min = forms.IntegerField(
+        label=_("Minimum required post length"), min_value=1
+    )
+    thread_title_length_max = forms.IntegerField(
+        label=_("Maximum allowed thread title length"), min_value=2, max_value=255
+    )
+    thread_title_length_min = forms.IntegerField(
+        label=_("Minimum required thread title length"), min_value=2, max_value=255
+    )

+ 97 - 0
misago/conf/admin/forms/users.py

@@ -0,0 +1,97 @@
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from ....admin.forms import YesNoSwitch
+from .base import ChangeSettingsForm
+
+
+class ChangeUsersSettingsForm(ChangeSettingsForm):
+    settings = [
+        "account_activation",
+        "allow_custom_avatars",
+        "avatar_upload_limit",
+        "default_avatar",
+        "default_gravatar_fallback",
+        "signature_length_max",
+        "subscribe_reply",
+        "subscribe_start",
+        "username_length_max",
+        "username_length_min",
+    ]
+
+    account_activation = forms.ChoiceField(
+        label=_("Require new accounts activation"),
+        choices=[
+            ("none", _("No activation required")),
+            ("user", _("Activation token sent to user e-mail")),
+            ("admin", _("Activation by administrator")),
+            ("closed", _("Disable new registrations")),
+        ],
+        widget=forms.RadioSelect(),
+    )
+    username_length_min = forms.IntegerField(
+        label=_("Minimum allowed username length"), min_value=2, max_value=20
+    )
+    username_length_max = forms.IntegerField(
+        label=_("Maximum allowed username length"), min_value=2, max_value=20
+    )
+    allow_custom_avatars = YesNoSwitch(
+        label=_("Allow custom avatar uploads"),
+        help_text=_(
+            "Turning this option off will forbid forum users from uploading custom "
+            "avatars. Good for forums adressed at young users."
+        ),
+    )
+    avatar_upload_limit = forms.IntegerField(
+        label=_("Maximum size of uploaded avatar"),
+        help_text=_("Enter maximum allowed file size (in KB) for avatar uploads."),
+        min_value=0,
+    )
+    default_avatar = forms.ChoiceField(
+        label=_("Default avatar"),
+        choices=[
+            ("dynamic", _("Individual")),
+            ("gravatar", _("Gravatar")),
+            ("gallery", _("Random avatar from gallery")),
+        ],
+        widget=forms.RadioSelect(),
+    )
+    default_gravatar_fallback = forms.ChoiceField(
+        label=_("Fallback for default gravatar"),
+        help_text=_(
+            "Select which avatar to use when user has no gravatar associated with "
+            "their e-mail address."
+        ),
+        choices=[
+            ("dynamic", _("Individual")),
+            ("gallery", _("Random avatar from gallery")),
+        ],
+        widget=forms.RadioSelect(),
+    )
+    signature_length_max = forms.IntegerField(
+        label=_("Maximum allowed signature length"), min_value=10, max_value=5000
+    )
+    subscribe_start = forms.ChoiceField(
+        label=_("Started threads"),
+        choices=[
+            ("no", _("Don't watch")),
+            ("watch", _("Put on watched threads list")),
+            (
+                "watch_email",
+                _("Put on watched threads list and e-mail user when somebody replies"),
+            ),
+        ],
+        widget=forms.RadioSelect(),
+    )
+    subscribe_reply = forms.ChoiceField(
+        label=_("Replied threads"),
+        choices=[
+            ("no", _("Don't watch")),
+            ("watch", _("Put on watched threads list")),
+            (
+                "watch_email",
+                _("Put on watched threads list and e-mail user when somebody replies"),
+            ),
+        ],
+        widget=forms.RadioSelect(),
+    )

+ 5 - 0
misago/conf/admin/tests/test_change_settings_view.py → misago/conf/admin/tests/test_change_settings_views.py

@@ -75,6 +75,11 @@ def test_view_invalidates_settings_cache_on_correct_post_request(rf, setting):
         view(rf.post("/", {setting.setting: "New Value"}))
 
 
+def test_analytics_settings_form_renders(admin_client):
+    response = admin_client.get(reverse("misago:admin:settings:analytics:index"))
+    assert response.status_code == 200
+
+
 def test_captcha_settings_form_renders(admin_client):
     response = admin_client.get(reverse("misago:admin:settings:captcha:index"))
     assert response.status_code == 200

+ 62 - 0
misago/conf/admin/tests/test_google_site_verification.py

@@ -0,0 +1,62 @@
+from io import BytesIO
+
+import pytest
+
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.urls import reverse
+
+from ...models import Setting
+
+admin_link = reverse("misago:admin:settings:analytics:index")
+
+
+@pytest.fixture
+def setting(db):
+    return Setting.objects.get(setting="google_site_verification")
+
+
+@pytest.fixture
+def setting_with_value(setting):
+    setting.dry_value = "asdfghjkl1234567"
+    setting.save()
+    return setting
+
+
+def test_site_verification_cant_be_edited_directly(admin_client, setting):
+    admin_client.post(admin_link, {"google_site_verification": "test"})
+    setting.refresh_from_db()
+    assert not setting.value
+
+
+def test_site_verification_is_set_from_uploaded_file(admin_client, setting):
+    verification = b"google-site-verification: googleasdfghjkl1234567.html"
+    verification_file = SimpleUploadedFile("test.html", verification, "text/html")
+    admin_client.post(admin_link, {"google_site_verification_file": verification_file})
+
+    setting.refresh_from_db()
+    assert setting.value == "asdfghjkl1234567"
+
+
+def test_non_html_uploaded_file_is_rejected(admin_client, setting):
+    verification_file = SimpleUploadedFile("test.html", b"test", "text/plain")
+    admin_client.post(admin_link, {"google_site_verification_file": verification_file})
+
+    setting.refresh_from_db()
+    assert not setting.value
+
+
+def test_empty_uploaded_file_is_rejected(admin_client, setting_with_value):
+    verification_file = SimpleUploadedFile("test.html", b"", "text/html")
+    admin_client.post(admin_link, {"google_site_verification_file": verification_file})
+
+    setting_with_value.refresh_from_db()
+    assert setting_with_value.value
+
+
+def test_incorrect_uploaded_file_is_rejected(admin_client, setting_with_value):
+    verification = b"google-site-verification: google.html"
+    verification_file = SimpleUploadedFile("test.html", verification, "text/html")
+    admin_client.post(admin_link, {"google_site_verification_file": verification_file})
+
+    setting_with_value.refresh_from_db()
+    assert setting_with_value.value

+ 6 - 0
misago/conf/admin/views.py

@@ -6,6 +6,7 @@ from ...admin.views import render
 from ...admin.views.generic import AdminView
 from ..models import Setting
 from .forms import (
+    ChangeAnalyticsSettingsForm,
     ChangeCaptchaSettingsForm,
     ChangeGeneralSettingsForm,
     ChangeThreadsSettingsForm,
@@ -59,6 +60,11 @@ class ChangeSettingsView(AdminView):
         return {key: setting.value for key, setting in settings.items()}
 
 
+class ChangeAnalyticsSettingsView(ChangeSettingsView):
+    form_class = ChangeAnalyticsSettingsForm
+    template_name = "misago/admin/conf/analytics_settings.html"
+
+
 class ChangeCaptchaSettingsView(ChangeSettingsView):
     form_class = ChangeCaptchaSettingsForm
     template_name = "misago/admin/conf/captcha_settings.html"

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

@@ -25,6 +25,8 @@ default_settings = [
     },
     {"setting": "forum_footnote", "is_public": True},
     {"setting": "forum_name", "dry_value": "Misago", "is_public": True},
+    {"setting": "google_tracking_id"},
+    {"setting": "google_site_verification"},
     {"setting": "index_header", "is_public": True},
     {"setting": "index_meta_description", "is_public": True},
     {"setting": "index_title", "is_public": True},

+ 18 - 0
misago/templates/misago/admin/conf/analytics_settings.html

@@ -0,0 +1,18 @@
+{% extends "misago/admin/conf/form.html" %}
+{% load i18n misago_admin_form %}
+
+
+{% block form-body %}
+<div class="form-fieldset">
+  <fieldset>
+    <legend>{% trans "Google Analytics and Search Console" %}</legend>
+
+    {% form_row form.google_tracking_id %}
+    {% if form.google_site_verification.value %}
+      {% form_row form.google_site_verification %}
+    {% endif %}
+    {% form_row form.google_site_verification_file %}
+
+  </fieldset>
+</div>
+{% endblock form-body %}

+ 2 - 2
misago/templates/misago/admin/form/input.html

@@ -6,7 +6,7 @@
 {% elif field|is_multiple_choice_field %}
   {% include "misago/admin/form/multiple_choice.html" %}
 {% elif field|is_textarea_field %}
-  <textarea {% render_attrs widget class_name="form-control" %} id="{{ field.id_for_label }}" name="{{ widget.name }}" {% render_bool_attrs widget %}>{% if widget.value is not None %}{{ widget.value }}{% endif %}</textarea>
+  <textarea {% render_attrs widget class_name="form-control" %} id="{{ field.id_for_label }}" name="{{ widget.name }}" {% render_bool_attrs widget %} {% if field.field.disabled %}disabled{% endif %}>{% if widget.value is not None %}{{ widget.value }}{% endif %}</textarea>
 {% else %}
-  <input {% render_attrs widget class_name="form-control" %} id="{{ field.id_for_label }}" name="{{ widget.name }}" type="{{ widget.type }}"{% if widget.value is not None %} value="{{ widget.value }}"{% endif %} {% render_bool_attrs widget %}>
+  <input {% render_attrs widget class_name="form-control" %} id="{{ field.id_for_label }}" name="{{ widget.name }}" type="{{ widget.type }}"{% if widget.value is not None %} value="{{ widget.value }}"{% endif %} {% render_bool_attrs widget %} {% if field.field.disabled %}disabled{% endif %}>
 {% endif %}

+ 8 - 0
misago/templates/misago/analytics.html

@@ -0,0 +1,8 @@
+<!-- Global site tag (gtag.js) - Google Analytics -->
+<script async src="https://www.googletagmanager.com/gtag/js?id={{ settings.google_tracking_id }}"></script>
+<script>
+  window.dataLayer = window.dataLayer || [];
+  function gtag(){dataLayer.push(arguments);}
+  gtag('js', new Date());
+  gtag('config', '{{ settings.google_tracking_id }}');
+</script>

+ 3 - 0
misago/templates/misago/base.html

@@ -22,6 +22,9 @@
     <script type="application/ld+json">{"@context":"http://schema.org","@type":"WebSite","url":"{{ settings.forum_address }}"}</script>
   </head>
   <body {% if misago_agreement %}class="agreement-overlay-visible"{% endif %}>
+    {% if settings.google_tracking_id %}
+      {% include "misago/analytics.html" %}
+    {% endif %}
 
     <div id="auth-message-mount"></div>
     <div id="snackbar-mount"></div>

+ 1 - 0
misago/urls.py

@@ -8,6 +8,7 @@ app_name = "misago"
 
 # Register Misago Apps
 urlpatterns = [
+    url(r"^", include("misago.analytics.urls")),
     url(r"^", include("misago.legal.urls")),
     url(r"^", include("misago.users.urls")),
     url(r"^", include("misago.categories.urls")),