Coverage for website/members/views.py: 55.67%
162 statements
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
1import json
2from datetime import date, datetime
4from django.contrib.auth.decorators import login_required
5from django.contrib.auth.views import RedirectURLMixin
6from django.contrib.messages.views import SuccessMessageMixin
7from django.core.exceptions import PermissionDenied
8from django.db.models import Q, QuerySet
9from django.http import Http404, HttpResponse
10from django.shortcuts import get_object_or_404
11from django.template.response import TemplateResponse
12from django.urls import reverse_lazy
13from django.utils.decorators import method_decorator
14from django.utils.translation import gettext_lazy as _
15from django.views.generic import CreateView, DetailView, UpdateView
16from django.views.generic.base import TemplateResponseMixin, TemplateView, View
18import activemembers.services as activemembers_services
19import events.services as event_services
20import pizzas.services
21import sales.services
22from members import emails, services
23from members.decorators import membership_required
24from members.models import EmailChange, Member, Membership, Profile
25from thaliawebsite.views import PagedView
26from utils.media.services import fetch_thumbnails
27from utils.snippets import datetime_to_lectureyear
29from . import models
30from .forms import ProfileForm
31from .services import member_achievements, member_societies
34@method_decorator(login_required, "dispatch")
35@method_decorator(membership_required, "dispatch")
36class MembersIndex(PagedView):
37 """View that renders the members overview."""
39 model = Member
40 paginate_by = 28
41 template_name = "members/index.html"
42 context_object_name = "members"
43 keywords = None
44 query_filter = ""
45 year_range = []
47 def setup(self, request, *args, **kwargs) -> None:
48 super().setup(request, *args, **kwargs)
49 current_lectureyear = datetime_to_lectureyear(date.today())
50 self.year_range = list(
51 reversed(range(current_lectureyear - 5, current_lectureyear + 1))
52 )
53 self.keywords = request.GET.get("keywords", "").split() or None
54 self.query_filter = kwargs.get("filter", None)
56 def get_queryset(self) -> QuerySet:
57 memberships_query = Q(until__gt=datetime.now()) | Q(until=None)
58 members_query = ~Q(id=None)
60 if self.query_filter and self.query_filter.isdigit(): 60 ↛ 61line 60 didn't jump to line 61 because the condition on line 60 was never true
61 members_query &= Q(profile__starting_year=int(self.query_filter))
62 memberships_query &= Q(type=Membership.MEMBER)
63 elif self.query_filter == "older": 63 ↛ 64line 63 didn't jump to line 64 because the condition on line 63 was never true
64 members_query &= Q(profile__starting_year__lt=self.year_range[-1])
65 memberships_query &= Q(type=Membership.MEMBER)
66 elif self.query_filter == "former":
67 # Filter out all current active memberships
68 memberships_query &= Q(type=Membership.MEMBER) | Q(type=Membership.HONORARY)
69 memberships = Membership.objects.filter(memberships_query)
70 members_query &= ~Q(pk__in=memberships.values("user__pk"))
71 # Members_query contains users that are not currently (honorary)member
72 elif self.query_filter == "benefactors": 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true
73 memberships_query &= Q(type=Membership.BENEFACTOR)
74 elif self.query_filter == "honorary": 74 ↛ 78line 74 didn't jump to line 78 because the condition on line 74 was always true
75 memberships_query = Q(until__gt=datetime.now().date()) | Q(until=None)
76 memberships_query &= Q(type=Membership.HONORARY)
78 if self.keywords: 78 ↛ 79line 78 didn't jump to line 79 because the condition on line 78 was never true
79 for key in self.keywords:
80 # Works because relevant options all have `nick` in their key
81 members_query &= (
82 (
83 Q(profile__nickname__icontains=key)
84 & Q(profile__display_name_preference__contains="nick")
85 )
86 | Q(first_name__icontains=key)
87 | Q(last_name__icontains=key)
88 | Q(username__icontains=key)
89 )
91 if self.query_filter == "former":
92 memberships_query = Q(type=Membership.MEMBER) | Q(type=Membership.HONORARY)
93 memberships = Membership.objects.filter(memberships_query)
94 all_memberships = Membership.objects.all()
95 # Only keep members that were once members, or are legacy users
96 # that do not have any memberships at all
97 members_query &= Q(pk__in=memberships.values("user__pk")) | ~Q(
98 pk__in=all_memberships.values("user__pk")
99 )
100 else:
101 memberships = Membership.objects.filter(memberships_query)
102 members_query &= Q(pk__in=memberships.values("user__pk"))
103 members = (
104 Member.objects.filter(members_query)
105 .order_by("first_name", "last_name", "pk")
106 .select_related("profile")
107 )
108 return members
110 def get_context_data(self, **kwargs) -> dict:
111 context = super().get_context_data(**kwargs)
113 context.update(
114 {
115 "filter": self.query_filter,
116 "year_range": self.year_range,
117 "keywords": self.keywords,
118 }
119 )
121 fetch_thumbnails(
122 [x.profile.photo for x in context["object_list"] if x.profile.photo]
123 )
125 return context
128@method_decorator(login_required, "dispatch")
129class ProfileDetailView(DetailView):
130 """View that renders a member's profile."""
132 context_object_name = "member"
133 model = Member
134 template_name = "members/user/profile.html"
136 def setup(self, request, *args, **kwargs) -> None:
137 if "pk" not in kwargs and request.member:
138 kwargs["pk"] = request.member.pk
139 elif not (request.member and request.member.has_active_membership()):
140 raise PermissionDenied
141 super().setup(request, *args, **kwargs)
143 def get_context_data(self, **kwargs) -> dict:
144 context = super().get_context_data(**kwargs)
145 member = context["member"]
147 achievements = member_achievements(member)
148 societies = member_societies(member)
150 membership = member.current_membership
151 membership_type = _("Unknown membership history")
152 if membership:
153 membership_type = membership.get_type_display()
154 elif member.has_been_honorary_member():
155 membership_type = _("Former honorary member")
156 elif member.has_been_member():
157 membership_type = _("Former member")
158 elif member.latest_membership:
159 membership_type = _("Former benefactor")
161 context.update(
162 {
163 "achievements": achievements,
164 "societies": societies,
165 "membership_type": membership_type,
166 }
167 )
169 return context
172@method_decorator(login_required, "dispatch")
173class UserProfileUpdateView(RedirectURLMixin, SuccessMessageMixin, UpdateView):
174 """View that allows a user to update their profile."""
176 template_name = "members/user/edit_profile.html"
177 model = Profile
178 form_class = ProfileForm
179 next_page = reverse_lazy("members:edit-profile")
180 success_message = _("Your profile has been updated successfully.")
182 def get_object(self, queryset=None) -> Profile:
183 return get_object_or_404(models.Profile, user=self.request.user)
185 def get_form_kwargs(self):
186 kwargs = super().get_form_kwargs()
187 kwargs["require_address"] = bool(self.request.GET.get("require_address", False))
188 return kwargs
191@method_decorator(login_required, "dispatch")
192class StatisticsView(TemplateView):
193 """View that renders the statistics page."""
195 template_name = "members/statistics.html"
197 def get_context_data(self, **kwargs) -> dict:
198 context = super().get_context_data(**kwargs)
199 context.update(
200 {
201 "total_members": models.Member.current_members.count(),
202 "cohort_sizes": json.dumps(services.gen_stats_year()),
203 "member_type_distribution": json.dumps(
204 services.gen_stats_member_type()
205 ),
206 "committee_sizes": json.dumps(
207 activemembers_services.generate_statistics()
208 ),
209 "event_categories": json.dumps(
210 event_services.generate_category_statistics()
211 ),
212 "total_pizza_orders": json.dumps(
213 pizzas.services.gen_stats_pizza_orders()
214 ),
215 "total_sales_orders": json.dumps(
216 sales.services.gen_stats_sales_orders()
217 ),
218 "active_members": json.dumps(services.gen_stats_active_members()),
219 }
220 )
222 return context
225@method_decorator(login_required, name="dispatch")
226class EmailChangeFormView(CreateView):
227 """View that renders the email change form."""
229 model = EmailChange
230 fields = ["email", "member"]
231 template_name = "members/user/email_change.html"
233 def get_initial(self) -> dict:
234 initial = super().get_initial()
235 initial["email"] = self.request.member.email
236 return initial
238 def post(self, request, *args, **kwargs) -> HttpResponse:
239 request.POST = request.POST.dict()
240 request.POST["member"] = request.member.pk
241 return super().post(request, *args, **kwargs)
243 def form_valid(self, form) -> HttpResponse:
244 change_request = form.save()
245 emails.send_email_change_confirmation_messages(change_request)
246 return TemplateResponse(
247 request=self.request, template="members/user/email_change_requested.html"
248 )
251@method_decorator(login_required, name="dispatch")
252class EmailChangeConfirmView(View, TemplateResponseMixin):
253 """View that renders an HTML template and confirms the old email address."""
255 template_name = "members/user/email_change_confirmed.html"
257 def get(self, request, *args, **kwargs) -> HttpResponse:
258 if not EmailChange.objects.filter(confirm_key=kwargs["key"]).exists():
259 raise Http404
261 change_request = EmailChange.objects.get(confirm_key=kwargs["key"])
263 services.confirm_email_change(change_request)
265 return self.render_to_response({})
268@method_decorator(login_required, name="dispatch")
269class EmailChangeVerifyView(View, TemplateResponseMixin):
270 """View that renders an HTML template and verifies the new email address."""
272 template_name = "members/user/email_change_verified.html"
274 def get(self, request, *args, **kwargs) -> HttpResponse:
275 if not EmailChange.objects.filter(verify_key=kwargs["key"]).exists():
276 raise Http404
278 change_request = EmailChange.objects.get(verify_key=kwargs["key"])
280 services.verify_email_change(change_request)
282 return self.render_to_response({})