Coverage for ivatar/ivataraccount/views.py: 76%
605 statements
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-26 00:11 +0000
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-26 00:11 +0000
1# -*- coding: utf-8 -*-
2"""
3View classes for ivatar/ivataraccount/
4"""
5from io import BytesIO
6from urllib.request import urlopen
7import base64
8import binascii
9from xml.sax import saxutils
10import gzip
12from PIL import Image
14from django.db.models import ProtectedError
15from django.core.exceptions import ObjectDoesNotExist
16from django.contrib.auth.decorators import login_required
17from django.contrib.auth.models import User
18from django.utils.decorators import method_decorator
19from django.contrib.messages.views import SuccessMessageMixin
20from django.contrib import messages
21from django.views.generic.edit import FormView, UpdateView
22from django.views.generic.base import View, TemplateView
23from django.views.generic.detail import DetailView
24from django.contrib.auth import authenticate, login
25from django.contrib.auth.forms import UserCreationForm, SetPasswordForm
26from django.contrib.auth.views import LoginView
27from django.contrib.auth.views import (
28 PasswordResetView as PasswordResetViewOriginal,
29)
30from django.utils.translation import gettext_lazy as _
31from django.http import HttpResponseRedirect, HttpResponse
32from django.urls import reverse_lazy, reverse
33from django.shortcuts import render
34from django_openid_auth.models import UserOpenID
36from openid import oidutil
37from openid.consumer import consumer
39from ipware import get_client_ip
41from email_validator import validate_email
43from libravatar import libravatar_url
44from ivatar.settings import (
45 MAX_NUM_PHOTOS,
46 MAX_PHOTO_SIZE,
47 JPEG_QUALITY,
48 AVATAR_MAX_SIZE,
49)
50from .gravatar import get_photo as get_gravatar_photo
52from .forms import AddEmailForm, UploadPhotoForm, AddOpenIDForm
53from .forms import UpdatePreferenceForm, UploadLibravatarExportForm
54from .forms import DeleteAccountForm
55from .models import UnconfirmedEmail, ConfirmedEmail, Photo
56from .models import UnconfirmedOpenId, ConfirmedOpenId, DjangoOpenIDStore
57from .models import UserPreference
58from .models import file_format
59from .read_libravatar_export import read_gzdata as libravatar_read_gzdata
62def openid_logging(message, level=0):
63 """
64 Helper method for openid logging
65 """
66 # Normal messages are not that important
67 # No need for coverage here
68 if level > 0: # pragma: no cover
69 print(message)
72class CreateView(SuccessMessageMixin, FormView):
73 """
74 View class for creating a new user
75 """
77 template_name = "new.html"
78 form_class = UserCreationForm
80 def form_valid(self, form):
81 form.save()
82 user = authenticate(
83 username=form.cleaned_data["username"],
84 password=form.cleaned_data["password1"],
85 )
86 if user is not None:
87 # If the username looks like a mail address, automagically
88 # add it as unconfirmed mail and set it also as user's
89 # email address
90 try:
91 # This will error out if it's not a valid address
92 valid = validate_email(form.cleaned_data["username"])
93 user.email = valid.email
94 user.save()
95 # The following will also error out if it already exists
96 unconfirmed = UnconfirmedEmail()
97 unconfirmed.email = valid.email
98 unconfirmed.user = user
99 unconfirmed.save()
100 unconfirmed.send_confirmation_mail(
101 url=self.request.build_absolute_uri("/")[:-1]
102 )
103 # In any exception cases, we just skip it
104 except Exception: # pylint: disable=broad-except
105 pass
107 login(self.request, user)
108 pref = UserPreference.objects.create(
109 user_id=user.pk
110 ) # pylint: disable=no-member
111 pref.save()
112 return HttpResponseRedirect(reverse_lazy("profile"))
113 return HttpResponseRedirect(reverse_lazy("login")) # pragma: no cover
115 def get(self, request, *args, **kwargs):
116 """
117 Handle get for create view
118 """
119 if request.user:
120 if request.user.is_authenticated:
121 return HttpResponseRedirect(reverse_lazy("profile"))
122 return super().get(self, request, args, kwargs)
125@method_decorator(login_required, name="dispatch")
126class PasswordSetView(SuccessMessageMixin, FormView):
127 """
128 View class for changing the password
129 """
131 template_name = "password_change.html"
132 form_class = SetPasswordForm
133 success_message = _("password changed successfully - please login again")
134 success_url = reverse_lazy("profile")
136 def get_form_kwargs(self):
137 kwargs = super(PasswordSetView, self).get_form_kwargs()
138 kwargs["user"] = self.request.user
139 return kwargs
141 def form_valid(self, form):
142 form.save()
143 super().form_valid(form)
144 return HttpResponseRedirect(reverse_lazy("login"))
147@method_decorator(login_required, name="dispatch")
148class AddEmailView(SuccessMessageMixin, FormView):
149 """
150 View class for adding email addresses
151 """
153 template_name = "add_email.html"
154 form_class = AddEmailForm
155 success_url = reverse_lazy("profile")
157 def form_valid(self, form):
158 if not form.save(self.request):
159 return render(self.request, self.template_name, {"form": form})
161 messages.success(self.request, _("Address added successfully"))
162 return super().form_valid(form)
165@method_decorator(login_required, name="dispatch")
166class RemoveUnconfirmedEmailView(SuccessMessageMixin, View):
167 """
168 View class for removing a unconfirmed email address
169 """
171 @staticmethod
172 def post(request, *args, **kwargs): # pylint: disable=unused-argument
173 """
174 Handle post request - removing unconfirmed email
175 """
176 try:
177 email = UnconfirmedEmail.objects.get( # pylint: disable=no-member
178 user=request.user, id=kwargs["email_id"]
179 )
180 email.delete()
181 messages.success(request, _("Address removed"))
182 except UnconfirmedEmail.DoesNotExist: # pylint: disable=no-member
183 messages.error(request, _("Address does not exist"))
184 return HttpResponseRedirect(reverse_lazy("profile"))
187class ConfirmEmailView(SuccessMessageMixin, TemplateView):
188 """
189 View class for confirming an unconfirmed email address
190 """
192 template_name = "email_confirmed.html"
194 def get(self, request, *args, **kwargs):
195 # be tolerant of extra crap added by mail clients
196 key = kwargs["verification_key"].replace(" ", "")
198 if len(key) != 64:
199 messages.error(request, _("Verification key incorrect"))
200 return HttpResponseRedirect(reverse_lazy("profile"))
202 try:
203 unconfirmed = UnconfirmedEmail.objects.get(
204 verification_key=key
205 ) # pylint: disable=no-member
206 except UnconfirmedEmail.DoesNotExist: # pylint: disable=no-member
207 messages.error(request, _("Verification key does not exist"))
208 return HttpResponseRedirect(reverse_lazy("profile"))
210 if ConfirmedEmail.objects.filter(email=unconfirmed.email).count() > 0:
211 messages.error(
212 request,
213 _("This mail address has been taken already and cannot be confirmed"),
214 )
215 return HttpResponseRedirect(reverse_lazy("profile"))
217 # TODO: Check for a reasonable expiration time in unconfirmed email
219 (confirmed_id, external_photos) = ConfirmedEmail.objects.create_confirmed_email(
220 unconfirmed.user, unconfirmed.email, not request.user.is_anonymous
221 )
223 unconfirmed.delete()
225 # if there's a single image in this user's profile,
226 # assign it to the new email
227 confirmed = ConfirmedEmail.objects.get(id=confirmed_id)
228 if confirmed.user.photo_set.count() == 1:
229 confirmed.set_photo(confirmed.user.photo_set.first())
230 kwargs["photos"] = external_photos
231 kwargs["email_id"] = confirmed_id
232 return super().get(request, *args, **kwargs)
235@method_decorator(login_required, name="dispatch")
236class RemoveConfirmedEmailView(SuccessMessageMixin, View):
237 """
238 View class for removing a confirmed email address
239 """
241 @staticmethod
242 def post(request, *args, **kwargs): # pylint: disable=unused-argument
243 """
244 Handle post request - removing confirmed email
245 """
246 try:
247 email = ConfirmedEmail.objects.get(user=request.user, id=kwargs["email_id"])
248 email.delete()
249 messages.success(request, _("Address removed"))
250 except ConfirmedEmail.DoesNotExist: # pylint: disable=no-member
251 messages.error(request, _("Address does not exist"))
252 return HttpResponseRedirect(reverse_lazy("profile"))
255@method_decorator(login_required, name="dispatch")
256class AssignPhotoEmailView(SuccessMessageMixin, TemplateView):
257 """
258 View class for assigning a photo to an email address
259 """
261 model = Photo
262 template_name = "assign_photo_email.html"
264 def post(self, request, *args, **kwargs): # pylint: disable=unused-argument
265 """
266 Handle post request - assign photo to email
267 """
268 photo = None
270 try:
271 email = ConfirmedEmail.objects.get(user=request.user, id=kwargs["email_id"])
272 except ConfirmedEmail.DoesNotExist: # pylint: disable=no-member
273 messages.error(request, _("Invalid request"))
274 return HttpResponseRedirect(reverse_lazy("profile"))
276 if "photoNone" in request.POST:
277 email.photo = None
278 else:
279 if "photo_id" not in request.POST:
280 messages.error(request, _("Invalid request [photo_id] missing"))
281 return HttpResponseRedirect(reverse_lazy("profile"))
283 try:
284 photo = self.model.objects.get( # pylint: disable=no-member
285 id=request.POST["photo_id"], user=request.user
286 )
287 except self.model.DoesNotExist: # pylint: disable=no-member
288 messages.error(request, _("Photo does not exist"))
289 return HttpResponseRedirect(reverse_lazy("profile"))
290 email.photo = photo
291 email.save()
293 messages.success(request, _("Successfully changed photo"))
294 return HttpResponseRedirect(reverse_lazy("profile"))
296 def get_context_data(self, **kwargs):
297 data = super().get_context_data(**kwargs)
298 data["email"] = ConfirmedEmail.objects.get(pk=kwargs["email_id"])
299 return data
302@method_decorator(login_required, name="dispatch")
303class AssignPhotoOpenIDView(SuccessMessageMixin, TemplateView):
304 """
305 View class for assigning a photo to an openid address
306 """
308 model = Photo
309 template_name = "assign_photo_openid.html"
311 def post(self, request, *args, **kwargs): # pylint: disable=unused-argument
312 """
313 Handle post - assign photo to openid
314 """
315 photo = None
317 try:
318 openid = ConfirmedOpenId.objects.get( # pylint: disable=no-member
319 user=request.user, id=kwargs["openid_id"]
320 )
321 except ConfirmedOpenId.DoesNotExist: # pylint: disable=no-member
322 messages.error(request, _("Invalid request"))
323 return HttpResponseRedirect(reverse_lazy("profile"))
325 if "photoNone" in request.POST:
326 openid.photo = None
327 else:
328 if "photo_id" not in request.POST:
329 messages.error(request, _("Invalid request [photo_id] missing"))
330 return HttpResponseRedirect(reverse_lazy("profile"))
332 try:
333 photo = self.model.objects.get( # pylint: disable=no-member
334 id=request.POST["photo_id"], user=request.user
335 )
336 except self.model.DoesNotExist: # pylint: disable=no-member
337 messages.error(request, _("Photo does not exist"))
338 return HttpResponseRedirect(reverse_lazy("profile"))
339 openid.photo = photo
340 openid.save()
342 messages.success(request, _("Successfully changed photo"))
343 return HttpResponseRedirect(reverse_lazy("profile"))
345 def get_context_data(self, **kwargs):
346 data = super().get_context_data(**kwargs)
347 data["openid"] = ConfirmedOpenId.objects.get(
348 pk=kwargs["openid_id"]
349 ) # pylint: disable=no-member
350 return data
353@method_decorator(login_required, name="dispatch")
354class ImportPhotoView(SuccessMessageMixin, TemplateView):
355 """
356 View class to import a photo from another service
357 Currently only Gravatar is supported
358 """
360 template_name = "import_photo.html"
362 def get_context_data(self, **kwargs):
363 context = super().get_context_data(**kwargs)
364 context["photos"] = []
365 addr = None
366 if "email_id" in kwargs:
367 try:
368 addr = ConfirmedEmail.objects.get(pk=kwargs["email_id"]).email
369 except ConfirmedEmail.ObjectDoesNotExist: # pylint: disable=no-member
370 messages.error(self.request, _("Address does not exist"))
371 return context
373 addr = kwargs.get("email_addr", None)
375 if addr:
376 gravatar = get_gravatar_photo(addr)
377 if gravatar:
378 context["photos"].append(gravatar)
380 libravatar_service_url = libravatar_url(
381 email=addr,
382 default=404,
383 size=AVATAR_MAX_SIZE,
384 )
385 if libravatar_service_url:
386 try:
387 urlopen(libravatar_service_url)
388 except OSError as exc:
389 print("Exception caught during photo import: {}".format(exc))
390 else:
391 context["photos"].append(
392 {
393 "service_url": libravatar_service_url,
394 "thumbnail_url": libravatar_service_url + "&s=80",
395 "image_url": libravatar_service_url + "&s=512",
396 "width": 80,
397 "height": 80,
398 "service_name": "Libravatar",
399 }
400 )
402 return context
404 def post(
405 self, request, *args, **kwargs
406 ): # pylint: disable=no-self-use,unused-argument,too-many-branches,line-too-long
407 """
408 Handle post to photo import
409 """
411 imported = None
413 email_id = kwargs.get("email_id", request.POST.get("email_id", None))
414 addr = kwargs.get("emali_addr", request.POST.get("email_addr", None))
416 if email_id:
417 email = ConfirmedEmail.objects.filter(id=email_id, user=request.user)
418 if email.exists():
419 addr = email.first().email
420 else:
421 messages.error(request, _("Address does not exist"))
422 return HttpResponseRedirect(reverse_lazy("profile"))
424 if "photo_Gravatar" in request.POST:
425 photo = Photo()
426 photo.user = request.user
427 photo.ip_address = get_client_ip(request)[0]
428 if photo.import_image("Gravatar", addr):
429 messages.success(request, _("Gravatar image successfully imported"))
430 else:
431 # Honestly, I'm not sure how to test this...
432 messages.error(
433 request, _("Gravatar image import not successful")
434 ) # pragma: no cover
435 imported = True
437 if "photo_Libravatar" in request.POST:
438 photo = Photo()
439 photo.user = request.user
440 photo.ip_address = get_client_ip(request)[0]
441 if photo.import_image("Libravatar", addr):
442 messages.success(request, _("Libravatar image successfully imported"))
443 else:
444 # Honestly, I'm not sure how to test this...
445 messages.error(
446 request, _("Libravatar image import not successful")
447 ) # pragma: no cover
448 imported = True
449 if not imported:
450 messages.warning(request, _("Nothing importable"))
451 return HttpResponseRedirect(reverse_lazy("profile"))
454@method_decorator(login_required, name="dispatch")
455class RawImageView(DetailView):
456 """
457 View to return (binary) raw image data, for use in <img/>-tags
458 """
460 model = Photo
462 def get(self, request, *args, **kwargs):
463 photo = self.model.objects.get(pk=kwargs["pk"]) # pylint: disable=no-member
464 if not photo.user.id == request.user.id and not request.user.is_staff:
465 return HttpResponseRedirect(reverse_lazy("home"))
466 return HttpResponse(BytesIO(photo.data), content_type="image/%s" % photo.format)
469@method_decorator(login_required, name="dispatch")
470class DeletePhotoView(SuccessMessageMixin, View):
471 """
472 View class for deleting a photo
473 """
475 model = Photo
477 def get(self, request, *args, **kwargs): # pylint: disable=unused-argument
478 """
479 Handle get - delete photo
480 """
481 try:
482 photo = self.model.objects.get( # pylint: disable=no-member
483 pk=kwargs["pk"], user=request.user
484 )
485 photo.delete()
486 except (self.model.DoesNotExist, ProtectedError): # pylint: disable=no-member
487 messages.error(request, _("No such image or no permission to delete it"))
488 return HttpResponseRedirect(reverse_lazy("profile"))
489 messages.success(request, _("Photo deleted successfully"))
490 return HttpResponseRedirect(reverse_lazy("profile"))
493@method_decorator(login_required, name="dispatch")
494class UploadPhotoView(SuccessMessageMixin, FormView):
495 """
496 View class responsible for photo upload
497 """
499 model = Photo
500 template_name = "upload_photo.html"
501 form_class = UploadPhotoForm
502 success_message = _("Successfully uploaded")
503 success_url = reverse_lazy("profile")
505 def post(self, request, *args, **kwargs):
506 num_photos = request.user.photo_set.count()
507 if num_photos >= MAX_NUM_PHOTOS:
508 messages.error(
509 request, _("Maximum number of photos (%i) reached" % MAX_NUM_PHOTOS)
510 )
511 return HttpResponseRedirect(reverse_lazy("profile"))
512 return super().post(request, *args, **kwargs)
514 def form_valid(self, form):
515 photo_data = self.request.FILES["photo"]
516 if photo_data.size > MAX_PHOTO_SIZE:
517 messages.error(self.request, _("Image too big"))
518 return HttpResponseRedirect(reverse_lazy("profile"))
520 photo = form.save(self.request, photo_data)
522 if not photo:
523 messages.error(self.request, _("Invalid Format"))
524 return HttpResponseRedirect(reverse_lazy("profile"))
526 # Override success URL -> Redirect to crop page.
527 self.success_url = reverse_lazy("crop_photo", args=[photo.pk])
528 return super().form_valid(form)
531@method_decorator(login_required, name="dispatch")
532class AddOpenIDView(SuccessMessageMixin, FormView):
533 """
534 View class for adding OpenID
535 """
537 template_name = "add_openid.html"
538 form_class = AddOpenIDForm
539 success_url = reverse_lazy("profile")
541 def form_valid(self, form):
542 openid_id = form.save(self.request.user)
543 if not openid_id:
544 return render(self.request, self.template_name, {"form": form})
546 # At this point we have an unconfirmed OpenID, but
547 # we do not add the message, that we successfully added it,
548 # since this is misleading
549 return HttpResponseRedirect(
550 reverse_lazy("openid_redirection", args=[openid_id])
551 )
554@method_decorator(login_required, name="dispatch")
555class RemoveUnconfirmedOpenIDView(View):
556 """
557 View class for removing a unconfirmed OpenID
558 """
560 model = UnconfirmedOpenId
562 def post(self, request, *args, **kwargs): # pylint: disable=unused-argument
563 """
564 Handle post - remove unconfirmed openid
565 """
566 try:
567 openid = self.model.objects.get( # pylint: disable=no-member
568 user=request.user, id=kwargs["openid_id"]
569 )
570 openid.delete()
571 messages.success(request, _("ID removed"))
572 except self.model.DoesNotExist: # pragma: no cover pylint: disable=no-member,line-too-long
573 messages.error(request, _("ID does not exist"))
574 return HttpResponseRedirect(reverse_lazy("profile"))
577@method_decorator(login_required, name="dispatch")
578class RemoveConfirmedOpenIDView(View):
579 """
580 View class for removing a confirmed OpenID
581 """
583 model = ConfirmedOpenId
585 def post(self, request, *args, **kwargs): # pylint: disable=unused-argument
586 """
587 Handle post - remove confirmed openid
588 """
589 try:
590 openid = self.model.objects.get( # pylint: disable=no-member
591 user=request.user, id=kwargs["openid_id"]
592 )
593 try:
594 openidobj = (
595 UserOpenID.objects.get( # pylint: disable=no-member,line-too-long
596 user_id=request.user.id, claimed_id=openid.openid
597 )
598 )
599 openidobj.delete()
600 except Exception as exc: # pylint: disable=broad-except
601 # Why it is not there?
602 print("How did we get here: %s" % exc)
603 openid.delete()
604 messages.success(request, _("ID removed"))
605 except self.model.DoesNotExist: # pylint: disable=no-member
606 messages.error(request, _("ID does not exist"))
607 return HttpResponseRedirect(reverse_lazy("profile"))
610@method_decorator(login_required, name="dispatch")
611class RedirectOpenIDView(View):
612 """
613 Redirect view for OpenID
614 """
616 model = UnconfirmedOpenId
618 def get(self, request, *args, **kwargs): # pylint: disable=unused-argument
619 """
620 Handle get for OpenID redirect view
621 """
622 try:
623 unconfirmed = self.model.objects.get( # pylint: disable=no-member
624 user=request.user, id=kwargs["openid_id"]
625 )
626 except self.model.DoesNotExist: # pragma: no cover pylint: disable=no-member,line-too-long
627 messages.error(request, _("ID does not exist"))
628 return HttpResponseRedirect(reverse_lazy("profile"))
630 user_url = unconfirmed.openid
631 session = {"id": request.session.session_key}
633 oidutil.log = openid_logging
634 openid_consumer = consumer.Consumer(session, DjangoOpenIDStore())
636 try:
637 auth_request = openid_consumer.begin(user_url)
638 except consumer.DiscoveryFailure as exc:
639 messages.error(request, _("OpenID discovery failed: %s" % exc))
640 return HttpResponseRedirect(reverse_lazy("profile"))
641 except UnicodeDecodeError as exc: # pragma: no cover
642 msg = _(
643 "OpenID discovery failed (userid=%(userid)s) for "
644 "%(userurl)s: %(message)s"
645 % {
646 "userid": request.user.id,
647 "userurl": user_url.encode("utf-8"),
648 "message": exc,
649 }
650 )
651 print("message: %s" % msg)
652 messages.error(request, msg)
654 if auth_request is None: # pragma: no cover
655 messages.error(request, _("OpenID discovery failed"))
656 return HttpResponseRedirect(reverse_lazy("profile"))
658 realm = request.build_absolute_uri("/")[:-1] # pragma: no cover
659 return_url = realm + reverse( # pragma: no cover
660 "confirm_openid", args=[kwargs["openid_id"]]
661 )
662 return HttpResponseRedirect( # pragma: no cover
663 auth_request.redirectURL(realm, return_url)
664 )
667@method_decorator(login_required, name="dispatch")
668class ConfirmOpenIDView(View): # pragma: no cover
669 """
670 Confirm OpenID view
671 """
673 model = UnconfirmedOpenId
674 model_confirmed = ConfirmedOpenId
676 def do_request(self, data, *args, **kwargs): # pylint: disable=unused-argument
677 """
678 Handle request, called by get() or post()
679 """
680 session = {"id": self.request.session.session_key}
681 current_url = self.request.build_absolute_uri("/")[:-1] + self.request.path
682 openid_consumer = consumer.Consumer(session, DjangoOpenIDStore())
683 info = openid_consumer.complete(data, current_url)
684 if info.status == consumer.FAILURE:
685 messages.error(
686 self.request, _('Confirmation failed: "') + str(info.message) + '"'
687 )
688 return HttpResponseRedirect(reverse_lazy("profile"))
690 if info.status == consumer.CANCEL:
691 messages.error(self.request, _("Cancelled by user"))
692 return HttpResponseRedirect(reverse_lazy("profile"))
694 if info.status != consumer.SUCCESS:
695 messages.error(self.request, _("Unknown verification error"))
696 return HttpResponseRedirect(reverse_lazy("profile"))
698 try:
699 unconfirmed = self.model.objects.get( # pylint: disable=no-member
700 user=self.request.user, id=kwargs["openid_id"]
701 )
702 except self.model.DoesNotExist: # pylint: disable=no-member
703 messages.error(self.request, _("ID does not exist"))
704 return HttpResponseRedirect(reverse_lazy("profile"))
706 # TODO: Check for a reasonable expiration time
707 confirmed = self.model_confirmed()
708 confirmed.user = unconfirmed.user
709 confirmed.ip_address = get_client_ip(self.request)[0]
710 confirmed.openid = unconfirmed.openid
711 confirmed.save()
713 unconfirmed.delete()
715 # If there is a single image in this user's profile
716 # assign it to the new id
717 if self.request.user.photo_set.count() == 1:
718 confirmed.set_photo(self.request.user.photo_set.first())
720 # Also allow user to login using this OpenID (if not already taken)
721 if not UserOpenID.objects.filter( # pylint: disable=no-member
722 claimed_id=confirmed.openid
723 ).exists():
724 user_openid = UserOpenID()
725 user_openid.user = self.request.user
726 user_openid.claimed_id = confirmed.openid
727 user_openid.display_id = confirmed.openid
728 user_openid.save()
729 return HttpResponseRedirect(reverse_lazy("profile"))
731 def get(self, request, *args, **kwargs):
732 """
733 Handle get - confirm openid
734 """
735 return self.do_request(request.GET, *args, **kwargs)
737 def post(self, request, *args, **kwargs):
738 """
739 Handle post - confirm openid
740 """
741 return self.do_request(request.POST, *args, **kwargs)
744@method_decorator(login_required, name="dispatch")
745class CropPhotoView(TemplateView):
746 """
747 View class for cropping photos
748 """
750 template_name = "crop_photo.html"
751 success_url = reverse_lazy("profile")
752 model = Photo
754 def get(self, request, *args, **kwargs):
755 photo = self.model.objects.get(
756 pk=kwargs["pk"], user=request.user
757 ) # pylint: disable=no-member
758 email = request.GET.get("email")
759 openid = request.GET.get("openid")
760 return render(
761 self.request,
762 self.template_name,
763 {
764 "photo": photo,
765 "email": email,
766 "openid": openid,
767 },
768 )
770 def post(self, request, *args, **kwargs): # pylint: disable=unused-argument
771 """
772 Handle post - crop photo
773 """
774 photo = self.model.objects.get(
775 pk=kwargs["pk"], user=request.user
776 ) # pylint: disable=no-member
777 dimensions = {
778 "x": int(float(request.POST["x"])),
779 "y": int(float(request.POST["y"])),
780 "w": int(float(request.POST["w"])),
781 "h": int(float(request.POST["h"])),
782 }
783 email = openid = None
784 if "email" in request.POST:
785 try:
786 email = ConfirmedEmail.objects.get(email=request.POST["email"])
787 except ConfirmedEmail.DoesNotExist: # pylint: disable=no-member
788 pass # Ignore automatic assignment
790 if "openid" in request.POST:
791 try:
792 openid = ConfirmedOpenId.objects.get( # pylint: disable=no-member
793 openid=request.POST["openid"]
794 )
795 except ConfirmedOpenId.DoesNotExist: # pylint: disable=no-member
796 pass # Ignore automatic assignment
798 return photo.perform_crop(request, dimensions, email, openid)
801@method_decorator(login_required, name="dispatch") # pylint: disable=too-many-ancestors
802class UserPreferenceView(FormView, UpdateView):
803 """
804 View class for user preferences view/update
805 """
807 template_name = "preferences.html"
808 model = UserPreference
809 form_class = UpdatePreferenceForm
810 success_url = reverse_lazy("user_preference")
812 def post(self, request, *args, **kwargs): # pylint: disable=unused-argument
813 """
814 Process POST-ed data from this form
815 """
816 userpref = None
817 try:
818 userpref = self.request.user.userpreference
819 except ObjectDoesNotExist:
820 userpref = UserPreference(user=self.request.user)
821 userpref.theme = request.POST["theme"]
822 userpref.save()
823 try:
824 if request.POST["email"] != self.request.user.email:
825 addresses = list(
826 self.request.user.confirmedemail_set.all().values_list(
827 "email", flat=True
828 )
829 )
830 if request.POST["email"] not in addresses:
831 messages.error(
832 self.request,
833 _("Mail address not allowed: %s" % request.POST["email"]),
834 )
835 else:
836 self.request.user.email = request.POST["email"]
837 self.request.user.save()
838 messages.info(self.request, _("Mail address changed."))
839 except Exception as e: # pylint: disable=broad-except
840 messages.error(self.request, _("Error setting new mail address: %s" % e))
842 try:
843 if request.POST["first_name"] or request.POST["last_name"]:
844 if request.POST["first_name"] != self.request.user.first_name:
845 self.request.user.first_name = request.POST["first_name"]
846 messages.info(self.request, _("First name changed."))
847 if request.POST["last_name"] != self.request.user.last_name:
848 self.request.user.last_name = request.POST["last_name"]
849 messages.info(self.request, _("Last name changed."))
850 self.request.user.save()
851 except Exception as e: # pylint: disable=broad-except
852 messages.error(self.request, _("Error setting names: %s" % e))
854 return HttpResponseRedirect(reverse_lazy("user_preference"))
856 def get(self, request, *args, **kwargs):
857 return render(
858 self.request,
859 self.template_name,
860 {
861 "THEMES": UserPreference.THEMES,
862 },
863 )
865 def get_object(self, queryset=None):
866 (obj, created) = UserPreference.objects.get_or_create(
867 user=self.request.user
868 ) # pylint: disable=no-member,unused-variable
869 return obj
872@method_decorator(login_required, name="dispatch")
873class UploadLibravatarExportView(SuccessMessageMixin, FormView):
874 """
875 View class responsible for libravatar user data export upload
876 """
878 template_name = "upload_libravatar_export.html"
879 form_class = UploadLibravatarExportForm
880 success_message = _("Successfully uploaded")
881 success_url = reverse_lazy("profile")
882 model = User
884 def post(self, request, *args, **kwargs): # pylint: disable=unused-argument
885 """
886 Handle post request - choose items to import
887 """
888 if "save" in kwargs: # pylint: disable=too-many-nested-blocks
889 if kwargs["save"] == "save":
890 for arg in request.POST:
891 if arg.startswith("email_"):
892 email = request.POST[arg]
893 if not ConfirmedEmail.objects.filter(
894 email=email
895 ) and not UnconfirmedEmail.objects.filter(
896 email=email
897 ): # pylint: disable=no-member
898 try:
899 unconfirmed = UnconfirmedEmail.objects.create( # pylint: disable=no-member
900 user=request.user, email=email
901 )
902 unconfirmed.save()
903 unconfirmed.send_confirmation_mail(
904 url=request.build_absolute_uri("/")[:-1]
905 )
906 messages.info(
907 request,
908 "%s: %s"
909 % (
910 email,
911 _(
912 "address added successfully,\
913 confirmation mail sent"
914 ),
915 ),
916 )
917 except Exception as exc: # pylint: disable=broad-except
918 # DEBUG
919 print(
920 "Exception during adding mail address (%s): %s"
921 % (email, exc)
922 )
924 if arg.startswith("photo"):
925 try:
926 data = base64.decodebytes(bytes(request.POST[arg], "utf-8"))
927 except binascii.Error as exc:
928 print("Cannot decode photo: %s" % exc)
929 continue
930 try:
931 pilobj = Image.open(BytesIO(data))
932 out = BytesIO()
933 pilobj.save(out, pilobj.format, quality=JPEG_QUALITY)
934 out.seek(0)
935 photo = Photo()
936 photo.user = request.user
937 photo.ip_address = get_client_ip(request)[0]
938 photo.format = file_format(pilobj.format)
939 photo.data = out.read()
940 photo.save()
941 except Exception as exc: # pylint: disable=broad-except
942 print("Exception during save: %s" % exc)
943 continue
945 return HttpResponseRedirect(reverse_lazy("profile"))
946 return super().post(request, args, kwargs)
948 def form_valid(self, form):
949 data = self.request.FILES["export_file"]
950 try:
951 items = libravatar_read_gzdata(data.read())
952 # DEBUG print(items)
953 return render(
954 self.request,
955 "choose_libravatar_export.html",
956 {
957 "emails": items["emails"],
958 "photos": items["photos"],
959 },
960 )
961 except Exception as e:
962 messages.error(self.request, _("Unable to parse file: %s" % e))
963 return HttpResponseRedirect(reverse_lazy("upload_export"))
966@method_decorator(login_required, name="dispatch")
967class ResendConfirmationMailView(View):
968 """
969 View class for resending confirmation mail
970 """
972 model = UnconfirmedEmail
974 def get(self, request, *args, **kwargs): # pylint: disable=unused-argument
975 """
976 Handle post - resend confirmation mail for unconfirmed e-mail address
977 """
978 try:
979 email = self.model.objects.get( # pylint: disable=no-member
980 user=request.user, id=kwargs["email_id"]
981 )
982 except self.model.DoesNotExist: # pragma: no cover pylint: disable=no-member
983 messages.error(request, _("ID does not exist"))
984 else:
985 try:
986 email.send_confirmation_mail(url=request.build_absolute_uri("/")[:-1])
987 messages.success(
988 request, "%s: %s" % (_("Confirmation mail sent to"), email.email)
989 )
990 except Exception as exc: # pylint: disable=broad-except
991 messages.error(
992 request,
993 "%s %s: %s"
994 % (_("Unable to send confirmation email for"), email.email, exc),
995 )
996 return HttpResponseRedirect(reverse_lazy("profile"))
999class IvatarLoginView(LoginView):
1000 """
1001 View class for login
1002 """
1004 template_name = "login.html"
1006 def get(self, request, *args, **kwargs):
1007 """
1008 Handle get for login view
1009 """
1010 if request.user:
1011 if request.user.is_authenticated:
1012 return HttpResponseRedirect(reverse_lazy("profile"))
1013 return super().get(self, request, args, kwargs)
1016@method_decorator(login_required, name="dispatch")
1017class ProfileView(TemplateView):
1018 """
1019 View class for profile
1020 """
1022 template_name = "profile.html"
1024 def get(self, request, *args, **kwargs):
1025 if "profile_username" in kwargs:
1026 if not request.user.is_staff:
1027 return HttpResponseRedirect(reverse_lazy("profile"))
1028 try:
1029 u = User.objects.get(username=kwargs["profile_username"])
1030 request.user = u
1031 except Exception: # pylint: disable=broad-except
1032 pass
1034 self._confirm_claimed_openid()
1035 return super().get(self, request, args, kwargs)
1037 def get_context_data(self, **kwargs):
1038 """
1039 Provide additional context data, like if max_photos is reached
1040 already or not.
1041 """
1042 context = super().get_context_data(**kwargs)
1043 context["max_photos"] = False
1044 if self.request.user:
1045 if self.request.user.photo_set.all().count() >= MAX_NUM_PHOTOS:
1046 context["max_photos"] = True
1047 return context
1049 def _confirm_claimed_openid(self):
1050 openids = self.request.user.useropenid_set.all()
1051 # If there is only one OpenID, we eventually need to add it to
1052 # the user account
1053 if openids.count() == 1:
1054 # Already confirmed, skip
1055 if ConfirmedOpenId.objects.filter( # pylint: disable=no-member
1056 openid=openids.first().claimed_id
1057 ).exists():
1058 return
1059 # For whatever reason, this is in unconfirmed state, skip
1060 if UnconfirmedOpenId.objects.filter( # pylint: disable=no-member
1061 openid=openids.first().claimed_id
1062 ).exists():
1063 return
1064 print("need to confirm: %s" % openids.first())
1065 confirmed = ConfirmedOpenId()
1066 confirmed.user = self.request.user
1067 confirmed.ip_address = get_client_ip(self.request)[0]
1068 confirmed.openid = openids.first().claimed_id
1069 confirmed.save()
1072class PasswordResetView(PasswordResetViewOriginal):
1073 """
1074 View class for password reset
1075 """
1077 def post(self, request, *args, **kwargs):
1078 """
1079 Since we have the mail addresses in ConfirmedEmail model,
1080 we need to set the email on the user object in order for the
1081 PasswordResetView class to pick up the correct user.
1082 In case we have the mail address in the User objecct, we still
1083 need to assign a random password in order for PasswordResetView
1084 class to pick up the user - else it will silently do nothing.
1085 """
1086 if "email" in request.POST:
1087 user = None
1089 # Try to find the user via the normal user class
1090 # TODO: How to handle the case that multiple user accounts
1091 # could have the same password set?
1092 user = User.objects.filter(email=request.POST["email"]).first()
1094 # If we didn't find the user in the previous step,
1095 # try the ConfirmedEmail class instead.
1096 # If we find the user there, we need to set the mail
1097 # attribute on the user object accordingly
1098 if not user:
1099 try:
1100 confirmed_email = ConfirmedEmail.objects.get(
1101 email=request.POST["email"]
1102 )
1103 user = confirmed_email.user
1104 user.email = confirmed_email.email
1105 user.save()
1106 except ObjectDoesNotExist:
1107 pass
1109 # If we found the user, set a random password. Else, the
1110 # ResetPasswordView class will silently ignore the password
1111 # reset request
1112 if user:
1113 if not user.password or user.password.startswith("!"):
1114 random_pass = User.objects.make_random_password()
1115 user.set_password(random_pass)
1116 user.save()
1118 # Whatever happens above, let the original function handle the rest
1119 return super().post(self, request, args, kwargs)
1122@method_decorator(login_required, name="dispatch")
1123class DeleteAccountView(SuccessMessageMixin, FormView):
1124 """
1125 View class for account deletion
1126 """
1128 template_name = "delete.html"
1129 form_class = DeleteAccountForm
1130 success_url = reverse_lazy("home")
1132 def get(self, request, *args, **kwargs):
1133 return super().get(self, request, args, kwargs)
1135 def post(self, request, *args, **kwargs):
1136 """
1137 Handle account deletion
1138 """
1139 if request.user.password:
1140 if "password" in request.POST:
1141 if not request.user.check_password(request.POST["password"]):
1142 messages.error(request, _("Incorrect password"))
1143 return HttpResponseRedirect(reverse_lazy("delete"))
1144 else:
1145 messages.error(request, _("No password given"))
1146 return HttpResponseRedirect(reverse_lazy("delete"))
1148 raise _("No password given")
1149 # should delete all confirmed/unconfirmed/photo objects
1150 request.user.delete()
1151 return super().post(self, request, args, kwargs)
1154@method_decorator(login_required, name="dispatch")
1155class ExportView(SuccessMessageMixin, TemplateView):
1156 """
1157 View class responsible for libravatar user data export
1158 """
1160 template_name = "export.html"
1161 model = User
1163 def get(self, request, *args, **kwargs):
1164 return super().get(self, request, args, kwargs)
1166 def post(self, request, *args, **kwargs):
1167 """
1168 Handle real export
1169 """
1170 SCHEMA_ROOT = "https://www.libravatar.org/schemas/export/0.2"
1171 SCHEMA_XSD = "%s/export.xsd" % SCHEMA_ROOT
1173 def xml_header():
1174 return (
1175 """<?xml version="1.0" encoding="UTF-8"?>"""
1176 '''<user xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'''
1177 ''' xsi:schemaLocation="%s %s"'''
1178 """ xmlns="%s">\n""" % (SCHEMA_ROOT, SCHEMA_XSD, SCHEMA_ROOT)
1179 )
1181 def xml_footer():
1182 return "</user>\n"
1184 def xml_account(user):
1185 escaped_username = saxutils.quoteattr(user.username)
1186 escaped_password = saxutils.quoteattr(user.password)
1187 return " <account username=%s password=%s/>\n" % (
1188 escaped_username,
1189 escaped_password,
1190 )
1192 def xml_email(user):
1193 returnstring = " <emails>\n"
1194 for email in user.confirmedemail_set.all():
1195 returnstring += (
1196 ' <email photo_id="'
1197 + str(email.photo_id)
1198 + '">'
1199 + str(email.email)
1200 + "</email>"
1201 + "\n"
1202 )
1203 returnstring += " </emails>\n"
1204 return returnstring
1206 def xml_openid(user):
1207 returnstring = " <openids>\n"
1208 for openid in user.confirmedopenid_set.all():
1209 returnstring += (
1210 ' <openid photo_id="'
1211 + str(openid.photo_id)
1212 + '">'
1213 + str(openid.openid)
1214 + "</openid>"
1215 + "\n"
1216 )
1217 returnstring += " </openids>\n"
1218 return returnstring
1220 def xml_photos(user):
1221 s = " <photos>\n"
1222 for photo in user.photo_set.all():
1223 encoded_photo = base64.b64encode(photo.data)
1224 if encoded_photo:
1225 s += (
1226 """ <photo id="%s" encoding="base64" format=%s>"""
1227 """%s"""
1228 """</photo>\n"""
1229 % (photo.id, saxutils.quoteattr(photo.format), encoded_photo)
1230 )
1231 s += " </photos>\n"
1232 return s
1234 user = request.user
1236 photos = []
1237 for photo in user.photo_set.all():
1238 photo_details = {"data": photo.data, "format": photo.format}
1239 photos.append(photo_details)
1241 bytesobj = BytesIO()
1242 data = gzip.GzipFile(fileobj=bytesobj, mode="w")
1243 data.write(bytes(xml_header(), "utf-8"))
1244 data.write(bytes(xml_account(user), "utf-8"))
1245 data.write(bytes(xml_email(user), "utf-8"))
1246 data.write(bytes(xml_openid(user), "utf-8"))
1247 data.write(bytes(xml_photos(user), "utf-8"))
1248 data.write(bytes(xml_footer(), "utf-8"))
1249 data.close()
1250 bytesobj.seek(0)
1252 response = HttpResponse(content_type="application/gzip")
1253 response["Content-Disposition"] = (
1254 'attachment; filename="libravatar-export_%s.xml.gz"' % user.username
1255 )
1256 response.write(bytesobj.read())
1257 return response