Coverage for sm/views_group.py: 79%
292 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 19:06 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 19:06 +0000
1from django.views.generic import ListView, FormView, TemplateView
2from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
3from django.contrib.auth.models import User, Group
4from django.db.models import Q
5from .models import GroupProfile, Invitation
6from django.urls import reverse_lazy, reverse
7from django.shortcuts import get_object_or_404, redirect
8from django.contrib import messages
9from django.utils.translation import gettext as _
10from django import forms
11from django.views import View
12from django.core.mail import send_mail
13from django.contrib.auth.password_validation import validate_password
14from django.core.exceptions import ValidationError
15from django.template.loader import render_to_string
16from typing import Any
17from .utils_permissions import (
18 get_group_permissions_for_model,
19 APP_MODELS,
20 MODEL_LABELS,
21)
22from django.utils import timezone
23from django.conf import settings
24from django.http import JsonResponse
27class GroupOwnerRequiredMixin(UserPassesTestMixin):
28 def test_func(self) -> bool:
29 # Superusers can manage all groups
30 if self.request.user.is_superuser:
31 return True
32 # Check if user is owner of at least one group
33 return GroupProfile.objects.filter(owner=self.request.user).exists()
36class GroupMemberListView(LoginRequiredMixin, GroupOwnerRequiredMixin, ListView):
37 template_name = "group/member_list.html"
38 context_object_name = "groups"
40 def get_queryset(self) -> Any:
41 if self.request.user.is_superuser:
42 return Group.objects.all().prefetch_related("user_set", "profile")
43 return (
44 Group.objects.filter(
45 Q(profile__owner=self.request.user) | Q(user=self.request.user)
46 )
47 .prefetch_related("user_set", "profile")
48 .distinct()
49 )
52class AddGroupMemberForm(forms.Form):
53 username = forms.CharField(label=_("Username or Email"))
55 def clean_username(self) -> Any:
56 username = self.cleaned_data["username"]
57 try:
58 if "@" in username:
59 return User.objects.get(email=username)
60 return User.objects.get(username=username)
61 except User.DoesNotExist:
62 raise forms.ValidationError(_("User does not exist."))
65class CreateGroupForm(forms.Form):
66 name = forms.CharField(label=_("Group Name"), max_length=150)
67 max_users = forms.IntegerField(
68 label=_("Max Users"),
69 initial=10,
70 min_value=2,
71 max_value=100,
72 help_text=_("Maximum number of users in this group"),
73 )
76class AddGroupMemberView(LoginRequiredMixin, GroupOwnerRequiredMixin, FormView):
77 form_class = AddGroupMemberForm
78 template_name = "group/add_member.html"
80 def get_success_url(self) -> str:
81 return reverse_lazy("group_member_list")
83 def form_valid(self, form: Any) -> Any:
84 group_id = self.kwargs.get("group_id")
85 # Ensure the user is the owner OR a superuser
86 if self.request.user.is_superuser:
87 group = get_object_or_404(Group, pk=group_id)
88 else:
89 group = get_object_or_404(
90 Group, pk=group_id, profile__owner=self.request.user
91 )
93 user_to_add = form.cleaned_data["username"]
95 if group.user_set.count() >= group.profile.max_users:
96 messages.error(
97 self.request,
98 _("User quota exceeded for this group (%d users).")
99 % group.profile.max_users,
100 )
101 return self.form_invalid(form)
103 group.user_set.add(user_to_add)
104 messages.success(
105 self.request,
106 _("User %s added to group %s.") % (user_to_add.username, group.name),
107 )
108 return super().form_valid(form)
111class RemoveGroupMemberView(LoginRequiredMixin, GroupOwnerRequiredMixin, View):
112 def post(self, request: Any, *args: Any, **kwargs: Any) -> Any:
113 group_id = self.kwargs.get("group_id")
114 user_id = self.kwargs.get("user_id")
116 if self.request.user.is_superuser:
117 group = get_object_or_404(Group, pk=group_id)
118 else:
119 group = get_object_or_404(
120 Group, pk=group_id, profile__owner=self.request.user
121 )
123 user_to_remove = get_object_or_404(User, pk=user_id)
125 if user_to_remove == self.request.user and not self.request.user.is_superuser:
126 messages.error(
127 request, _("You cannot remove yourself from your own group.")
128 )
129 else:
130 group.user_set.remove(user_to_remove)
131 messages.success(
132 request,
133 _("User %s removed from group %s.")
134 % (user_to_remove.username, group.name),
135 )
137 return redirect("group_member_list")
140class GroupPermissionForm(forms.Form):
141 def __init__(self, *args: Any, **kwargs: Any) -> None:
142 self.group = kwargs.pop("group")
143 super().__init__(*args, **kwargs)
145 self.models_to_manage = [
146 (app_label, MODEL_LABELS.get(app_label, app_label))
147 for app_label, _model in APP_MODELS
148 ]
150 current_perms = self.group.permissions.all().values_list("codename", flat=True)
152 for app_label, label in self.models_to_manage:
153 # Find the actual change permission codename which might be 'change_model'
154 # because we named our model 'Model'
155 perms = get_group_permissions_for_model(app_label)
156 change_perm = next(
157 (p for p in perms if p.codename.startswith("change_")), None
158 )
160 initial_val = False
161 if change_perm:
162 initial_val = change_perm.codename in current_perms
164 self.fields[f"edit_{app_label}"] = forms.BooleanField(
165 label=_("Can Edit %s") % label,
166 required=False,
167 initial=initial_val,
168 )
170 def save(self) -> None:
171 for app_label_tuple in self.models_to_manage:
172 app_label = app_label_tuple[0]
173 perms = get_group_permissions_for_model(app_label)
174 # We manage add, change, delete as "Edit"
175 edit_perms = [
176 p
177 for p in perms
178 if p.codename.startswith(("add_", "change_", "delete_"))
179 ]
181 if self.cleaned_data.get(f"edit_{app_label}"):
182 self.group.permissions.add(*edit_perms)
183 else:
184 self.group.permissions.remove(*edit_perms)
187class GroupPermissionUpdateView(LoginRequiredMixin, GroupOwnerRequiredMixin, FormView):
188 template_name = "group/permission_edit.html"
189 form_class = GroupPermissionForm
191 def get_form_kwargs(self) -> Any:
192 kwargs = super().get_form_kwargs()
193 group_id = self.kwargs.get("group_id")
194 if self.request.user.is_superuser:
195 kwargs["group"] = get_object_or_404(Group, pk=group_id)
196 else:
197 kwargs["group"] = get_object_or_404(
198 Group, pk=group_id, profile__owner=self.request.user
199 )
200 return kwargs
202 def get_success_url(self) -> str:
203 return reverse_lazy("group_member_list")
205 def form_valid(self, form: Any) -> Any:
206 form.save()
207 messages.success(self.request, _("Group permissions updated successfully."))
208 return super().form_valid(form)
211class UserPermissionForm(forms.Form):
212 def __init__(self, *args: Any, **kwargs: Any) -> None:
213 self.user = kwargs.pop("user")
214 self.group = kwargs.pop("group")
215 super().__init__(*args, **kwargs)
217 self.models_to_manage = [
218 (app_label, MODEL_LABELS.get(app_label, app_label))
219 for app_label, _model in APP_MODELS
220 ]
222 group_perms = self.group.permissions.all().values_list("codename", flat=True)
223 user_perms = self.user.user_permissions.all().values_list("codename", flat=True)
225 for app_label, label in self.models_to_manage:
226 perms = get_group_permissions_for_model(app_label)
227 change_perm = next(
228 (p for p in perms if p.codename.startswith("change_")), None
229 )
231 initial_val = False
232 if change_perm:
233 initial_val = change_perm.codename in user_perms
235 inherited = change_perm.codename in group_perms if change_perm else False
237 self.fields[f"edit_{app_label}"] = forms.BooleanField(
238 label=_("Can Edit %s") % label,
239 required=False,
240 initial=initial_val,
241 )
242 if inherited:
243 self.fields[f"edit_{app_label}"].help_text = _("(Inherited from group)")
245 def save(self) -> None:
246 for app_label_tuple in self.models_to_manage:
247 app_label = app_label_tuple[0]
248 perms = get_group_permissions_for_model(app_label)
249 edit_perms = [
250 p
251 for p in perms
252 if p.codename.startswith(("add_", "change_", "delete_"))
253 ]
255 if self.cleaned_data.get(f"edit_{app_label}"):
256 self.user.user_permissions.add(*edit_perms)
257 else:
258 self.user.user_permissions.remove(*edit_perms)
261class UserPermissionUpdateView(LoginRequiredMixin, GroupOwnerRequiredMixin, FormView):
262 template_name = "group/user_permission_edit.html"
263 form_class = UserPermissionForm
265 def get_form_kwargs(self) -> Any:
266 kwargs = super().get_form_kwargs()
267 group_id = self.kwargs.get("group_id")
268 user_id = self.kwargs.get("user_id")
269 if self.request.user.is_superuser:
270 group = get_object_or_404(Group, pk=group_id)
271 else:
272 group = get_object_or_404(
273 Group, pk=group_id, profile__owner=self.request.user
274 )
275 # Only group owners may edit the permissions of their own group's
276 # members. Without the membership check an owner could grant/revoke
277 # the (global) user permissions of arbitrary users in other groups.
278 kwargs["group"] = group
279 kwargs["user"] = get_object_or_404(User, pk=user_id, groups=group)
280 return kwargs
282 def get_success_url(self) -> str:
283 return reverse_lazy("group_member_list")
285 def form_valid(self, form: Any) -> Any:
286 form.save()
287 messages.success(self.request, _("User permissions updated successfully."))
288 return super().form_valid(form)
291class InviteGroupMemberForm(forms.Form):
292 email = forms.EmailField(label=_("Email Address"))
294 def clean_email(self) -> Any:
295 email = self.cleaned_data["email"]
296 if User.objects.filter(email__iexact=email).exists():
297 raise forms.ValidationError(_("A user with this email already exists."))
298 return email.lower()
301class InviteGroupMemberView(LoginRequiredMixin, GroupOwnerRequiredMixin, FormView):
302 form_class = InviteGroupMemberForm
303 template_name = "group/invite_member.html"
305 def get_success_url(self) -> str:
306 return reverse_lazy("group_member_list")
308 def get_group(self) -> Group:
309 group_id = self.kwargs.get("group_id")
310 if self.request.user.is_superuser:
311 return get_object_or_404(Group, pk=group_id)
312 return get_object_or_404(Group, pk=group_id, profile__owner=self.request.user)
314 def form_valid(self, form: Any) -> Any:
315 group = self.get_group()
316 email = form.cleaned_data["email"]
318 existing_invitation = Invitation.objects.filter(
319 email__iexact=email, group=group
320 ).first()
321 if existing_invitation and not existing_invitation.is_expired():
322 messages.error(
323 self.request,
324 _("An invitation has already been sent to this email address."),
325 )
326 return self.form_invalid(form)
328 # Replace any expired/used invitation for the same email+group so we
329 # don't trip the (email, group) unique constraint.
330 Invitation.objects.filter(email__iexact=email, group=group).delete()
332 invitation = Invitation.objects.create(
333 email=email,
334 group=group,
335 created_by=self.request.user,
336 )
338 self.send_invitation_email(invitation)
340 messages.success(
341 self.request,
342 _("Invitation sent to %(email)s.") % {"email": email},
343 )
344 return super().form_valid(form)
346 def send_invitation_email(self, invitation: Invitation) -> None:
347 invite_url = self.request.build_absolute_uri(
348 reverse("accept_invitation", kwargs={"token": invitation.token})
349 )
350 subject = _("Invitation to join %(group_name)s on ServerManager") % {
351 "group_name": invitation.group.name
352 }
353 message = render_to_string(
354 "group/email/invite.txt",
355 {
356 "invitation": invitation,
357 "invite_url": invite_url,
358 "owner_name": (
359 invitation.created_by.username
360 if invitation.created_by
361 else "An administrator"
362 ),
363 },
364 )
365 send_mail(
366 subject,
367 message,
368 getattr(settings, "DEFAULT_FROM_EMAIL", "noreply@servermanager"),
369 [invitation.email],
370 fail_silently=False,
371 )
374class AcceptInvitationView(TemplateView):
375 template_name = "group/accept_invitation.html"
377 def get_context_data(self, **kwargs: Any) -> Any:
378 context = super().get_context_data(**kwargs)
379 token = self.kwargs.get("token")
380 invitation = get_object_or_404(Invitation, token=token)
382 if invitation.is_expired():
383 context["error"] = _("This invitation has expired.")
384 return context
386 if invitation.accepted_at:
387 context["error"] = _("This invitation has already been used.")
388 return context
390 context["invitation"] = invitation
391 return context
393 def post(self, request: Any, *args: Any, **kwargs: Any) -> Any:
394 token = self.kwargs.get("token")
395 invitation = get_object_or_404(Invitation, token=token)
397 if invitation.is_expired() or invitation.accepted_at:
398 messages.error(request, _("This invitation is no longer valid."))
399 return redirect("account_login")
401 username = request.POST.get("username")
402 email = request.POST.get("email")
403 password = request.POST.get("password")
404 password_confirm = request.POST.get("password_confirm")
406 if not username or not email or not password:
407 messages.error(request, _("All fields are required."))
408 return self.get(request, *args, **kwargs)
410 if password != password_confirm:
411 messages.error(request, _("Passwords do not match."))
412 return self.get(request, *args, **kwargs)
414 # The invitee must claim the invited account under the invited email,
415 # otherwise anyone with the link could register a new identity.
416 if email.lower() != invitation.email.lower():
417 messages.error(request, _("The email does not match the invitation."))
418 return self.get(request, *args, **kwargs)
420 try:
421 validate_password(password)
422 except ValidationError as exc:
423 messages.error(
424 request, _("Password is not strong enough: %s") % " ".join(exc.messages)
425 )
426 return self.get(request, *args, **kwargs)
428 if User.objects.filter(username=username).exists():
429 messages.error(request, _("Username already exists."))
430 return self.get(request, *args, **kwargs)
432 if User.objects.filter(email__iexact=email).exists():
433 messages.error(request, _("Email already registered."))
434 return self.get(request, *args, **kwargs)
436 user = User.objects.create_user(
437 username=username,
438 email=email,
439 password=password,
440 )
442 invitation.group.user_set.add(user)
443 invitation.accepted_at = timezone.now()
444 invitation.save()
446 messages.success(
447 request,
448 _("You have joined %(group_name)s!")
449 % {"group_name": invitation.group.name},
450 )
452 from django.contrib.auth import login
454 login(request, user, backend="django.contrib.auth.backends.ModelBackend")
456 return redirect("dashboard")
459class GroupFilterView(View):
460 def post(self, request: Any, *args: Any, **kwargs: Any) -> Any:
461 if not request.user.is_authenticated:
462 return JsonResponse({"error": "Unauthorized"}, status=401)
464 # Support both the checkbox format ("1,2,3" in `groups`) and the
465 # single-select format (a `group` field) used by the search filter.
466 groups_param = request.POST.get("groups", "")
467 single = request.POST.get("group", "")
468 if groups_param:
469 selected_groups = [g for g in groups_param.split(",") if g]
470 elif single:
471 selected_groups = [single]
472 else:
473 selected_groups = []
475 if selected_groups:
476 request.session["selected_groups"] = selected_groups
477 else:
478 request.session["selected_groups"] = []
480 request.session.modified = True
482 # If a `next` URL was provided, redirect there so the filter applies
483 # to the referring page (e.g. search results).
484 next_url = request.POST.get("next") or request.GET.get("next")
485 if next_url:
486 from django.shortcuts import redirect
488 return redirect(next_url)
490 return JsonResponse({"status": "success"})
493class GroupCreateView(LoginRequiredMixin, FormView):
494 form_class = CreateGroupForm
495 template_name = "group/member_list.html"
496 success_url = reverse_lazy("group_member_list")
498 def form_valid(self, form: Any) -> Any:
499 name = form.cleaned_data["name"]
500 max_users = form.cleaned_data.get("max_users", 10)
502 max_groups = 5
503 owned_groups_count = self.request.user.owned_groups.count()
504 if owned_groups_count >= max_groups:
505 messages.error(
506 self.request,
507 _("You can only create up to %d groups.") % max_groups,
508 )
509 return redirect("group_member_list")
511 existing = Group.objects.filter(name=name).first()
512 if existing:
513 messages.error(self.request, _("A group with this name already exists."))
514 return redirect("group_member_list")
516 group = Group.objects.create(name=name)
517 # Profile is already created by create_group_profile signal
518 profile = group.profile
519 profile.owner = self.request.user
520 profile.max_users = max_users
521 profile.save()
523 self.request.user.groups.add(group)
525 from .utils_permissions import sync_group_permissions
527 sync_group_permissions(group, grant_all=True)
529 messages.success(self.request, _("Group '%s' created successfully.") % name)
530 return super().form_valid(form)
532 def form_invalid(self, form: Any) -> Any:
533 messages.error(self.request, _("Failed to create group."))
534 return redirect("group_member_list")