From e094b4a80f0bd03c601b8d2dff9932994c25bead Mon Sep 17 00:00:00 2001 From: Elin swedin Date: Mon, 13 Mar 2017 15:35:26 +0100 Subject: [PATCH] Ability to edit user profiles (#36) --- src/foobar/admin.py | 3 +- src/foobar/api.py | 13 +++- src/foobar/forms.py | 11 +++- .../migrations/0020_auto_20170302_1359.py | 20 ++++++ src/foobar/models.py | 6 +- src/foobar/rest/serializers/account.py | 7 ++ src/foobar/rest/views/account.py | 3 +- src/foobar/static/css/profile.css | 44 +++++++++++++ src/foobar/static/css/scan_card.css | 6 ++ src/foobar/templates/profile/bad_request.html | 5 ++ .../templates/profile/base_profile.html | 17 +++++ src/foobar/templates/profile/success.html | 17 +++++ src/foobar/tests/test_api.py | 27 +++++++- src/foobar/tests/test_views.py | 64 +++++++++++++------ src/foobar/urls.py | 3 + src/foobar/views.py | 32 ++++++++-- 16 files changed, 248 insertions(+), 30 deletions(-) create mode 100644 src/foobar/migrations/0020_auto_20170302_1359.py create mode 100644 src/foobar/static/css/profile.css create mode 100644 src/foobar/templates/profile/bad_request.html create mode 100644 src/foobar/templates/profile/base_profile.html create mode 100644 src/foobar/templates/profile/success.html diff --git a/src/foobar/admin.py b/src/foobar/admin.py index cf827fb..7072bde 100644 --- a/src/foobar/admin.py +++ b/src/foobar/admin.py @@ -67,7 +67,7 @@ class CardInline(admin.TabularInline): @admin.register(models.Account) class AccountAdmin(admin.ModelAdmin): - list_display = ('id', 'name', 'user', 'balance') + list_display = ('id', 'name', 'user', 'balance', 'email') readonly_fields = ('id', 'wallet_link', 'date_created', 'date_modified') inlines = (CardInline, PurchaseInline,) search_fields = ('name',) @@ -77,6 +77,7 @@ class AccountAdmin(admin.ModelAdmin): 'id', 'user', 'name', + 'email' ) }), ('Additional information', { diff --git a/src/foobar/api.py b/src/foobar/api.py index 4aacadf..c072fd6 100644 --- a/src/foobar/api.py +++ b/src/foobar/api.py @@ -23,12 +23,23 @@ def get_card(card_id): return None -def get_account(card_id): +def get_account(account_id): + try: + return Account.objects.get(id=account_id) + except Account.DoesNotExist: + return None + + +def get_account_by_card(card_id): card_obj = get_card(card_id) if card_obj is not None: return card_obj.account +def update_account(account_id, **kwargs): + Account.objects.filter(id=account_id).update(**kwargs) + + @transaction.atomic def purchase(account_id, products): """ diff --git a/src/foobar/forms.py b/src/foobar/forms.py index a8804a6..600ad2c 100644 --- a/src/foobar/forms.py +++ b/src/foobar/forms.py @@ -5,8 +5,10 @@ class CorrectionForm(forms.Form): - balance = MoneyField(label='Balance', min_value=0) - comment = forms.CharField(label='Comment', max_length=128, required=False) + balance = MoneyField(label=_('Balance'), min_value=0) + comment = forms.CharField(label=_('Comment'), + max_length=128, + required=False) class DepositForm(forms.Form): @@ -23,3 +25,8 @@ def clean_deposit_or_withdrawal(self): if data.amount < 0 and -data > balance: raise forms.ValidationError(_('Not enough funds')) return data + + +class EditProfileForm(forms.Form): + name = forms.CharField(label=_("Account Name"), max_length=128) + email = forms.EmailField(label=_("E-mail")) diff --git a/src/foobar/migrations/0020_auto_20170302_1359.py b/src/foobar/migrations/0020_auto_20170302_1359.py new file mode 100644 index 0000000..0976ed2 --- /dev/null +++ b/src/foobar/migrations/0020_auto_20170302_1359.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-03-02 13:59 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('foobar', '0019_auto_20170221_1547'), + ] + + operations = [ + migrations.AlterField( + model_name='account', + name='email', + field=models.EmailField(blank=True, max_length=254, null=True), + ), + ] diff --git a/src/foobar/models.py b/src/foobar/models.py index 8c9a39a..544e8e5 100644 --- a/src/foobar/models.py +++ b/src/foobar/models.py @@ -15,7 +15,11 @@ class Account(UUIDModel, TimeStampedModel): user = models.ForeignKey(User, null=True, blank=True) name = models.CharField(null=True, blank=True, max_length=128) - email = models.CharField(null=True, blank=True, max_length=128) + email = models.EmailField(null=True, blank=True) + + @property + def is_complete(self): + return bool(self.email) def __str__(self): return str(self.id) diff --git a/src/foobar/rest/serializers/account.py b/src/foobar/rest/serializers/account.py index 83b0968..d7c7034 100644 --- a/src/foobar/rest/serializers/account.py +++ b/src/foobar/rest/serializers/account.py @@ -1,6 +1,7 @@ from rest_framework import serializers from foobar.wallet import api as wallet_api from ..fields import MoneyField +from django.core import signing class AccountSerializer(serializers.Serializer): @@ -8,10 +9,16 @@ def get_balance(self, instance): _, balance = wallet_api.get_balance(instance.id) return MoneyField().to_representation(balance) + def get_token(self, instance): + token = signing.dumps({'id': str(instance.id)}) + return token + id = serializers.UUIDField(read_only=True) user_id = serializers.UUIDField(read_only=True, source='user.id') name = serializers.CharField(read_only=True) balance = serializers.SerializerMethodField() + token = serializers.SerializerMethodField() + is_complete = serializers.BooleanField(read_only=True) class AccountQuerySerializer(serializers.Serializer): diff --git a/src/foobar/rest/views/account.py b/src/foobar/rest/views/account.py index 058ad84..ef40e00 100644 --- a/src/foobar/rest/views/account.py +++ b/src/foobar/rest/views/account.py @@ -2,6 +2,7 @@ from rest_framework.response import Response from authtoken.permissions import HasTokenScope + import foobar.api from ..serializers.account import AccountQuerySerializer, AccountSerializer @@ -13,7 +14,7 @@ class AccountAPI(viewsets.ViewSet): def retrieve(self, request, pk): serializer = AccountQuerySerializer(data={'card_id': pk}) serializer.is_valid(raise_exception=True) - account_obj = foobar.api.get_account(card_id=pk) + account_obj = foobar.api.get_account_by_card(card_id=pk) if account_obj is None: return Response(status=status.HTTP_404_NOT_FOUND) serializer = AccountSerializer(account_obj) diff --git a/src/foobar/static/css/profile.css b/src/foobar/static/css/profile.css new file mode 100644 index 0000000..5bc98e5 --- /dev/null +++ b/src/foobar/static/css/profile.css @@ -0,0 +1,44 @@ +body{ + margin: 0; + font-size: 26px; +} +.info { + background: #dfd; + padding: 10px; + color: #333; + text-align: center; +} +#header{ + background: #a70d0d; + font-size: 350%; + padding: 2%; + color: white; + text-align: center; + margin: auto; + border-bottom: 5px solid #d34a4a; +} +.account_form{ + font-size: 180%; + width: 70%; + margin: auto; + padding-top: 5% +} +.account_form >*{ + width: 100%; + height: 40px; + margin: 10px; + outline-color: #a70d0d; +} +input{ + font-size: 26px; +} +#submit_changes{ + background: #a70d0d; + color: white; + border: none; + padding: 5px; + height: 60px; + font-size: 100%; + -webkit-appearance: none; + border-radius: 0; +} diff --git a/src/foobar/static/css/scan_card.css b/src/foobar/static/css/scan_card.css index 18c1b03..0079dc4 100644 --- a/src/foobar/static/css/scan_card.css +++ b/src/foobar/static/css/scan_card.css @@ -18,3 +18,9 @@ margin-left: 0 !important; margin-top: -4px !important; } +.info{ + background: #dfd url(../img/icon-yes.svg) 40px 12px no-repeat; + background-size: 16px auto; + text-align: center; + +} diff --git a/src/foobar/templates/profile/bad_request.html b/src/foobar/templates/profile/bad_request.html new file mode 100644 index 0000000..173e6f3 --- /dev/null +++ b/src/foobar/templates/profile/bad_request.html @@ -0,0 +1,5 @@ +{% extends 'profile/base_profile.html' %} +{% load i18n %} +{% block content %} +
{% trans "Invalid QRcode" %}
{% trans "Login to get a new" %}
+{% endblock %} \ No newline at end of file diff --git a/src/foobar/templates/profile/base_profile.html b/src/foobar/templates/profile/base_profile.html new file mode 100644 index 0000000..690bec4 --- /dev/null +++ b/src/foobar/templates/profile/base_profile.html @@ -0,0 +1,17 @@ + + + + + + FooBar + + + + {% load i18n %} + + {% block content %} + {% endblock %} + + diff --git a/src/foobar/templates/profile/success.html b/src/foobar/templates/profile/success.html new file mode 100644 index 0000000..013e22c --- /dev/null +++ b/src/foobar/templates/profile/success.html @@ -0,0 +1,17 @@ +{% extends 'profile/base_profile.html' %} +{% load i18n %} +{% block content %} +
+ {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} +
+
+ {% csrf_token %} + {{ form }} + +
+ +{% endblock %} diff --git a/src/foobar/tests/test_api.py b/src/foobar/tests/test_api.py index 612baca..f104d1c 100644 --- a/src/foobar/tests/test_api.py +++ b/src/foobar/tests/test_api.py @@ -8,6 +8,7 @@ from .factories import AccountFactory, CardFactory from moneyed import Money from django.contrib.auth.models import User +import uuid class FoobarAPITest(TestCase): @@ -27,19 +28,41 @@ def test_get_card(self): self.assertGreater(obj2.date_used, date_used) def test_get_account(self): + # Assure None when missing account + id = uuid.uuid4() + obj1 = api.get_account(account_id=id) + self.assertIsNone(obj1) + + # Create an account + account_obj = AccountFactory.create() + obj2 = api.get_account(account_id=account_obj.id) + + self.assertIsNotNone(obj2) + + def test_get_account_by_card(self): # Retrieve an non-existent account - obj1 = api.get_account(card_id=1337) + obj1 = api.get_account_by_card(card_id=1337) self.assertIsNone(obj1) # Create an account CardFactory.create(number=1337) - obj2 = api.get_account(card_id=1337) + obj2 = api.get_account_by_card(card_id=1337) self.assertIsNotNone(obj2) account_objs = models.Account.objects.filter(id=obj2.id) self.assertEqual(account_objs.count(), 1) + def test_update_account(self): + account_obj = AccountFactory.create() + api.update_account(account_id=account_obj.id, + name='1337', + email='1337@foo.com') + account = api.get_account(account_id=account_obj.id) + # Test that correct fields are updated + self.assertEqual('1337', account.name) + self.assertEqual('1337@foo.com', account.email) + def test_purchase(self): account_obj = AccountFactory.create() wallet_obj = WalletFactory.create(owner_id=account_obj.id) diff --git a/src/foobar/tests/test_views.py b/src/foobar/tests/test_views.py index 931d58f..9739b6f 100644 --- a/src/foobar/tests/test_views.py +++ b/src/foobar/tests/test_views.py @@ -6,6 +6,7 @@ from wallet.tests.factories import WalletFactory, WalletTrxFactory from wallet.enums import TrxType from . import factories +from django.core import signing class FoobarViewTest(TestCase): @@ -23,7 +24,7 @@ def setUp(self): password=self.TESTUSER_PASS ) - @mock.patch('foobar.api.get_account') + @mock.patch('foobar.api.get_account_by_card') def test_account_for_card(self, mock_get_account): url = reverse('account_for_card', kwargs={'card_id': 1337}) mock_get_account.return_value = None @@ -53,38 +54,65 @@ def test_wallet_management(self, mock_deposit_withdrawal, mock_correction): ) url = reverse('wallet_management', kwargs={'obj_id': wallet_obj.owner_id}) - cl = self.client # Test that deposit or withdrawal # is not called if balance will get negative - response = cl.post(url, - {'deposit_or_withdrawal_1': ['SEK'], - 'save_deposit': ['Submit'], - 'comment': ['test'], - 'deposit_or_withdrawal_0': ['-3000']}) + response = self.client.post(url, + {'deposit_or_withdrawal_1': ['SEK'], + 'save_deposit': ['Submit'], + 'comment': ['test'], + 'deposit_or_withdrawal_0': ['-3000']}) mock_deposit_withdrawal.assert_not_called() # Test that page can be found - response = cl.get(url) + response = self.client.get(url) self.assertEqual(response.status_code, 200) # Test that correction form post is correct and # calls function with correct params - response = cl.post(url, - {'save_correction': ['Submit'], - 'balance_1': ['SEK'], - 'comment': ['test'], - 'balance_0': ['1000']}) + self.client.post(url, + {'save_correction': ['Submit'], + 'balance_1': ['SEK'], + 'comment': ['test'], + 'balance_0': ['1000']}) mock_correction.assert_called_with(Money(1000, 'SEK'), wallet_obj.owner_id, self.user, 'test') # Test that deposit or withdrawal form post is correct and # calls fucnction with correct params - response = cl.post(url, - {'deposit_or_withdrawal_1': ['SEK'], - 'save_deposit': ['Submit'], - 'comment': ['test'], - 'deposit_or_withdrawal_0': ['100']}) + self.client.post(url, + {'deposit_or_withdrawal_1': ['SEK'], + 'save_deposit': ['Submit'], + 'comment': ['test'], + 'deposit_or_withdrawal_0': ['100']}) mock_deposit_withdrawal.assert_called_with( Money(100, 'SEK'), wallet_obj.owner_id, self.user, 'test') + + @mock.patch('foobar.api.update_account') + def test_edit_profile(self, mock_update_account): + account_obj = factories.AccountFactory.create() + token = signing.dumps({'id': str(account_obj.id)}) + url = reverse('edit_profile', kwargs={'token': token}) + bad_token = reverse('edit_profile', kwargs={'token': 'bad'}) + response1 = self.client.get(url) + response2 = self.client.get(bad_token) + + # Assert that page can be found + self.assertEqual(response1.status_code, 200) + self.assertEqual(response2.status_code, 200) + + # Assure update_account not called when url with bad token send POST + self.client.post(bad_token, {'name': 'foo', + 'email': 'test@test.com', + 'save_changes': ['Submit']}) + mock_update_account.assert_not_called() + + # Assure set_balance is called when token is valid + token_data = signing.loads(token, max_age=1800) + self.client.post(url, {'name': 'foo', + 'email': 'test@test.com', + 'save_changes': ['Submit']}) + mock_update_account.assert_called_with(token_data.get('id'), + name='foo', + email='test@test.com') diff --git a/src/foobar/urls.py b/src/foobar/urls.py index ece19cb..63871b0 100644 --- a/src/foobar/urls.py +++ b/src/foobar/urls.py @@ -21,6 +21,9 @@ url(r'^admin/foobar/account/card/(?P\d+)', foobar.views.account_for_card, name='account_for_card'), url(r'^admin/', include(admin.site.urls)), + url(r'profile/(?P.+)', + foobar.views.edit_profile, + name="edit_profile") ] if settings.DEBUG: diff --git a/src/foobar/views.py b/src/foobar/views.py index 0063d6d..719888a 100644 --- a/src/foobar/views.py +++ b/src/foobar/views.py @@ -4,20 +4,22 @@ from django.utils.translation import ugettext_lazy as _ from . import api from django.shortcuts import render -from .forms import CorrectionForm, DepositForm +from .forms import CorrectionForm, DepositForm, EditProfileForm from django.contrib import messages from django.http import HttpResponseRedirect from foobar.wallet.api import get_wallet +from django.core import signing @staff_member_required @permission_required('foobar.change_account') def account_for_card(request, card_id): - account_obj = api.get_account(card_id) + account_obj = api.get_account_by_card(card_id) if account_obj is None: messages.add_message(request, messages.ERROR, _('No account has been found for given card.')) return redirect('admin:foobar_account_changelist') + return redirect('admin:foobar_account_change', account_obj.id) @@ -38,7 +40,7 @@ def wallet_management(request, obj_id): ) messages.add_message(request, messages.INFO, - 'Correction was successfully saved.') + _('Correction was successfully saved.')) return HttpResponseRedirect(request.path) elif 'save_deposit' in request.POST: @@ -51,7 +53,7 @@ def wallet_management(request, obj_id): ) messages.add_message(request, messages.INFO, - 'Successfully saved.') + _('Successfully saved.')) return HttpResponseRedirect(request.path) return render(request, @@ -59,3 +61,25 @@ def wallet_management(request, obj_id): {'wallet': wallet, 'form_class': form_class, 'form_class1': form_class1}) + + +def edit_profile(request, token): + form_class = EditProfileForm(request.POST or None) + try: + token = signing.loads(token, max_age=1800) + except signing.BadSignature: + return render(request, "profile/bad_request.html") + + if request.method == 'POST': + if form_class.is_valid(): + api.update_account(token.get('id'), + name=form_class.cleaned_data['name'], + email=form_class.cleaned_data['email']) + messages.add_message(request, messages.INFO, + _('Successfully Saved')) + return HttpResponseRedirect(request.path) + + account = api.get_account(token.get('id')) + form_class = EditProfileForm(initial={'name': account.name, + 'email': account.email}) + return render(request, "profile/success.html", {'form': form_class})