summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGabriel Rodríguez Alberich2022-12-14 18:15:46 +0100
committerGitHub2022-12-14 10:15:46 -0700
commitd1ce056288c97eea18e17f1b950194678618cefc (patch)
treef090c5da442348d7bf35a374cb4efc167e03d311
parent0d8b7db2729d94338824c748901637c625c103b0 (diff)
downloadtakahe-d1ce056288c97eea18e17f1b950194678618cefc.tar.gz
takahe-d1ce056288c97eea18e17f1b950194678618cefc.tar.bz2
takahe-d1ce056288c97eea18e17f1b950194678618cefc.zip
Show follows and following counts on profile page
And let their visibility be configured
-rw-r--r--core/models/config.py1
-rw-r--r--static/css/style.css12
-rw-r--r--templates/identity/view.html9
-rw-r--r--templates/settings/profile.html1
-rw-r--r--tests/users/views/settings/test_privacy.py26
-rw-r--r--users/views/identity.py7
-rw-r--r--users/views/settings/__init__.py3
-rw-r--r--users/views/settings/interface.py111
-rw-r--r--users/views/settings/profile.py11
-rw-r--r--users/views/settings/settings_page.py108
10 files changed, 178 insertions, 111 deletions
diff --git a/core/models/config.py b/core/models/config.py
index 53c729f..2bb0d75 100644
--- a/core/models/config.py
+++ b/core/models/config.py
@@ -237,3 +237,4 @@ class Config(models.Model):
toot_mode: bool = False
default_post_visibility: int = 0 # Post.Visibilities.public
+ visible_follows: bool = True
diff --git a/static/css/style.css b/static/css/style.css
index 80a26ab..166a83f 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -769,6 +769,18 @@ table.metadata td.name {
font-weight: bold;
}
+.stats {
+ margin: 0 0 20px 0;
+}
+
+.stats ul {
+ padding: 0;
+ list-style: none;
+ display: flex;
+ justify-content: start;
+ gap: 1em;
+}
+
/* Timelines */
.left-column .timeline-name {
diff --git a/templates/identity/view.html b/templates/identity/view.html
index 8d5806f..7e2c8d4 100644
--- a/templates/identity/view.html
+++ b/templates/identity/view.html
@@ -72,6 +72,15 @@
</table>
{% endif %}
+ {% if identity.config_identity.visible_follows %}
+ <div class="stats">
+ <ul>
+ <li><strong>{{ following_count }}</strong> following</li>
+ <li><strong>{{ followers_count }}</strong> followers</li>
+ </ul>
+ </div>
+ {% endif %}
+
{% if not identity.local %}
{% if identity.outdated and not identity.name %}
<p class="system-note">
diff --git a/templates/settings/profile.html b/templates/settings/profile.html
index 806a930..3cac026 100644
--- a/templates/settings/profile.html
+++ b/templates/settings/profile.html
@@ -10,6 +10,7 @@
{% include "forms/_field.html" with field=form.name %}
{% include "forms/_field.html" with field=form.summary %}
{% include "forms/_field.html" with field=form.discoverable %}
+ {% include "forms/_field.html" with field=form.visible_follows %}
</fieldset>
<fieldset>
<legend>Images</legend>
diff --git a/tests/users/views/settings/test_privacy.py b/tests/users/views/settings/test_privacy.py
new file mode 100644
index 0000000..b961124
--- /dev/null
+++ b/tests/users/views/settings/test_privacy.py
@@ -0,0 +1,26 @@
+import pytest
+from pytest_django.asserts import assertContains, assertNotContains
+
+from core.models.config import Config
+from users.models import Follow
+
+
+@pytest.mark.django_db
+def test_stats(client, identity, other_identity):
+ """
+ Tests that follow stats are visible
+ """
+ Follow.objects.create(source=other_identity, target=identity)
+ Config.set_identity(identity, "visible_follows", True)
+ response = client.get(identity.urls.view)
+ assertContains(response, "<strong>1</strong> followers", status_code=200)
+
+
+@pytest.mark.django_db
+def test_visible_follows_disabled(client, identity):
+ """
+ Tests that disabling visible follows hides it from profile
+ """
+ Config.set_identity(identity, "visible_follows", False)
+ response = client.get(identity.urls.view)
+ assertNotContains(response, '<div class="stats">', status_code=200)
diff --git a/users/views/identity.py b/users/views/identity.py
index b68806b..268b683 100644
--- a/users/views/identity.py
+++ b/users/views/identity.py
@@ -78,6 +78,13 @@ class ViewIdentity(ListView):
context["page_obj"],
self.request.identity,
)
+ if self.identity.config_identity.visible_follows:
+ context["followers_count"] = self.identity.inbound_follows.filter(
+ state__in=FollowStates.group_active()
+ ).count()
+ context["following_count"] = self.identity.outbound_follows.filter(
+ state__in=FollowStates.group_active()
+ ).count()
if self.request.identity:
follow = Follow.maybe_get(self.request.identity, self.identity)
if follow and follow.state in FollowStates.group_active():
diff --git a/users/views/settings/__init__.py b/users/views/settings/__init__.py
index f7f4018..f3332d3 100644
--- a/users/views/settings/__init__.py
+++ b/users/views/settings/__init__.py
@@ -2,9 +2,10 @@ from django.utils.decorators import method_decorator
from django.views.generic import RedirectView
from users.decorators import identity_required
-from users.views.settings.interface import InterfacePage, SettingsPage # noqa
+from users.views.settings.interface import InterfacePage # noqa
from users.views.settings.profile import ProfilePage # noqa
from users.views.settings.security import SecurityPage # noqa
+from users.views.settings.settings_page import SettingsPage # noqa
@method_decorator(identity_required, name="dispatch")
diff --git a/users/views/settings/interface.py b/users/views/settings/interface.py
index e8c73a6..c8863b2 100644
--- a/users/views/settings/interface.py
+++ b/users/views/settings/interface.py
@@ -1,114 +1,5 @@
-from functools import partial
-from typing import ClassVar
-
-from django import forms
-from django.core.files import File
-from django.shortcuts import redirect
-from django.utils.decorators import method_decorator
-from django.views.generic import FormView
-
-from core.models.config import Config, UploadedImage
-from users.decorators import identity_required
-
-
-@method_decorator(identity_required, name="dispatch")
-class SettingsPage(FormView):
- """
- Shows a settings page dynamically created from our settings layout
- at the bottom of the page. Don't add this to a URL directly - subclass!
- """
-
- options_class = Config.IdentityOptions
- template_name = "settings/settings.html"
- section: ClassVar[str]
- options: dict[str, dict[str, str | int]]
- layout: dict[str, list[str]]
-
- def get_form_class(self):
- # Create the fields dict from the config object
- fields = {}
- for key, details in self.options.items():
- field_kwargs = {}
- config_field = self.options_class.__fields__[key]
- if config_field.type_ is bool:
- form_field = partial(
- forms.BooleanField,
- widget=forms.Select(
- choices=[(True, "Enabled"), (False, "Disabled")]
- ),
- )
- elif config_field.type_ is UploadedImage:
- form_field = forms.ImageField
- elif config_field.type_ is str:
- if details.get("display") == "textarea":
- form_field = partial(
- forms.CharField,
- widget=forms.Textarea,
- )
- else:
- form_field = forms.CharField
- elif config_field.type_ is int:
- choices = details.get("choices")
- if choices:
- field_kwargs["widget"] = forms.Select(choices=choices)
- for int_kwarg in {"min_value", "max_value", "step_size"}:
- val = details.get(int_kwarg)
- if val:
- field_kwargs[int_kwarg] = val
- form_field = forms.IntegerField
- else:
- raise ValueError(f"Cannot render settings type {config_field.type_}")
- fields[key] = form_field(
- label=details["title"],
- help_text=details.get("help_text", ""),
- required=details.get("required", False),
- **field_kwargs,
- )
- # Create a form class dynamically (yeah, right?) and return that
- return type("SettingsForm", (forms.Form,), fields)
-
- def load_config(self):
- return Config.load_identity(self.request.identity)
-
- def save_config(self, key, value):
- Config.set_identity(self.request.identity, key, value)
-
- def get_initial(self):
- config = self.load_config()
- initial = {}
- for key in self.options.keys():
- initial[key] = getattr(config, key)
- return initial
-
- def get_context_data(self):
- context = super().get_context_data()
- context["section"] = self.section
- # Gather fields into fieldsets
- context["fieldsets"] = {}
- for title, fields in self.layout.items():
- context["fieldsets"][title] = [context["form"][field] for field in fields]
- return context
-
- def form_valid(self, form):
- # Save each key
- for field in form:
- if field.field.__class__.__name__ == "ImageField":
- # These can be cleared with an extra checkbox
- if self.request.POST.get(f"{field.name}__clear"):
- self.save_config(field.name, None)
- continue
- # We shove the preview values in initial_data, so only save file
- # fields if they have a File object.
- if not isinstance(form.cleaned_data[field.name], File):
- continue
- self.save_config(
- field.name,
- form.cleaned_data[field.name],
- )
- return redirect(".")
-
-
from activities.models.post import Post
+from users.views.settings.settings_page import SettingsPage
class InterfacePage(SettingsPage):
diff --git a/users/views/settings/profile.py b/users/views/settings/profile.py
index 518b916..f01fec4 100644
--- a/users/views/settings/profile.py
+++ b/users/views/settings/profile.py
@@ -5,6 +5,7 @@ from django.utils.decorators import method_decorator
from django.views.generic import FormView
from core.files import resize_image
+from core.models.config import Config
from users.decorators import identity_required
@@ -38,6 +39,11 @@ class ProfilePage(FormView):
),
required=False,
)
+ visible_follows = forms.BooleanField(
+ help_text="Whether or not to show your following and follower counts in your profile.",
+ widget=forms.Select(choices=[(True, "Visible"), (False, "Hidden")]),
+ required=False,
+ )
def get_initial(self):
identity = self.request.identity
@@ -47,6 +53,7 @@ class ProfilePage(FormView):
"icon": identity.icon and identity.icon.url,
"image": identity.image and identity.image.url,
"discoverable": identity.discoverable,
+ "visible_follows": identity.config_identity.visible_follows,
}
def form_valid(self, form):
@@ -69,4 +76,8 @@ class ProfilePage(FormView):
resize_image(image, size=(1500, 500)),
)
identity.save()
+ # Save profile-specific identity Config
+ Config.set_identity(
+ identity, "visible_follows", form.cleaned_data["visible_follows"]
+ )
return redirect(".")
diff --git a/users/views/settings/settings_page.py b/users/views/settings/settings_page.py
new file mode 100644
index 0000000..a92d507
--- /dev/null
+++ b/users/views/settings/settings_page.py
@@ -0,0 +1,108 @@
+from functools import partial
+from typing import ClassVar
+
+from django import forms
+from django.core.files import File
+from django.shortcuts import redirect
+from django.utils.decorators import method_decorator
+from django.views.generic import FormView
+
+from core.models.config import Config, UploadedImage
+from users.decorators import identity_required
+
+
+@method_decorator(identity_required, name="dispatch")
+class SettingsPage(FormView):
+ """
+ Shows a settings page dynamically created from our settings layout
+ at the bottom of the page. Don't add this to a URL directly - subclass!
+ """
+
+ options_class = Config.IdentityOptions
+ template_name = "settings/settings.html"
+ section: ClassVar[str]
+ options: dict[str, dict[str, str | int]]
+ layout: dict[str, list[str]]
+
+ def get_form_class(self):
+ # Create the fields dict from the config object
+ fields = {}
+ for key, details in self.options.items():
+ field_kwargs = {}
+ config_field = self.options_class.__fields__[key]
+ if config_field.type_ is bool:
+ form_field = partial(
+ forms.BooleanField,
+ widget=forms.Select(
+ choices=[(True, "Enabled"), (False, "Disabled")]
+ ),
+ )
+ elif config_field.type_ is UploadedImage:
+ form_field = forms.ImageField
+ elif config_field.type_ is str:
+ if details.get("display") == "textarea":
+ form_field = partial(
+ forms.CharField,
+ widget=forms.Textarea,
+ )
+ else:
+ form_field = forms.CharField
+ elif config_field.type_ is int:
+ choices = details.get("choices")
+ if choices:
+ field_kwargs["widget"] = forms.Select(choices=choices)
+ for int_kwarg in {"min_value", "max_value", "step_size"}:
+ val = details.get(int_kwarg)
+ if val:
+ field_kwargs[int_kwarg] = val
+ form_field = forms.IntegerField
+ else:
+ raise ValueError(f"Cannot render settings type {config_field.type_}")
+ fields[key] = form_field(
+ label=details["title"],
+ help_text=details.get("help_text", ""),
+ required=details.get("required", False),
+ **field_kwargs,
+ )
+ # Create a form class dynamically (yeah, right?) and return that
+ return type("SettingsForm", (forms.Form,), fields)
+
+ def load_config(self):
+ return Config.load_identity(self.request.identity)
+
+ def save_config(self, key, value):
+ Config.set_identity(self.request.identity, key, value)
+
+ def get_initial(self):
+ config = self.load_config()
+ initial = {}
+ for key in self.options.keys():
+ initial[key] = getattr(config, key)
+ return initial
+
+ def get_context_data(self):
+ context = super().get_context_data()
+ context["section"] = self.section
+ # Gather fields into fieldsets
+ context["fieldsets"] = {}
+ for title, fields in self.layout.items():
+ context["fieldsets"][title] = [context["form"][field] for field in fields]
+ return context
+
+ def form_valid(self, form):
+ # Save each key
+ for field in form:
+ if field.field.__class__.__name__ == "ImageField":
+ # These can be cleared with an extra checkbox
+ if self.request.POST.get(f"{field.name}__clear"):
+ self.save_config(field.name, None)
+ continue
+ # We shove the preview values in initial_data, so only save file
+ # fields if they have a File object.
+ if not isinstance(form.cleaned_data[field.name], File):
+ continue
+ self.save_config(
+ field.name,
+ form.cleaned_data[field.name],
+ )
+ return redirect(".")