import logging
from django.views import View
from django.views.generic import TemplateView
from django.views.generic.edit import FormView, DeleteView
from django.views.generic.edit import UpdateView, CreateView
from django.conf import settings
from django.contrib.auth.models import User, Group
from django.contrib.auth.mixins import LoginRequiredMixin
from django_comments.models import Comment
from django.views.decorators.http import require_GET
from django.urls import reverse_lazy
from django.contrib import messages
from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.template.loader import render_to_string
from django.contrib.auth import update_session_auth_hash
from rest_framework.authtoken.models import Token
from tom_common.forms import ChangeUserPasswordForm, CustomUserCreationForm, GroupForm
from tom_common.mixins import SuperuserRequiredMixin
logger = logging.getLogger(__name__)
[docs]
class GroupCreateView(SuperuserRequiredMixin, CreateView):
"""
View that handles creation of a user ``Group``. Requires authorization.
"""
form_class = GroupForm
model = Group
success_url = reverse_lazy('user-list')
[docs]
class GroupDeleteView(SuperuserRequiredMixin, DeleteView):
"""
View that handles deletion of a user ``Group``. Requires authorization.
"""
model = Group
success_url = reverse_lazy('user-list')
[docs]
class GroupUpdateView(SuperuserRequiredMixin, UpdateView):
"""
View that handles modification of a user ``Group``. Requires authorization.
"""
form_class = GroupForm
model = Group
success_url = reverse_lazy('user-list')
[docs]
def get_initial(self, *args, **kwargs):
"""
Adds the ``User`` objects that are associated with this ``Group`` to the initial data.
:returns: list of users
:rtype: QuerySet
"""
initial = super().get_initial(*args, **kwargs)
initial['users'] = self.get_object().user_set.all()
return initial
[docs]
class UserListView(LoginRequiredMixin, TemplateView):
"""
View that handles display of the list of ``User`` and ``Group`` objects. Requires authentication.
"""
template_name = 'auth/user_list.html'
[docs]
class UserDeleteView(LoginRequiredMixin, DeleteView):
"""
View that handles deletion of a ``User``. Requires login.
"""
success_url = reverse_lazy('user-list')
model = User
[docs]
def dispatch(self, *args, **kwargs):
"""
Directs the class-based view to the correct method for the HTTP request method. Ensures that non-superusers
are not incorrectly updating the profiles of other users.
"""
if not self.request.user.is_superuser and self.request.user.id != self.kwargs['pk']:
return redirect('user-delete', self.request.user.id)
else:
return super().dispatch(*args, **kwargs)
[docs]
class RegenerateAPITokenView(LoginRequiredMixin, View):
"""View that handles regeneration of a User's DRF API token. Requires login.
Deletes the existing token (if any) and creates a new one. For HTMX requests,
returns the api_token partial with the new token. For non-HTMX requests,
redirects to the user update page with a success message.
"""
# this is the partial template to render the API token
partial_template_name = 'tom_common/partials/api_token.html'
[docs]
def dispatch(self, *args, **kwargs):
"""Ensure non-superusers can only regenerate their own token.
Checks authentication first (via LoginRequiredMixin), then checks
that non-superusers are only operating on their own token.
"""
# the User must be authenticated
if not self.request.user.is_authenticated:
return self.handle_no_permission()
# don't let a non-super-user regenerate someone else's API Token,
# instead, redirect them to their own user-update view.
if not self.request.user.is_superuser and self.request.user.id != int(self.kwargs['pk']):
return redirect('user-update', pk=self.request.user.id)
return super().dispatch(*args, **kwargs)
def post(self, request, pk: int) -> HttpResponse:
target_user = get_object_or_404(User, pk=pk)
# Delete existing token (safe even if none exists) and create a new one
Token.objects.filter(user=target_user).delete()
new_token = Token.objects.create(user=target_user)
# handle HTMX requests here
if request.htmx:
# Return just the partial for in-place replacement (avoid full page reload)
html = render_to_string(
self.partial_template_name,
{'drf_api_token': new_token, 'user_pk': target_user.pk},
request=request,
)
return HttpResponse(html)
# Non-HTMX fallback: redirect with a success message
messages.success(request, 'API token regenerated.')
return redirect('user-update', pk=target_user.pk)
[docs]
class UserProfileView(LoginRequiredMixin, TemplateView):
"""
View to handle creating a user profile page. Requires a login.
Note: This is NOT a User Detail view that would require a primary Key tying it to a specific user.
This is a profile page that always displays the information for the logged in user.
A User Detail view would allow admin users to view the profile of any user which is not what we want here for
security reasons.
"""
template_name = 'tom_common/user_profile.html'
[docs]
class UserPasswordChangeView(SuperuserRequiredMixin, FormView):
"""
View that handles modification of the password for a ``User``. Requires authorization.
"""
template_name = 'tom_common/change_user_password.html' # The form template
confirmation_template_name = 'auth/user_confirm_change_password.html'
success_url = reverse_lazy('user-list')
form_class = ChangeUserPasswordForm
[docs]
def get_context_data(self, **kwargs):
"""Add the user object to the context for all templates."""
context = super().get_context_data(**kwargs)
if 'object' not in context:
context['object'] = User.objects.get(pk=self.kwargs['pk'])
return context
[docs]
def get(self, request, *args, **kwargs):
"""
On a GET request, show a confirmation page before allowing the password change.
This follows the pattern of Django's DeleteView, but bypasses the confirmation
if a superuser is changing their own password.
"""
user_to_change: User = User.objects.get(pk=self.kwargs['pk'])
# If the logged-in superuser is changing their own password, bypass the
# confirmation page and show the password change form directly.
if self.request.user == user_to_change:
# This renders the password change form directly.
form = self.get_form() # get_form() will return an unbound form instance.
context_data = self.get_context_data(form=form)
return self.render_to_response(context_data)
# For any other case (admin changing another user's password), show the confirmation page first.
# The render_to_response method from TemplateResponseMixin doesn't accept
# a template_name argument. We need to render the confirmation template
# without instantiating a form, which get_context_data() would do.
context = super(FormView, self).get_context_data(**kwargs)
context['object'] = user_to_change
return self.response_class(
request=self.request,
template=[self.confirmation_template_name],
context=context,
)
[docs]
def post(self, request, *args, **kwargs):
"""
A POST can come from the confirmation page (to show the form) or from the
password change form itself (to perform the change).
"""
# If the post is from the confirmation page, show the password change form.
if 'change_password_form' not in self.request.POST:
form = self.get_form_class()() # Get an unbound form
return self.render_to_response(self.get_context_data(form=form))
# Otherwise, process the form using the parent class's post method.
return super().post(request, *args, **kwargs)
[docs]
class UserCreateView(SuperuserRequiredMixin, CreateView):
"""
View that handles ``User`` creation. Requires authorization.
"""
template_name = 'tom_common/create_user.html'
success_url = reverse_lazy('user-list')
form_class = CustomUserCreationForm
[docs]
class UserUpdateView(LoginRequiredMixin, UpdateView):
"""
View that handles ``User`` modification. Requires authentication to call, and authorization to update.
"""
model = User
template_name = 'tom_common/create_user.html'
form_class = CustomUserCreationForm
[docs]
def get_success_url(self):
"""
Returns the redirect URL for a successful update. If the current user is a superuser, returns the URL for the
user list. Otherwise, returns the URL for updating the current user.
:returns: URL for user list or update user
:rtype: str
"""
if self.request.user.is_superuser:
return reverse_lazy('user-list')
else:
return reverse_lazy('user-update', kwargs={'pk': self.request.user.id})
[docs]
def get_context_data(self, **kwargs):
"""Add current user and API token to the context for all templates."""
context = super().get_context_data(**kwargs)
# this is the User doing the updating. (could be super-user)
context['current_user'] = self.request.user
# this is the User being updated (usually the same as the requesting User,
# but not if a super-user is updating a different User).
user_being_updated = self.object
# add context required to Regenerate the user's DRF API token
context['drf_api_token'] = getattr(user_being_updated, 'auth_token', None)
context['user_pk'] = user_being_updated.pk
return context
[docs]
def dispatch(self, *args, **kwargs):
"""
Directs the class-based view to the correct method for the HTTP request method. Ensures that non-superusers
are not incorrectly updating the profiles of other users.
"""
if not self.request.user.is_superuser and self.request.user.id != int(self.kwargs['pk']):
return redirect('user-update', pk=self.request.user.id)
else:
return super().dispatch(*args, **kwargs)
[docs]
@require_GET
def robots_txt(request):
"""A function-based view that handles the robots.txt content.
The default robots.txt is defined here. It disallows everything from everyone.
If you want to change that, we check for a path to a custom robots.txt file defined
in settings.py as `ROBOTS_TXT_PATH`. If you set `ROBOTS_TXT_PATH` in your settings.py,
then that file will be served instead of the default.
"""
# define the default robots.txt content
robots_txt_content = (
'User-Agent: *\n'
'Disallow: /\n'
)
# check for a custom robots.txt file in settings.py
if hasattr(settings, 'ROBOTS_TXT_PATH'):
# if a custom robots.txt file is defined in settings.py, use that instead
try:
with open(settings.ROBOTS_TXT_PATH, 'r') as f:
robots_txt_content = f.read()
except FileNotFoundError as e:
logger.warning(f'Default robots.txt served: settings.ROBOTS_TXT_PATH '
f'is {settings.ROBOTS_TXT_PATH}, but {e}')
return HttpResponse(robots_txt_content, content_type="text/plain")