''' views for actions you can take in the application ''' from io import BytesIO, TextIOWrapper from uuid import uuid4 from PIL import Image import dateutil.parser from dateutil.parser import ParserError from django.contrib.auth import authenticate, login, logout from django.contrib.auth.decorators import login_required, permission_required from django.core.exceptions import PermissionDenied from django.core.files.base import ContentFile from django.http import HttpResponseBadRequest, HttpResponseNotFound from django.shortcuts import redirect from django.template.response import TemplateResponse from django.utils import timezone from bookwyrm import books_manager from bookwyrm import forms, models, outgoing from bookwyrm import goodreads_import from bookwyrm.emailing import password_reset_email from bookwyrm.settings import DOMAIN from bookwyrm.views import get_user_from_username def user_login(request): ''' authenticate user login ''' if request.method == 'GET': return redirect('/login') login_form = forms.LoginForm(request.POST) username = login_form.data['username'] username = '%s@%s' % (username, DOMAIN) password = login_form.data['password'] user = authenticate(request, username=username, password=password) if user is not None: # successful login login(request, user) user.last_active_date = timezone.now() return redirect(request.GET.get('next', '/')) login_form.non_field_errors = 'Username or password are incorrect' register_form = forms.RegisterForm() data = { 'site_settings': models.SiteSettings.get(), 'login_form': login_form, 'register_form': register_form } return TemplateResponse(request, 'login.html', data) def register(request): ''' join the server ''' if request.method == 'GET': return redirect('/login') if not models.SiteSettings.get().allow_registration: invite_code = request.POST.get('invite_code') if not invite_code: raise PermissionDenied try: invite = models.SiteInvite.objects.get(code=invite_code) except models.SiteInvite.DoesNotExist: raise PermissionDenied else: invite = None form = forms.RegisterForm(request.POST) errors = False if not form.is_valid(): errors = True username = form.data['username'] email = form.data['email'] password = form.data['password'] # check username and email uniqueness if models.User.objects.filter(localname=username).first(): form.add_error('username', 'User with this username already exists') errors = True if errors: data = { 'site_settings': models.SiteSettings.get(), 'login_form': forms.LoginForm(), 'register_form': form } return TemplateResponse(request, 'login.html', data) user = models.User.objects.create_user(username, email, password) if invite: invite.times_used += 1 invite.save() login(request, user) return redirect('/') @login_required def user_logout(request): ''' done with this place! outa here! ''' logout(request) return redirect('/') def password_reset_request(request): ''' create a password reset token ''' email = request.POST.get('email') try: user = models.User.objects.get(email=email) except models.User.DoesNotExist: return redirect('/password-reset') # remove any existing password reset cods for this user models.PasswordReset.objects.filter(user=user).all().delete() # create a new reset code code = models.PasswordReset.objects.create(user=user) password_reset_email(code) data = {'message': 'Password reset link sent to %s' % email} return TemplateResponse(request, 'password_reset_request.html', data) def password_reset(request): ''' allow a user to change their password through an emailed token ''' try: reset_code = models.PasswordReset.objects.get( code=request.POST.get('reset-code') ) except models.PasswordReset.DoesNotExist: data = {'errors': ['Invalid password reset link']} return TemplateResponse(request, 'password_reset.html', data) user = reset_code.user new_password = request.POST.get('password') confirm_password = request.POST.get('confirm-password') if new_password != confirm_password: data = {'errors': ['Passwords do not match']} return TemplateResponse(request, 'password_reset.html', data) user.set_password(new_password) user.save() login(request, user) reset_code.delete() return redirect('/') @login_required def password_change(request): ''' allow a user to change their password ''' new_password = request.POST.get('password') confirm_password = request.POST.get('confirm-password') if new_password != confirm_password: return redirect('/user-edit') request.user.set_password(new_password) request.user.save() login(request, request.user) return redirect('/user-edit') @login_required def edit_profile(request): ''' les get fancy with images ''' if not request.method == 'POST': return redirect('/user/%s' % request.user.localname) form = forms.EditUserForm(request.POST, request.FILES) if not form.is_valid(): data = { 'form': form, 'user': request.user, } return TemplateResponse(request, 'edit_user.html', data) request.user.name = form.data['name'] request.user.email = form.data['email'] if 'avatar' in form.files: # crop and resize avatar upload image = Image.open(form.files['avatar']) target_size = 120 width, height = image.size thumbnail_scale = height / (width / target_size) if height > width \ else width / (height / target_size) image.thumbnail([thumbnail_scale, thumbnail_scale]) width, height = image.size width_diff = width - target_size height_diff = height - target_size cropped = image.crop(( int(width_diff / 2), int(height_diff / 2), int(width - (width_diff / 2)), int(height - (height_diff / 2)) )) output = BytesIO() cropped.save(output, format=image.format) ContentFile(output.getvalue()) # set the name to a hash extension = form.files['avatar'].name.split('.')[-1] filename = '%s.%s' % (uuid4(), extension) request.user.avatar.save( filename, ContentFile(output.getvalue()) ) request.user.summary = form.data['summary'] request.user.manually_approves_followers = \ form.cleaned_data['manually_approves_followers'] request.user.save() outgoing.handle_update_user(request.user) return redirect('/user/%s' % request.user.localname) def resolve_book(request): ''' figure out the local path to a book from a remote_id ''' remote_id = request.POST.get('remote_id') book = books_manager.get_or_create_book(remote_id) return redirect('/book/%d' % book.id) @login_required @permission_required('bookwyrm.edit_book', raise_exception=True) def edit_book(request, book_id): ''' edit a book cool ''' if not request.method == 'POST': return redirect('/book/%s' % book_id) try: book = models.Edition.objects.get(id=book_id) except models.Edition.DoesNotExist: return HttpResponseNotFound() form = forms.EditionForm(request.POST, request.FILES, instance=book) if not form.is_valid(): return redirect(request.headers.get('Referer', '/')) form.save() outgoing.handle_update_book(request.user, book) return redirect('/book/%s' % book.id) @login_required def upload_cover(request, book_id): ''' upload a new cover ''' # TODO: alternate covers? if not request.method == 'POST': return redirect('/book/%s' % request.user.localname) try: book = models.Edition.objects.get(id=book_id) except models.Edition.DoesNotExist: return HttpResponseNotFound() form = forms.CoverForm(request.POST, request.FILES, instance=book) if not form.is_valid(): return redirect(request.headers.get('Referer', '/')) book.cover = form.files['cover'] book.sync_cover = False book.save() outgoing.handle_update_book(request.user, book) return redirect('/book/%s' % book.id) @login_required def shelve(request): ''' put a on a user's shelf ''' book = books_manager.get_edition(request.POST['book']) desired_shelf = models.Shelf.objects.filter( identifier=request.POST['shelf'], user=request.user ).first() if request.POST.get('reshelve', True): try: current_shelf = models.Shelf.objects.get( user=request.user, edition=book ) outgoing.handle_unshelve(request.user, book, current_shelf) except models.Shelf.DoesNotExist: # this just means it isn't currently on the user's shelves pass outgoing.handle_shelve(request.user, book, desired_shelf) # post about "want to read" shelves if desired_shelf.identifier == 'to-read': outgoing.handle_reading_status( request.user, desired_shelf, book, privacy='public' ) return redirect('/') @login_required def unshelve(request): ''' put a on a user's shelf ''' book = models.Edition.objects.get(id=request.POST['book']) current_shelf = models.Shelf.objects.get(id=request.POST['shelf']) outgoing.handle_unshelve(request.user, book, current_shelf) return redirect(request.headers.get('Referer', '/')) @login_required def start_reading(request): ''' begin reading a book ''' book = books_manager.get_edition(request.POST['book']) shelf = models.Shelf.objects.filter( identifier='reading', user=request.user ).first() # create a readthrough readthrough = update_readthrough(request, book=book) if readthrough.start_date: readthrough.save() # shelve the book if request.POST.get('reshelve', True): try: current_shelf = models.Shelf.objects.get( user=request.user, edition=book ) outgoing.handle_unshelve(request.user, book, current_shelf) except models.Shelf.DoesNotExist: # this just means it isn't currently on the user's shelves pass outgoing.handle_shelve(request.user, book, shelf) # post about it (if you want) if request.POST.get('post-status'): privacy = request.POST.get('privacy') outgoing.handle_reading_status(request.user, shelf, book, privacy) return redirect(request.headers.get('Referer', '/')) @login_required def finish_reading(request): ''' a user completed a book, yay ''' book = books_manager.get_edition(request.POST['book']) shelf = models.Shelf.objects.filter( identifier='read', user=request.user ).first() # update or create a readthrough readthrough = update_readthrough(request, book=book) if readthrough.start_date or readthrough.finish_date: readthrough.save() # shelve the book if request.POST.get('reshelve', True): try: current_shelf = models.Shelf.objects.get( user=request.user, edition=book ) outgoing.handle_unshelve(request.user, book, current_shelf) except models.Shelf.DoesNotExist: # this just means it isn't currently on the user's shelves pass outgoing.handle_shelve(request.user, book, shelf) # post about it (if you want) if request.POST.get('post-status'): privacy = request.POST.get('privacy') outgoing.handle_reading_status(request.user, shelf, book, privacy) return redirect(request.headers.get('Referer', '/')) @login_required def edit_readthrough(request): ''' can't use the form because the dates are too finnicky ''' readthrough = update_readthrough(request, create=False) if not readthrough: return HttpResponseNotFound() # don't let people edit other people's data if request.user != readthrough.user: return HttpResponseBadRequest() readthrough.save() return redirect(request.headers.get('Referer', '/')) @login_required def delete_readthrough(request): ''' remove a readthrough ''' try: readthrough = models.ReadThrough.objects.get(id=request.POST.get('id')) except models.ReadThrough.DoesNotExist: return HttpResponseNotFound() # don't let people edit other people's data if request.user != readthrough.user: return HttpResponseBadRequest() readthrough.delete() return redirect(request.headers.get('Referer', '/')) @login_required def rate(request): ''' just a star rating for a book ''' form = forms.RatingForm(request.POST) return handle_status(request, form) @login_required def review(request): ''' create a book review ''' form = forms.ReviewForm(request.POST) return handle_status(request, form) @login_required def quotate(request): ''' create a book quotation ''' form = forms.QuotationForm(request.POST) return handle_status(request, form) @login_required def comment(request): ''' create a book comment ''' form = forms.CommentForm(request.POST) return handle_status(request, form) @login_required def reply(request): ''' respond to a book review ''' form = forms.ReplyForm(request.POST) return handle_status(request, form) def handle_status(request, form): ''' all the "create a status" functions are the same ''' if not form.is_valid(): return redirect(request.headers.get('Referer', '/')) outgoing.handle_status(request.user, form) return redirect(request.headers.get('Referer', '/')) @login_required def tag(request): ''' tag a book ''' # I'm not using a form here because sometimes "name" is sent as a hidden # field which doesn't validate name = request.POST.get('name') book_id = request.POST.get('book') try: book = models.Edition.objects.get(id=book_id) except models.Edition.DoesNotExist: return HttpResponseNotFound() tag_obj, created = models.Tag.objects.get_or_create( name=name, book=book, user=request.user ) if created: outgoing.handle_tag(request.user, tag_obj) return redirect('/book/%s' % book_id) @login_required def untag(request): ''' untag a book ''' name = request.POST.get('name') book_id = request.POST.get('book') outgoing.handle_untag(request.user, book_id, name) return redirect('/book/%s' % book_id) @login_required def favorite(request, status_id): ''' like a status ''' status = models.Status.objects.get(id=status_id) outgoing.handle_favorite(request.user, status) return redirect(request.headers.get('Referer', '/')) @login_required def unfavorite(request, status_id): ''' like a status ''' status = models.Status.objects.get(id=status_id) outgoing.handle_unfavorite(request.user, status) return redirect(request.headers.get('Referer', '/')) @login_required def boost(request, status_id): ''' boost a status ''' status = models.Status.objects.get(id=status_id) outgoing.handle_boost(request.user, status) return redirect(request.headers.get('Referer', '/')) @login_required def delete_status(request): ''' delete and tombstone a status ''' status_id = request.POST.get('status') if not status_id: return HttpResponseBadRequest() try: status = models.Status.objects.get(id=status_id) except models.Status.DoesNotExist: return HttpResponseBadRequest() # don't let people delete other people's statuses if status.user != request.user: return HttpResponseBadRequest() # perform deletion outgoing.handle_delete_status(request.user, status) return redirect(request.headers.get('Referer', '/')) @login_required def follow(request): ''' follow another user, here or abroad ''' username = request.POST['user'] try: to_follow = get_user_from_username(username) except models.User.DoesNotExist: return HttpResponseBadRequest() outgoing.handle_follow(request.user, to_follow) user_slug = to_follow.localname if to_follow.localname \ else to_follow.username return redirect('/user/%s' % user_slug) @login_required def unfollow(request): ''' unfollow a user ''' username = request.POST['user'] try: to_unfollow = get_user_from_username(username) except models.User.DoesNotExist: return HttpResponseBadRequest() outgoing.handle_unfollow(request.user, to_unfollow) user_slug = to_unfollow.localname if to_unfollow.localname \ else to_unfollow.username return redirect('/user/%s' % user_slug) @login_required def clear_notifications(request): ''' permanently delete notification for user ''' request.user.notification_set.filter(read=True).delete() return redirect('/notifications') @login_required def accept_follow_request(request): ''' a user accepts a follow request ''' username = request.POST['user'] try: requester = get_user_from_username(username) except models.User.DoesNotExist: return HttpResponseBadRequest() try: follow_request = models.UserFollowRequest.objects.get( user_subject=requester, user_object=request.user ) except models.UserFollowRequest.DoesNotExist: # Request already dealt with. pass else: outgoing.handle_accept(follow_request) return redirect('/user/%s' % request.user.localname) @login_required def delete_follow_request(request): ''' a user rejects a follow request ''' username = request.POST['user'] try: requester = get_user_from_username(username) except models.User.DoesNotExist: return HttpResponseBadRequest() try: follow_request = models.UserFollowRequest.objects.get( user_subject=requester, user_object=request.user ) except models.UserFollowRequest.DoesNotExist: return HttpResponseBadRequest() outgoing.handle_reject(follow_request) return redirect('/user/%s' % request.user.localname) @login_required def import_data(request): ''' ingest a goodreads csv ''' form = forms.ImportForm(request.POST, request.FILES) if form.is_valid(): include_reviews = request.POST.get('include_reviews') == 'on' privacy = request.POST.get('privacy') try: job = goodreads_import.create_job( request.user, TextIOWrapper( request.FILES['csv_file'], encoding=request.encoding), include_reviews, privacy, ) except (UnicodeDecodeError, ValueError): return HttpResponseBadRequest('Not a valid csv file') goodreads_import.start_import(job) return redirect('/import_status/%d' % (job.id,)) return HttpResponseBadRequest() @login_required @permission_required('bookwyrm.create_invites', raise_exception=True) def create_invite(request): ''' creates a user invite database entry ''' form = forms.CreateInviteForm(request.POST) if not form.is_valid(): return HttpResponseBadRequest("ERRORS : %s" % (form.errors,)) invite = form.save(commit=False) invite.user = request.user invite.save() return redirect('/invite') def update_readthrough(request, book=None, create=True): ''' updates but does not save dates on a readthrough ''' try: read_id = request.POST.get('id') if not read_id: raise models.ReadThrough.DoesNotExist readthrough = models.ReadThrough.objects.get(id=read_id) except models.ReadThrough.DoesNotExist: if not create or not book: return None readthrough = models.ReadThrough( user=request.user, book=book, ) start_date = request.POST.get('start_date') if start_date: try: start_date = dateutil.parser.parse(start_date) readthrough.start_date = start_date except ParserError: pass finish_date = request.POST.get('finish_date') if finish_date: try: finish_date = dateutil.parser.parse(finish_date) readthrough.finish_date = finish_date except ParserError: pass return readthrough