From a73bbdc0c7973402cb4bf3871f7f5cb8982bdc0e Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Fri, 6 Jan 2017 20:49:20 -0800 Subject: [PATCH 01/46] starting to get this going. --- starting_app/foo/coupons/__init__.py | 0 .../foo/coupons/migrations/__init__.py | 0 starting_app/foo/coupons/models.py | 0 starting_app/foo/coupons/tests.py | 0 starting_app/foo/coupons/urls.py | 0 starting_app/foo/coupons/views.py | 0 starting_app/foo/foo/__init__.py | 0 starting_app/foo/foo/settings.py | 120 ++++++++++++++++++ starting_app/foo/foo/urls.py | 21 +++ starting_app/foo/foo/wsgi.py | 16 +++ starting_app/foo/manage.py | 22 ++++ 11 files changed, 179 insertions(+) create mode 100644 starting_app/foo/coupons/__init__.py create mode 100644 starting_app/foo/coupons/migrations/__init__.py create mode 100644 starting_app/foo/coupons/models.py create mode 100644 starting_app/foo/coupons/tests.py create mode 100644 starting_app/foo/coupons/urls.py create mode 100644 starting_app/foo/coupons/views.py create mode 100644 starting_app/foo/foo/__init__.py create mode 100644 starting_app/foo/foo/settings.py create mode 100644 starting_app/foo/foo/urls.py create mode 100644 starting_app/foo/foo/wsgi.py create mode 100755 starting_app/foo/manage.py diff --git a/starting_app/foo/coupons/__init__.py b/starting_app/foo/coupons/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/starting_app/foo/coupons/migrations/__init__.py b/starting_app/foo/coupons/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/starting_app/foo/coupons/models.py b/starting_app/foo/coupons/models.py new file mode 100644 index 0000000..e69de29 diff --git a/starting_app/foo/coupons/tests.py b/starting_app/foo/coupons/tests.py new file mode 100644 index 0000000..e69de29 diff --git a/starting_app/foo/coupons/urls.py b/starting_app/foo/coupons/urls.py new file mode 100644 index 0000000..e69de29 diff --git a/starting_app/foo/coupons/views.py b/starting_app/foo/coupons/views.py new file mode 100644 index 0000000..e69de29 diff --git a/starting_app/foo/foo/__init__.py b/starting_app/foo/foo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/starting_app/foo/foo/settings.py b/starting_app/foo/foo/settings.py new file mode 100644 index 0000000..e003454 --- /dev/null +++ b/starting_app/foo/foo/settings.py @@ -0,0 +1,120 @@ +""" +Django settings for foo project. + +Generated by 'django-admin startproject' using Django 1.10.2. + +For more information on this file, see +https://docs.djangoproject.com/en/1.10/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.10/ref/settings/ +""" + +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = '=qp#2mo^t4yb+pm=or6fzrmd-9ae#3hs&)fw6*c4nl9dk7(ow5' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'foo.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'foo.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/1.10/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/1.10/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.10/howto/static-files/ + +STATIC_URL = '/static/' diff --git a/starting_app/foo/foo/urls.py b/starting_app/foo/foo/urls.py new file mode 100644 index 0000000..e00a244 --- /dev/null +++ b/starting_app/foo/foo/urls.py @@ -0,0 +1,21 @@ +"""foo URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.10/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django.conf.urls import url +from django.contrib import admin + +urlpatterns = [ + url(r'^admin/', admin.site.urls), +] diff --git a/starting_app/foo/foo/wsgi.py b/starting_app/foo/foo/wsgi.py new file mode 100644 index 0000000..168892c --- /dev/null +++ b/starting_app/foo/foo/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for foo project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "foo.settings") + +application = get_wsgi_application() diff --git a/starting_app/foo/manage.py b/starting_app/foo/manage.py new file mode 100755 index 0000000..671c67e --- /dev/null +++ b/starting_app/foo/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "foo.settings") + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) + raise + execute_from_command_line(sys.argv) From 66a932486de2dde03fd9ecb5c39f203c73b3b784 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Fri, 6 Jan 2017 20:53:35 -0800 Subject: [PATCH 02/46] .gitignore. --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 72364f9..4042179 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.idea/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] From e7b6276761f8141bf5cb3e702c78d31b02556658 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Fri, 6 Jan 2017 21:26:47 -0800 Subject: [PATCH 03/46] starting on some coupon stuff. --- starting_app/foo/coupons/models.py | 41 ++++++++++++++++++++++++++++++ starting_app/foo/foo/models.py | 0 starting_app/foo/foo/settings.py | 3 +++ starting_app/foo/foo/urls.py | 4 ++- starting_app/foo/foo/views.py | 0 5 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 starting_app/foo/foo/models.py create mode 100644 starting_app/foo/foo/views.py diff --git a/starting_app/foo/coupons/models.py b/starting_app/foo/coupons/models.py index e69de29..506228b 100644 --- a/starting_app/foo/coupons/models.py +++ b/starting_app/foo/coupons/models.py @@ -0,0 +1,41 @@ +from __future__ import unicode_literals + +from django.db import models + +COUPON_TYPES = ( + ('discount', 'discount'), + ('value', 'value'), +) + + +class Coupon(models.Model): + """ + These are the coupons that are in the system. + + - Coupons can be a value, or a percentage. + - They can be bound to a specific user in the system, or an email address (not yet in the system). + - They can be single-use per user, or single-use globally. + - They can be infinite per a specific user, or infinite globally. + - They can be used a specific number of times per user, or globally. + """ + + added = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + # The coupon code itself + code = models.CharField(max_length=64) + # the lowercase version to simplify some code (for now). + code_l = models.CharField(max_length=64) + + +class ClaimedCoupon(models.Model): + """ + These are the instances of claimed coupons. + """ + + added = models.DateTimeField(auto_now_add=True) + + # Every claimed coupon should point back to a Coupon in the system. + coupon = models.ForeignKey('Coupon') + + diff --git a/starting_app/foo/foo/models.py b/starting_app/foo/foo/models.py new file mode 100644 index 0000000..e69de29 diff --git a/starting_app/foo/foo/settings.py b/starting_app/foo/foo/settings.py index e003454..e330b97 100644 --- a/starting_app/foo/foo/settings.py +++ b/starting_app/foo/foo/settings.py @@ -37,6 +37,9 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'rest_framework', + 'coupons', + 'foo', ] MIDDLEWARE = [ diff --git a/starting_app/foo/foo/urls.py b/starting_app/foo/foo/urls.py index e00a244..768d572 100644 --- a/starting_app/foo/foo/urls.py +++ b/starting_app/foo/foo/urls.py @@ -13,9 +13,11 @@ 1. Import the include() function: from django.conf.urls import url, include 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) """ -from django.conf.urls import url +from django.conf.urls import url, include from django.contrib import admin urlpatterns = [ url(r'^admin/', admin.site.urls), + url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), + url(r'^coupons/', include('coupons.urls')), ] diff --git a/starting_app/foo/foo/views.py b/starting_app/foo/foo/views.py new file mode 100644 index 0000000..e69de29 From aa8fd29641123a41ac9ddff169e9a58f93bb6c74 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sat, 7 Jan 2017 20:00:44 -0800 Subject: [PATCH 04/46] Create README.md --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..97546ae --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# drf-coupons +A django-rest-framework application that provides many varieties of coupons + +It supports the following variations of coupons: + +1. Coupons can be a value, or a percentage. +2. They can be bound to a specific user in the system, or an email address (not yet in the system). +3. They can be single-use per user, or single-use globally. +4. They can be infinite per a specific user, or infinite globally. +5. They can be used a specific number of times per user, or globally. +6. (They can be used by a specific list of users?) ... maybe + +You create coupons in the system that are then claimed by users. From 4b75d877299cb2f2b3efda7471ed3906545d2d00 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sat, 7 Jan 2017 20:01:02 -0800 Subject: [PATCH 05/46] meh. --- starting_app/foo/coupons/models.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/starting_app/foo/coupons/models.py b/starting_app/foo/coupons/models.py index 506228b..bb98a7c 100644 --- a/starting_app/foo/coupons/models.py +++ b/starting_app/foo/coupons/models.py @@ -17,20 +17,24 @@ class Coupon(models.Model): - They can be single-use per user, or single-use globally. - They can be infinite per a specific user, or infinite globally. - They can be used a specific number of times per user, or globally. + - (They can be used by a specific list of users?) """ added = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) - # The coupon code itself + # The coupon code itself (so it can be mixed case in presentation... meh) code = models.CharField(max_length=64) # the lowercase version to simplify some code (for now). code_l = models.CharField(max_length=64) + # Whether it's a percentage off or a value. + type = models.CharField(choices=COUPON_TYPES) + class ClaimedCoupon(models.Model): """ - These are the instances of claimed coupons. + These are the instances of claimed coupons, each is an individual usage of a coupon by someone in the system. """ added = models.DateTimeField(auto_now_add=True) From dd601bcbb1fd1cbbfcfa6f9184ed91359a4d986b Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sat, 7 Jan 2017 21:07:57 -0800 Subject: [PATCH 06/46] starting serializers. --- starting_app/foo/coupons/models.py | 41 +++++++++++++++++++++++-- starting_app/foo/coupons/serializers.py | 28 +++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 starting_app/foo/coupons/serializers.py diff --git a/starting_app/foo/coupons/models.py b/starting_app/foo/coupons/models.py index bb98a7c..d9eecb9 100644 --- a/starting_app/foo/coupons/models.py +++ b/starting_app/foo/coupons/models.py @@ -3,10 +3,15 @@ from django.db import models COUPON_TYPES = ( - ('discount', 'discount'), + ('percent', 'percent'), ('value', 'value'), ) +BINDING_TYPES = ( + ('user', 'user'), + ('email', 'email'), +) + class Coupon(models.Model): """ @@ -20,7 +25,7 @@ class Coupon(models.Model): - (They can be used by a specific list of users?) """ - added = models.DateTimeField(auto_now_add=True) + created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) # The coupon code itself (so it can be mixed case in presentation... meh) @@ -31,13 +36,43 @@ class Coupon(models.Model): # Whether it's a percentage off or a value. type = models.CharField(choices=COUPON_TYPES) + # When it expires (if it expires) + expires = models.DateTimeField() + + # Is this coupon bound to a specific user? + bound = models.BooleanField(default=False) + bind = models.CharField(choices=BINDING_TYPES, default='user') + binding = models.CharField(max_length=256, blank=True, null=True) + # We'll validate the binding's value based on the type, either it's an int that is a user's pk or a valid email + # string. You'll be able to query for coupons with a binding value that's a pk, because you can just provide an + # int via the URL query string and it should process it properly. + + # How many times this coupon can be used, -1 == infinitely, otherwise it's a number, such as 1 or many. + # To determine if you can redeem it, it'll check this value against the number of corresponding ClaimedCoupons. + repeat = models.IntegerField(default=-1) + + # single-use per user + # repeat = 1, bound = True, binding = user_id + # single-use globally + # repeat = 1, bound = False + + # infinite-user per user + # repeat = -1, bound = True + # infinite globally + # repeat = -1, bound = False + + # specific number of times per user + # repeat => 0, bound = True, binding = user_id + # specific number of times globally + # repeat => 0, bound = False + class ClaimedCoupon(models.Model): """ These are the instances of claimed coupons, each is an individual usage of a coupon by someone in the system. """ - added = models.DateTimeField(auto_now_add=True) + redeemed = models.DateTimeField(auto_now_add=True) # Every claimed coupon should point back to a Coupon in the system. coupon = models.ForeignKey('Coupon') diff --git a/starting_app/foo/coupons/serializers.py b/starting_app/foo/coupons/serializers.py new file mode 100644 index 0000000..1da4a91 --- /dev/null +++ b/starting_app/foo/coupons/serializers.py @@ -0,0 +1,28 @@ +from django.apps import apps +from rest_framework import serializers + +from coupons.models import * + + +class CouponSerializer(serializers.HyperlinkedModelSerializer): + """ + RW Coupon serializer. + """ + + class Meta: + model = apps.get_model('coupons.Coupon') + fields = ('created', 'updated', 'code', + 'code_l', 'type', 'expires', + 'bound', 'bind', 'binding', + 'repeat', 'id') + + +class ClaimedCouponSerializer(serializers.HyperlinkedModelSerializer): + """ + RW ClaimedCoupon serializer. + """ + + class Meta: + model = apps.get_model('coupons.ClaimedCoupon') + fields = ('redeemed', 'coupon', 'id') + From 766f0e1560b6f9d57986f91f0f403e2242c9c276 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sat, 7 Jan 2017 21:14:17 -0800 Subject: [PATCH 07/46] some initial views to help move coupons on. --- .gitignore | 1 + .../foo/coupons/migrations/0001_initial.py | 45 +++++++++++++++++++ starting_app/foo/coupons/models.py | 4 +- starting_app/foo/coupons/urls.py | 27 +++++++++++ starting_app/foo/coupons/views.py | 13 ++++++ 5 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 starting_app/foo/coupons/migrations/0001_initial.py diff --git a/.gitignore b/.gitignore index 4042179..bd5e572 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea/ +db.sqlite3 # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/starting_app/foo/coupons/migrations/0001_initial.py b/starting_app/foo/coupons/migrations/0001_initial.py new file mode 100644 index 0000000..1224bc6 --- /dev/null +++ b/starting_app/foo/coupons/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2017-01-08 05:13 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='ClaimedCoupon', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('redeemed', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='Coupon', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('code', models.CharField(max_length=64)), + ('code_l', models.CharField(max_length=64)), + ('type', models.CharField(choices=[('percent', 'percent'), ('value', 'value')], max_length=16)), + ('expires', models.DateTimeField()), + ('bound', models.BooleanField(default=False)), + ('bind', models.CharField(choices=[('user', 'user'), ('email', 'email')], default='user', max_length=16)), + ('binding', models.CharField(blank=True, max_length=256, null=True)), + ('repeat', models.IntegerField(default=-1)), + ], + ), + migrations.AddField( + model_name='claimedcoupon', + name='coupon', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='coupons.Coupon'), + ), + ] diff --git a/starting_app/foo/coupons/models.py b/starting_app/foo/coupons/models.py index d9eecb9..1c342d7 100644 --- a/starting_app/foo/coupons/models.py +++ b/starting_app/foo/coupons/models.py @@ -34,14 +34,14 @@ class Coupon(models.Model): code_l = models.CharField(max_length=64) # Whether it's a percentage off or a value. - type = models.CharField(choices=COUPON_TYPES) + type = models.CharField(max_length=16, choices=COUPON_TYPES) # When it expires (if it expires) expires = models.DateTimeField() # Is this coupon bound to a specific user? bound = models.BooleanField(default=False) - bind = models.CharField(choices=BINDING_TYPES, default='user') + bind = models.CharField(max_length=16, choices=BINDING_TYPES, default='user') binding = models.CharField(max_length=256, blank=True, null=True) # We'll validate the binding's value based on the type, either it's an int that is a user's pk or a valid email # string. You'll be able to query for coupons with a binding value that's a pk, because you can just provide an diff --git a/starting_app/foo/coupons/urls.py b/starting_app/foo/coupons/urls.py index e69de29..22c839b 100644 --- a/starting_app/foo/coupons/urls.py +++ b/starting_app/foo/coupons/urls.py @@ -0,0 +1,27 @@ +"""foo URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.10/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django.conf.urls import url, include +from django.contrib import admin +from rest_framework import routers + +import views + +router = routers.DefaultRouter(trailing_slash=False) +router.register(r'coupon', views.CouponViewSet, base_name='coupon') + +urlpatterns = [ + url(r'^', include(router.urls)), +] diff --git a/starting_app/foo/coupons/views.py b/starting_app/foo/coupons/views.py index e69de29..aecdb5e 100644 --- a/starting_app/foo/coupons/views.py +++ b/starting_app/foo/coupons/views.py @@ -0,0 +1,13 @@ +from rest_framework import viewsets + +from coupons.models import Coupon +from coupons.serializers import CouponSerializer + + +class CouponViewSet(viewsets.ModelViewSet): + """ + API endpoint that lets a user manipulate their folders. + """ + + serializer_class = CouponSerializer + queryset = Coupon.objects.all() From 0cb0e916d02fccc14408e05396e1d1a35ccf93ed Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 8 Jan 2017 18:59:50 -0800 Subject: [PATCH 08/46] Starting to write some unit tests for the coupon application. --- README.md | 14 +- .../foo/coupons/migrations/0001_initial.py | 17 +- starting_app/foo/coupons/models.py | 32 ++- starting_app/foo/coupons/serializers.py | 58 +++++- .../coupons/{tests.py => tests/__init__.py} | 0 starting_app/foo/coupons/tests/base.py | 19 ++ .../foo/coupons/tests/test_coupon_create.py | 187 ++++++++++++++++++ .../foo/coupons/tests/test_coupon_update.py | 32 +++ starting_app/foo/foo/urls.py | 2 +- 9 files changed, 339 insertions(+), 22 deletions(-) rename starting_app/foo/coupons/{tests.py => tests/__init__.py} (100%) create mode 100644 starting_app/foo/coupons/tests/base.py create mode 100644 starting_app/foo/coupons/tests/test_coupon_create.py create mode 100644 starting_app/foo/coupons/tests/test_coupon_update.py diff --git a/README.md b/README.md index 97546ae..842be69 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,15 @@ It supports the following variations of coupons: 1. Coupons can be a value, or a percentage. 2. They can be bound to a specific user in the system, or an email address (not yet in the system). -3. They can be single-use per user, or single-use globally. -4. They can be infinite per a specific user, or infinite globally. -5. They can be used a specific number of times per user, or globally. -6. (They can be used by a specific list of users?) ... maybe +3. They can be single-use: + 1. per user (a pre-specified user can use it once), `case I` + 2. globally (any user in the system can use it, but only oncee) `case II` +4. They can be infinite: + 1. per a specific user (a pre-specified user can use it repeatedly infinitely) `case III` + 2. infinite globally (any user can use it repeatedly infinitely) `case IV` +5. They can be used a specific number of times: + 1. per user (a pre-specified user can use it a specific number of times) `case V` + 2. globally (any user can use it a specific number of times) `case VI` +6. (They can be used by a specific list of users?) ... maybe later. You create coupons in the system that are then claimed by users. diff --git a/starting_app/foo/coupons/migrations/0001_initial.py b/starting_app/foo/coupons/migrations/0001_initial.py index 1224bc6..cfd5000 100644 --- a/starting_app/foo/coupons/migrations/0001_initial.py +++ b/starting_app/foo/coupons/migrations/0001_initial.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.2 on 2017-01-08 05:13 +# Generated by Django 1.10.2 on 2017-01-09 02:54 from __future__ import unicode_literals +from django.conf import settings from django.db import migrations, models import django.db.models.deletion @@ -11,6 +12,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -28,13 +30,15 @@ class Migration(migrations.Migration): ('created', models.DateTimeField(auto_now_add=True)), ('updated', models.DateTimeField(auto_now=True)), ('code', models.CharField(max_length=64)), - ('code_l', models.CharField(max_length=64)), + ('code_l', models.CharField(blank=True, max_length=64, unique=True)), ('type', models.CharField(choices=[('percent', 'percent'), ('value', 'value')], max_length=16)), - ('expires', models.DateTimeField()), + ('expires', models.DateTimeField(blank=True, null=True)), + ('value', models.DecimalField(decimal_places=2, default=0.0, max_digits=5)), ('bound', models.BooleanField(default=False)), ('bind', models.CharField(choices=[('user', 'user'), ('email', 'email')], default='user', max_length=16)), - ('binding', models.CharField(blank=True, max_length=256, null=True)), + ('binding_email', models.EmailField(blank=True, max_length=256, null=True)), ('repeat', models.IntegerField(default=-1)), + ('binding_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), migrations.AddField( @@ -42,4 +46,9 @@ class Migration(migrations.Migration): name='coupon', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='coupons.Coupon'), ), + migrations.AddField( + model_name='claimedcoupon', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), ] diff --git a/starting_app/foo/coupons/models.py b/starting_app/foo/coupons/models.py index 1c342d7..1435916 100644 --- a/starting_app/foo/coupons/models.py +++ b/starting_app/foo/coupons/models.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +from django.conf import settings +from django.contrib.auth import get_user_model from django.db import models COUPON_TYPES = ( @@ -13,6 +15,17 @@ ) +try: + user = settings.AUTH_USER_MODEL +except AttributeError: + from django.contrib.auth.models import User as user + +#user = get_user_model() + +import sys +sys.stderr.write('user: %s\n' % user) + + class Coupon(models.Model): """ These are the coupons that are in the system. @@ -31,21 +44,25 @@ class Coupon(models.Model): # The coupon code itself (so it can be mixed case in presentation... meh) code = models.CharField(max_length=64) # the lowercase version to simplify some code (for now). - code_l = models.CharField(max_length=64) + # + # usually blank=True goes with null=True, but in this case, we want the admin to know it's optional, but the + # database does require it, and it needs to be unique. + code_l = models.CharField(max_length=64, blank=True, unique=True) # Whether it's a percentage off or a value. type = models.CharField(max_length=16, choices=COUPON_TYPES) # When it expires (if it expires) - expires = models.DateTimeField() + expires = models.DateTimeField(blank=True, null=True) + + # The values (either percentage based, or value based), if percentage based make sure it's no greater than 1.0 + value = models.DecimalField(default=0.0, max_digits=5, decimal_places=2) # Is this coupon bound to a specific user? bound = models.BooleanField(default=False) bind = models.CharField(max_length=16, choices=BINDING_TYPES, default='user') - binding = models.CharField(max_length=256, blank=True, null=True) - # We'll validate the binding's value based on the type, either it's an int that is a user's pk or a valid email - # string. You'll be able to query for coupons with a binding value that's a pk, because you can just provide an - # int via the URL query string and it should process it properly. + binding_email = models.EmailField(max_length=256, blank=True, null=True) + binding_user = models.ForeignKey(user, blank=True, null=True) # How many times this coupon can be used, -1 == infinitely, otherwise it's a number, such as 1 or many. # To determine if you can redeem it, it'll check this value against the number of corresponding ClaimedCoupons. @@ -76,5 +93,4 @@ class ClaimedCoupon(models.Model): # Every claimed coupon should point back to a Coupon in the system. coupon = models.ForeignKey('Coupon') - - + user = models.ForeignKey(user) diff --git a/starting_app/foo/coupons/serializers.py b/starting_app/foo/coupons/serializers.py index 1da4a91..9610068 100644 --- a/starting_app/foo/coupons/serializers.py +++ b/starting_app/foo/coupons/serializers.py @@ -1,23 +1,71 @@ from django.apps import apps +from django.utils.timezone import now from rest_framework import serializers -from coupons.models import * +from coupons.models import Coupon, ClaimedCoupon -class CouponSerializer(serializers.HyperlinkedModelSerializer): +class CouponSerializer(serializers.ModelSerializer): """ RW Coupon serializer. """ + def validate(self, data): + """ + Verify the input used to create or update the coupon is valid. Because we don't support PATCH for the binding + field, we don't need to check self.instance for this. + """ + + # Verify if the expiration date is set that it's in the future. + if 'expires' in data: + if data['expires'] < now(): + raise serializers.ValidationError("Expiration date set in the past.") + + # Verify if it's type is 'percentage' that the percentage value is set + # Verify if it's type is 'value' that the value is set. + if data['type'] == 'percent': + if 'value' in data and data['value'] > 1.0: + raise serializers.ValidationError("Percentage discount specified greater than 100%.") + + # Verify if it's bound, that the user exists or the email is valid. + if 'bound' in data and data['bound']: + if data['bind'] == 'user': + if 'binding_user' not in data: + raise serializers.ValidationError("Bound to user, but binding_user field not specified.") + elif data['bind'] == 'email': + if 'binding_email' not in data: + raise serializers.ValidationError("Bound to email, but binding_email field not specified.") + + return data + + def validate_code(self, value): + """ + An explicit check here, because it was just throwing: + IntegrityError: UNIQUE constraint failed: coupons_coupon.code_l and not returning 400. + """ + + if Coupon.objects.filter(code_l=value.lower()).count() > 0: + raise serializers.ValidationError("Coupon code violate uniqueness constraint.") + + return value + + def create(self, validated_data): + validated_data['code_l'] = validated_data['code'].lower() + + coupon = Coupon.objects.create(**validated_data) + + return coupon + class Meta: model = apps.get_model('coupons.Coupon') fields = ('created', 'updated', 'code', 'code_l', 'type', 'expires', - 'bound', 'bind', 'binding', - 'repeat', 'id') + 'bound', 'bind', 'binding_email', + 'binding_user', 'repeat', 'value', + 'id') -class ClaimedCouponSerializer(serializers.HyperlinkedModelSerializer): +class ClaimedCouponSerializer(serializers.ModelSerializer): """ RW ClaimedCoupon serializer. """ diff --git a/starting_app/foo/coupons/tests.py b/starting_app/foo/coupons/tests/__init__.py similarity index 100% rename from starting_app/foo/coupons/tests.py rename to starting_app/foo/coupons/tests/__init__.py diff --git a/starting_app/foo/coupons/tests/base.py b/starting_app/foo/coupons/tests/base.py new file mode 100644 index 0000000..a6bb8d0 --- /dev/null +++ b/starting_app/foo/coupons/tests/base.py @@ -0,0 +1,19 @@ +from rest_framework.test import APITestCase + + +class BasicTest(APITestCase): + """ + Generic testing stuff. + """ + + PW = 'password123' + + def login(self, username): + self.client.login(username=username, password=self.PW) + + def logout(self): + self.client.logout() + + def verify_built(self, expected, data): + for key in expected: + self.assertEqual(data[key], expected[key]) diff --git a/starting_app/foo/coupons/tests/test_coupon_create.py b/starting_app/foo/coupons/tests/test_coupon_create.py new file mode 100644 index 0000000..5728e9b --- /dev/null +++ b/starting_app/foo/coupons/tests/test_coupon_create.py @@ -0,0 +1,187 @@ +from django.contrib.auth import get_user_model +from rest_framework import status +from datetime import datetime, timedelta + +from coupons.tests.base import BasicTest + + +class CouponCreateTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', 'password123') + self.user = u.objects.create_user('user', 'me@snow.com', 'password123') + + def test_can_create_coupon(self): + """ + Create a coupon that is globally bound and infinite. + """ + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = -1 + coupon['bind'] = 'user' + coupon['bound'] = False + + self.verify_built(coupon, response.data) + + def test_can_create_coupon_lowercase_verification(self): + """ + Verify creating a coupon creates a lowercase version if it's identifier. + """ + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = -1 + coupon['bind'] = 'user' + coupon['bound'] = False + + self.verify_built(coupon, response.data) + + def test_cant_create_coupon_duplicate_name(self): + """ + Verify that we ensure uniqueness of coupon code. + """ + + # Create initial one. + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = -1 + coupon['bind'] = 'user' + coupon['bound'] = False + + self.verify_built(coupon, response.data) + + # Create duplicate. + coupon2 = { + 'code': 'AsdF', + 'type': 'percent', + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon2, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.logout() + + def test_can_create_coupon_expires(self): + """ + Verify you can set an expiration date, but it must be in the future. + """ + + future = datetime.utcnow() + timedelta(days=14) + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + 'expires': str(future), + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + def test_cant_create_coupon_after_expiration(self): + """ + Verify you can't set an expiration date in the past. + """ + + past = datetime.utcnow() + timedelta(days=-1) + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + 'expires': str(past), + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.logout() + + def test_can_create_coupon_case_i(self): + """ + Handle case I defined in the README. + """ + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + 'bound': True, + 'bind': 'user', + 'binding_user': self.user.id, + 'repeat': 1, + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + + self.verify_built(coupon, response.data) + + def test_can_create_coupon_case_ii(self): + """ + Handle case II defined in the README. + """ + + self.assertTrue(True) + + def test_can_create_coupon_case_iii(self): + """ + Handle case III defined in the README. + """ + + self.assertTrue(True) + + def test_can_create_coupon_case_iv(self): + """ + Handle case IV defined in the README. + """ + + self.assertTrue(True) + + def test_can_create_coupon_case_v(self): + """ + Handle case V defined in the README. + """ + + self.assertTrue(True) + + def test_can_create_coupon_case_vi(self): + """ + Handle case VI defined in the README. + """ + + self.assertTrue(True) + + diff --git a/starting_app/foo/coupons/tests/test_coupon_update.py b/starting_app/foo/coupons/tests/test_coupon_update.py new file mode 100644 index 0000000..e8fb53c --- /dev/null +++ b/starting_app/foo/coupons/tests/test_coupon_update.py @@ -0,0 +1,32 @@ +from django.contrib.auth import get_user_model +from rest_framework import status + +from coupons.tests.base import BasicTest + + +class CouponCreateTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', 'password123') + + def test_can_update_coupon(self): + """ + Verify we can update a coupon. + """ + + self.assertTrue(True) + + def test_can_update_coupon_lowercase_verification(self): + """ + Verify if we update the coupon code, its lowercase version is updated. + """ + + self.assertTrue(True) + + def test_cant_update_coupon_duplicate_name(self): + """ + Verify you can't update the coupon code to become a duplicate. + """ + + self.assertTrue(True) diff --git a/starting_app/foo/foo/urls.py b/starting_app/foo/foo/urls.py index 768d572..b0a1e63 100644 --- a/starting_app/foo/foo/urls.py +++ b/starting_app/foo/foo/urls.py @@ -19,5 +19,5 @@ urlpatterns = [ url(r'^admin/', admin.site.urls), url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), - url(r'^coupons/', include('coupons.urls')), + url(r'^', include('coupons.urls')), ] From e29b65920d9f4c2bad3dc0f80bf605ce8b1d8c4c Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 8 Jan 2017 19:02:55 -0800 Subject: [PATCH 09/46] some tweaks to the models. --- starting_app/foo/coupons/migrations/0001_initial.py | 6 +++--- starting_app/foo/coupons/models.py | 9 +++------ starting_app/foo/coupons/serializers.py | 12 ++++++------ starting_app/foo/coupons/tests/test_coupon_create.py | 12 ++++++------ 4 files changed, 18 insertions(+), 21 deletions(-) diff --git a/starting_app/foo/coupons/migrations/0001_initial.py b/starting_app/foo/coupons/migrations/0001_initial.py index cfd5000..08487f8 100644 --- a/starting_app/foo/coupons/migrations/0001_initial.py +++ b/starting_app/foo/coupons/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.2 on 2017-01-09 02:54 +# Generated by Django 1.10.2 on 2017-01-09 03:02 from __future__ import unicode_literals from django.conf import settings @@ -36,9 +36,9 @@ class Migration(migrations.Migration): ('value', models.DecimalField(decimal_places=2, default=0.0, max_digits=5)), ('bound', models.BooleanField(default=False)), ('bind', models.CharField(choices=[('user', 'user'), ('email', 'email')], default='user', max_length=16)), - ('binding_email', models.EmailField(blank=True, max_length=256, null=True)), + ('email', models.EmailField(blank=True, max_length=256, null=True)), ('repeat', models.IntegerField(default=-1)), - ('binding_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), migrations.AddField( diff --git a/starting_app/foo/coupons/models.py b/starting_app/foo/coupons/models.py index 1435916..7b09459 100644 --- a/starting_app/foo/coupons/models.py +++ b/starting_app/foo/coupons/models.py @@ -18,14 +18,11 @@ try: user = settings.AUTH_USER_MODEL except AttributeError: + # get_user_model isn't working at this point in loading. from django.contrib.auth.models import User as user #user = get_user_model() -import sys -sys.stderr.write('user: %s\n' % user) - - class Coupon(models.Model): """ These are the coupons that are in the system. @@ -61,8 +58,8 @@ class Coupon(models.Model): # Is this coupon bound to a specific user? bound = models.BooleanField(default=False) bind = models.CharField(max_length=16, choices=BINDING_TYPES, default='user') - binding_email = models.EmailField(max_length=256, blank=True, null=True) - binding_user = models.ForeignKey(user, blank=True, null=True) + email = models.EmailField(max_length=256, blank=True, null=True) + user = models.ForeignKey(user, blank=True, null=True) # How many times this coupon can be used, -1 == infinitely, otherwise it's a number, such as 1 or many. # To determine if you can redeem it, it'll check this value against the number of corresponding ClaimedCoupons. diff --git a/starting_app/foo/coupons/serializers.py b/starting_app/foo/coupons/serializers.py index 9610068..7c828e7 100644 --- a/starting_app/foo/coupons/serializers.py +++ b/starting_app/foo/coupons/serializers.py @@ -30,11 +30,11 @@ def validate(self, data): # Verify if it's bound, that the user exists or the email is valid. if 'bound' in data and data['bound']: if data['bind'] == 'user': - if 'binding_user' not in data: - raise serializers.ValidationError("Bound to user, but binding_user field not specified.") + if 'user' not in data: + raise serializers.ValidationError("Bound to user, but user field not specified.") elif data['bind'] == 'email': - if 'binding_email' not in data: - raise serializers.ValidationError("Bound to email, but binding_email field not specified.") + if 'email' not in data: + raise serializers.ValidationError("Bound to email, but email field not specified.") return data @@ -60,8 +60,8 @@ class Meta: model = apps.get_model('coupons.Coupon') fields = ('created', 'updated', 'code', 'code_l', 'type', 'expires', - 'bound', 'bind', 'binding_email', - 'binding_user', 'repeat', 'value', + 'bound', 'bind', 'email', + 'user', 'repeat', 'value', 'id') diff --git a/starting_app/foo/coupons/tests/test_coupon_create.py b/starting_app/foo/coupons/tests/test_coupon_create.py index 5728e9b..0430a2d 100644 --- a/starting_app/foo/coupons/tests/test_coupon_create.py +++ b/starting_app/foo/coupons/tests/test_coupon_create.py @@ -132,12 +132,12 @@ def test_can_create_coupon_case_i(self): """ coupon = { - 'code': 'ASDF', - 'type': 'percent', - 'bound': True, - 'bind': 'user', - 'binding_user': self.user.id, - 'repeat': 1, + 'code': 'ASDF', + 'type': 'percent', + 'bound': True, + 'bind': 'user', + 'user': self.user.id, + 'repeat': 1, } self.login(username='admin') From 83b038f4ab402fc47ea4c58c9176c184da184db1 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 8 Jan 2017 19:23:55 -0800 Subject: [PATCH 10/46] Specifying the repeat field is failing with NOT NULL constraint. --- README.md | 2 +- .../foo/coupons/migrations/0001_initial.py | 2 +- starting_app/foo/coupons/models.py | 12 +++++------ starting_app/foo/coupons/serializers.py | 8 +++++++ .../foo/coupons/tests/test_coupon_create.py | 21 +++++++++++++++---- 5 files changed, 33 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 842be69..99b4ae2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ It supports the following variations of coupons: 2. They can be bound to a specific user in the system, or an email address (not yet in the system). 3. They can be single-use: 1. per user (a pre-specified user can use it once), `case I` - 2. globally (any user in the system can use it, but only oncee) `case II` + 2. globally (any user in the system can use it, but only once) `case II` 4. They can be infinite: 1. per a specific user (a pre-specified user can use it repeatedly infinitely) `case III` 2. infinite globally (any user can use it repeatedly infinitely) `case IV` diff --git a/starting_app/foo/coupons/migrations/0001_initial.py b/starting_app/foo/coupons/migrations/0001_initial.py index 08487f8..3ea55c9 100644 --- a/starting_app/foo/coupons/migrations/0001_initial.py +++ b/starting_app/foo/coupons/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.2 on 2017-01-09 03:02 +# Generated by Django 1.10.2 on 2017-01-09 03:17 from __future__ import unicode_literals from django.conf import settings diff --git a/starting_app/foo/coupons/models.py b/starting_app/foo/coupons/models.py index 7b09459..25e1f62 100644 --- a/starting_app/foo/coupons/models.py +++ b/starting_app/foo/coupons/models.py @@ -61,9 +61,9 @@ class Coupon(models.Model): email = models.EmailField(max_length=256, blank=True, null=True) user = models.ForeignKey(user, blank=True, null=True) - # How many times this coupon can be used, -1 == infinitely, otherwise it's a number, such as 1 or many. + # How many times this coupon can be used, 0 == infinitely, otherwise it's a number, such as 1 or many. # To determine if you can redeem it, it'll check this value against the number of corresponding ClaimedCoupons. - repeat = models.IntegerField(default=-1) + repeat = models.IntegerField(default=0) # single-use per user # repeat = 1, bound = True, binding = user_id @@ -71,14 +71,14 @@ class Coupon(models.Model): # repeat = 1, bound = False # infinite-user per user - # repeat = -1, bound = True + # repeat = 0, bound = True # infinite globally - # repeat = -1, bound = False + # repeat = 0, bound = False # specific number of times per user - # repeat => 0, bound = True, binding = user_id + # repeat => X, bound = True, binding = user_id # specific number of times globally - # repeat => 0, bound = False + # repeat => X, bound = False class ClaimedCoupon(models.Model): diff --git a/starting_app/foo/coupons/serializers.py b/starting_app/foo/coupons/serializers.py index 7c828e7..59e3d3e 100644 --- a/starting_app/foo/coupons/serializers.py +++ b/starting_app/foo/coupons/serializers.py @@ -38,6 +38,14 @@ def validate(self, data): return data + def validate_repeat(self, value): + """ + Validate that if it's specified it can be -1, 1, or more than that, but not zero. + """ + + if value < 0: + raise serializers.ValidationError("Repeat field can be 0 for infinite, otherwise must be greater than 0.") + def validate_code(self, value): """ An explicit check here, because it was just throwing: diff --git a/starting_app/foo/coupons/tests/test_coupon_create.py b/starting_app/foo/coupons/tests/test_coupon_create.py index 0430a2d..c17afbe 100644 --- a/starting_app/foo/coupons/tests/test_coupon_create.py +++ b/starting_app/foo/coupons/tests/test_coupon_create.py @@ -28,7 +28,7 @@ def test_can_create_coupon(self): self.logout() coupon['code_l'] = coupon['code'].lower() - coupon['repeat'] = -1 + coupon['repeat'] = 0 coupon['bind'] = 'user' coupon['bound'] = False @@ -50,7 +50,7 @@ def test_can_create_coupon_lowercase_verification(self): self.logout() coupon['code_l'] = coupon['code'].lower() - coupon['repeat'] = -1 + coupon['repeat'] = 0 coupon['bind'] = 'user' coupon['bound'] = False @@ -73,7 +73,7 @@ def test_cant_create_coupon_duplicate_name(self): self.logout() coupon['code_l'] = coupon['code'].lower() - coupon['repeat'] = -1 + coupon['repeat'] = 0 coupon['bind'] = 'user' coupon['bound'] = False @@ -154,7 +154,20 @@ def test_can_create_coupon_case_ii(self): Handle case II defined in the README. """ - self.assertTrue(True) + coupon = { + 'code': 'ASDF', + 'type': 'percent', + 'repeat': 1, + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + + self.verify_built(coupon, response.data) def test_can_create_coupon_case_iii(self): """ From 7e4f8e0e8870b531a1a3f8280cec42fa4aeea5aa Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 8 Jan 2017 19:34:57 -0800 Subject: [PATCH 11/46] Fixed my mistake, lol. :) I love these types of bugs while I'm working on something ;) --- starting_app/foo/coupons/migrations/0001_initial.py | 4 ++-- starting_app/foo/coupons/serializers.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/starting_app/foo/coupons/migrations/0001_initial.py b/starting_app/foo/coupons/migrations/0001_initial.py index 3ea55c9..55568c0 100644 --- a/starting_app/foo/coupons/migrations/0001_initial.py +++ b/starting_app/foo/coupons/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.2 on 2017-01-09 03:17 +# Generated by Django 1.10.2 on 2017-01-09 03:30 from __future__ import unicode_literals from django.conf import settings @@ -37,7 +37,7 @@ class Migration(migrations.Migration): ('bound', models.BooleanField(default=False)), ('bind', models.CharField(choices=[('user', 'user'), ('email', 'email')], default='user', max_length=16)), ('email', models.EmailField(blank=True, max_length=256, null=True)), - ('repeat', models.IntegerField(default=-1)), + ('repeat', models.IntegerField(default=0)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), diff --git a/starting_app/foo/coupons/serializers.py b/starting_app/foo/coupons/serializers.py index 59e3d3e..bead94e 100644 --- a/starting_app/foo/coupons/serializers.py +++ b/starting_app/foo/coupons/serializers.py @@ -46,6 +46,8 @@ def validate_repeat(self, value): if value < 0: raise serializers.ValidationError("Repeat field can be 0 for infinite, otherwise must be greater than 0.") + return value + def validate_code(self, value): """ An explicit check here, because it was just throwing: From 701103d0491a29dcd03c3882f68a5af0290726af Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 8 Jan 2017 19:37:14 -0800 Subject: [PATCH 12/46] disable PATCH. --- starting_app/foo/coupons/views.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/starting_app/foo/coupons/views.py b/starting_app/foo/coupons/views.py index aecdb5e..d00eda1 100644 --- a/starting_app/foo/coupons/views.py +++ b/starting_app/foo/coupons/views.py @@ -1,4 +1,6 @@ +from rest_framework import status from rest_framework import viewsets +from rest_framework.response import Response from coupons.models import Coupon from coupons.serializers import CouponSerializer @@ -11,3 +13,7 @@ class CouponViewSet(viewsets.ModelViewSet): serializer_class = CouponSerializer queryset = Coupon.objects.all() + + def partial_update(self, request, pk=None, **kwargs): + return Response(status=status.HTTP_404_NOT_FOUND) + From 25fc499fbee6465400aff33e5ee6f5bffdab18a0 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 8 Jan 2017 19:47:16 -0800 Subject: [PATCH 13/46] Yay, unit-tests. --- .../foo/coupons/tests/test_coupon_create.py | 80 ++++++++++++++++++- .../foo/coupons/tests/test_coupon_update.py | 2 +- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/starting_app/foo/coupons/tests/test_coupon_create.py b/starting_app/foo/coupons/tests/test_coupon_create.py index c17afbe..af0bd49 100644 --- a/starting_app/foo/coupons/tests/test_coupon_create.py +++ b/starting_app/foo/coupons/tests/test_coupon_create.py @@ -90,6 +90,22 @@ def test_cant_create_coupon_duplicate_name(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.logout() + def test_cant_create_coupon_invalid_percentage(self): + """ + Verify you can't provide an invalid percentage. + """ + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + 'value': 2, + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.logout() + def test_can_create_coupon_expires(self): """ Verify you can set an expiration date, but it must be in the future. @@ -174,27 +190,83 @@ def test_can_create_coupon_case_iii(self): Handle case III defined in the README. """ - self.assertTrue(True) + coupon = { + 'code': 'ASDF', + 'type': 'percent', + 'bound': True, + 'bind': 'user', + 'user': self.user.id, + 'repeat': 0, + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + + self.verify_built(coupon, response.data) def test_can_create_coupon_case_iv(self): """ Handle case IV defined in the README. """ - self.assertTrue(True) + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + + self.verify_built(coupon, response.data) def test_can_create_coupon_case_v(self): """ Handle case V defined in the README. """ - self.assertTrue(True) + coupon = { + 'code': 'ASDF', + 'type': 'percent', + 'bound': True, + 'bind': 'user', + 'user': self.user.id, + 'repeat': 10, + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + + self.verify_built(coupon, response.data) def test_can_create_coupon_case_vi(self): """ Handle case VI defined in the README. """ - self.assertTrue(True) + coupon = { + 'code': 'ASDF', + 'type': 'percent', + 'repeat': 10, + } + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + + self.verify_built(coupon, response.data) diff --git a/starting_app/foo/coupons/tests/test_coupon_update.py b/starting_app/foo/coupons/tests/test_coupon_update.py index e8fb53c..ac5ed76 100644 --- a/starting_app/foo/coupons/tests/test_coupon_update.py +++ b/starting_app/foo/coupons/tests/test_coupon_update.py @@ -4,7 +4,7 @@ from coupons.tests.base import BasicTest -class CouponCreateTests(BasicTest): +class CouponUpdateTests(BasicTest): def setUp(self): u = get_user_model() From 875bf596b33a95325fa29f192d098f4c1df21acd Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 8 Jan 2017 19:59:12 -0800 Subject: [PATCH 14/46] Forcing the Coupon view to return 202 on update success, instead of 200, for some reason. --- starting_app/foo/coupons/serializers.py | 22 +++++++-------- .../foo/coupons/tests/test_coupon_update.py | 28 ++++++++++++++++++- starting_app/foo/coupons/views.py | 16 +++++++++++ 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/starting_app/foo/coupons/serializers.py b/starting_app/foo/coupons/serializers.py index bead94e..efdfcd8 100644 --- a/starting_app/foo/coupons/serializers.py +++ b/starting_app/foo/coupons/serializers.py @@ -36,6 +36,17 @@ def validate(self, data): if 'email' not in data: raise serializers.ValidationError("Bound to email, but email field not specified.") + # Verify the lowercase code is unique. + # IntegrityError: UNIQUE constraint failed: coupons_coupon.code_l and not returning 400. + qs = Coupon.objects.filter(code_l=data['code'].lower()) + if qs.count() > 0: + # there was a matching one, is it this one? + if self.instance: + if data['code'].lower() != self.instance.code_l: + raise serializers.ValidationError("Coupon code being updated to a code that already exists.") + else: + raise serializers.ValidationError("Creating coupon with code that violates uniqueness constraint.") + return data def validate_repeat(self, value): @@ -48,17 +59,6 @@ def validate_repeat(self, value): return value - def validate_code(self, value): - """ - An explicit check here, because it was just throwing: - IntegrityError: UNIQUE constraint failed: coupons_coupon.code_l and not returning 400. - """ - - if Coupon.objects.filter(code_l=value.lower()).count() > 0: - raise serializers.ValidationError("Coupon code violate uniqueness constraint.") - - return value - def create(self, validated_data): validated_data['code_l'] = validated_data['code'].lower() diff --git a/starting_app/foo/coupons/tests/test_coupon_update.py b/starting_app/foo/coupons/tests/test_coupon_update.py index ac5ed76..3499a75 100644 --- a/starting_app/foo/coupons/tests/test_coupon_update.py +++ b/starting_app/foo/coupons/tests/test_coupon_update.py @@ -9,13 +9,37 @@ class CouponUpdateTests(BasicTest): def setUp(self): u = get_user_model() u.objects.create_superuser('admin', 'john@snow.com', 'password123') + self.user = u.objects.create_user('user', 'me@snow.com', 'password123') def test_can_update_coupon(self): """ Verify we can update a coupon. """ - self.assertTrue(True) + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bind'] = 'user' + coupon['bound'] = False + + self.verify_built(coupon, response.data) + + coupon['repeat'] = 50 + self.login(username='admin') + response = self.client.put('/coupon/%s' % response.data['id'], coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.logout() + + self.verify_built(coupon, response.data) def test_can_update_coupon_lowercase_verification(self): """ @@ -30,3 +54,5 @@ def test_cant_update_coupon_duplicate_name(self): """ self.assertTrue(True) + + # XXX: Verify we can update a coupon from binding to a user to an email. diff --git a/starting_app/foo/coupons/views.py b/starting_app/foo/coupons/views.py index d00eda1..c44ec09 100644 --- a/starting_app/foo/coupons/views.py +++ b/starting_app/foo/coupons/views.py @@ -1,3 +1,4 @@ +from django.shortcuts import get_object_or_404 from rest_framework import status from rest_framework import viewsets from rest_framework.response import Response @@ -17,3 +18,18 @@ class CouponViewSet(viewsets.ModelViewSet): def partial_update(self, request, pk=None, **kwargs): return Response(status=status.HTTP_404_NOT_FOUND) + def update(self, request, pk=None, **kwargs): + """ + This forces it to return a 202 upon success instead of 200. + """ + + queryset = self.get_queryset() + obj = get_object_or_404(queryset, pk=pk) + + serializer = CouponSerializer(obj, data=request.data, context={'request': request}) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_202_ACCEPTED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + From 8beb5ceba6faed120f1dd7e1696c679896ade7b3 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 8 Jan 2017 20:19:36 -0800 Subject: [PATCH 15/46] Some tweaks and some new tests. --- starting_app/foo/coupons/serializers.py | 8 +- .../foo/coupons/tests/test_coupon_update.py | 103 +++++++++++++++++- 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/starting_app/foo/coupons/serializers.py b/starting_app/foo/coupons/serializers.py index efdfcd8..2a63e93 100644 --- a/starting_app/foo/coupons/serializers.py +++ b/starting_app/foo/coupons/serializers.py @@ -47,6 +47,8 @@ def validate(self, data): else: raise serializers.ValidationError("Creating coupon with code that violates uniqueness constraint.") + data['code_l'] = data['code'].lower() + return data def validate_repeat(self, value): @@ -60,11 +62,7 @@ def validate_repeat(self, value): return value def create(self, validated_data): - validated_data['code_l'] = validated_data['code'].lower() - - coupon = Coupon.objects.create(**validated_data) - - return coupon + return Coupon.objects.create(**validated_data) class Meta: model = apps.get_model('coupons.Coupon') diff --git a/starting_app/foo/coupons/tests/test_coupon_update.py b/starting_app/foo/coupons/tests/test_coupon_update.py index 3499a75..2d9ecd1 100644 --- a/starting_app/foo/coupons/tests/test_coupon_update.py +++ b/starting_app/foo/coupons/tests/test_coupon_update.py @@ -46,13 +46,110 @@ def test_can_update_coupon_lowercase_verification(self): Verify if we update the coupon code, its lowercase version is updated. """ - self.assertTrue(True) + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bind'] = 'user' + coupon['bound'] = False + + self.verify_built(coupon, response.data) + + coupon['code'] = 'PERSON' + del coupon['code_l'] + self.login(username='admin') + response = self.client.put('/coupon/%s' % response.data['id'], coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + + self.verify_built(coupon, response.data) + + def test_update_ignores_code_lowercase(self): + """ + Verify if you try to update the lowercase code value, it's overwritten. + """ + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bind'] = 'user' + coupon['bound'] = False + + self.verify_built(coupon, response.data) + + # code_l doesn't match anymore. + coupon['code'] = 'PERSON' + self.login(username='admin') + response = self.client.put('/coupon/%s' % response.data['id'], coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + + self.verify_built(coupon, response.data) def test_cant_update_coupon_duplicate_name(self): """ Verify you can't update the coupon code to become a duplicate. """ - self.assertTrue(True) + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } - # XXX: Verify we can update a coupon from binding to a user to an email. + coupon2 = { + 'code': 'SECOND', + 'type': 'percent', + } + + # First coupon + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + coupon_id = response.data['id'] + + coupon['code_l'] = coupon['code'].lower() + self.verify_built(coupon, response.data) + + # Second coupon + self.login(username='admin') + response = self.client.post('/coupon', coupon2, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon2['code_l'] = coupon2['code'].lower() + self.verify_built(coupon2, response.data) + + + # Update first coupon to be the same as the second. + coupon['code'] = 'SECOND' + self.login(username='admin') + response = self.client.put('/coupon/%s' % coupon_id, coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.logout() + + def test_can_update_coupon_change_binding(self): + # XXX: Verify we can update a coupon from binding to a user to an email. + + self.assertTrue(True) From 1456436d5a850177e0fe3f322086dae731563190 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 8 Jan 2017 20:23:23 -0800 Subject: [PATCH 16/46] whitspace, :D --- starting_app/foo/coupons/tests/test_coupon_update.py | 1 - 1 file changed, 1 deletion(-) diff --git a/starting_app/foo/coupons/tests/test_coupon_update.py b/starting_app/foo/coupons/tests/test_coupon_update.py index 2d9ecd1..f6309d2 100644 --- a/starting_app/foo/coupons/tests/test_coupon_update.py +++ b/starting_app/foo/coupons/tests/test_coupon_update.py @@ -141,7 +141,6 @@ def test_cant_update_coupon_duplicate_name(self): coupon2['code_l'] = coupon2['code'].lower() self.verify_built(coupon2, response.data) - # Update first coupon to be the same as the second. coupon['code'] = 'SECOND' self.login(username='admin') From 9e75f8d63597a9e116305a8329a0f11778df061b Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 8 Jan 2017 21:09:48 -0800 Subject: [PATCH 17/46] ditched the email bit because it didn't really make much sense. --- README.md | 2 +- .../foo/coupons/migrations/0001_initial.py | 4 +- starting_app/foo/coupons/models.py | 10 +--- starting_app/foo/coupons/serializers.py | 15 ++--- .../foo/coupons/tests/test_coupon_create.py | 10 +--- .../foo/coupons/tests/test_coupon_redeem.py | 59 +++++++++++++++++++ .../foo/coupons/tests/test_coupon_update.py | 9 +-- starting_app/foo/coupons/views.py | 23 ++++++-- 8 files changed, 90 insertions(+), 42 deletions(-) create mode 100644 starting_app/foo/coupons/tests/test_coupon_redeem.py diff --git a/README.md b/README.md index 99b4ae2..2c8a52d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A django-rest-framework application that provides many varieties of coupons It supports the following variations of coupons: 1. Coupons can be a value, or a percentage. -2. They can be bound to a specific user in the system, or an email address (not yet in the system). +2. They can be bound to a specific user in the system. 3. They can be single-use: 1. per user (a pre-specified user can use it once), `case I` 2. globally (any user in the system can use it, but only once) `case II` diff --git a/starting_app/foo/coupons/migrations/0001_initial.py b/starting_app/foo/coupons/migrations/0001_initial.py index 55568c0..b234369 100644 --- a/starting_app/foo/coupons/migrations/0001_initial.py +++ b/starting_app/foo/coupons/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.2 on 2017-01-09 03:30 +# Generated by Django 1.10.2 on 2017-01-09 05:05 from __future__ import unicode_literals from django.conf import settings @@ -35,8 +35,6 @@ class Migration(migrations.Migration): ('expires', models.DateTimeField(blank=True, null=True)), ('value', models.DecimalField(decimal_places=2, default=0.0, max_digits=5)), ('bound', models.BooleanField(default=False)), - ('bind', models.CharField(choices=[('user', 'user'), ('email', 'email')], default='user', max_length=16)), - ('email', models.EmailField(blank=True, max_length=256, null=True)), ('repeat', models.IntegerField(default=0)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], diff --git a/starting_app/foo/coupons/models.py b/starting_app/foo/coupons/models.py index 25e1f62..c11bac0 100644 --- a/starting_app/foo/coupons/models.py +++ b/starting_app/foo/coupons/models.py @@ -9,19 +9,13 @@ ('value', 'value'), ) -BINDING_TYPES = ( - ('user', 'user'), - ('email', 'email'), -) - - try: + # In case they specified something else in their settings file, which is quite common. user = settings.AUTH_USER_MODEL except AttributeError: # get_user_model isn't working at this point in loading. from django.contrib.auth.models import User as user -#user = get_user_model() class Coupon(models.Model): """ @@ -57,8 +51,6 @@ class Coupon(models.Model): # Is this coupon bound to a specific user? bound = models.BooleanField(default=False) - bind = models.CharField(max_length=16, choices=BINDING_TYPES, default='user') - email = models.EmailField(max_length=256, blank=True, null=True) user = models.ForeignKey(user, blank=True, null=True) # How many times this coupon can be used, 0 == infinitely, otherwise it's a number, such as 1 or many. diff --git a/starting_app/foo/coupons/serializers.py b/starting_app/foo/coupons/serializers.py index 2a63e93..9b8aba9 100644 --- a/starting_app/foo/coupons/serializers.py +++ b/starting_app/foo/coupons/serializers.py @@ -29,12 +29,8 @@ def validate(self, data): # Verify if it's bound, that the user exists or the email is valid. if 'bound' in data and data['bound']: - if data['bind'] == 'user': - if 'user' not in data: - raise serializers.ValidationError("Bound to user, but user field not specified.") - elif data['bind'] == 'email': - if 'email' not in data: - raise serializers.ValidationError("Bound to email, but email field not specified.") + if 'user' not in data: + raise serializers.ValidationError("Bound to user, but user field not specified.") # Verify the lowercase code is unique. # IntegrityError: UNIQUE constraint failed: coupons_coupon.code_l and not returning 400. @@ -68,9 +64,8 @@ class Meta: model = apps.get_model('coupons.Coupon') fields = ('created', 'updated', 'code', 'code_l', 'type', 'expires', - 'bound', 'bind', 'email', - 'user', 'repeat', 'value', - 'id') + 'bound', 'user', 'repeat', + 'value', 'id') class ClaimedCouponSerializer(serializers.ModelSerializer): @@ -80,5 +75,5 @@ class ClaimedCouponSerializer(serializers.ModelSerializer): class Meta: model = apps.get_model('coupons.ClaimedCoupon') - fields = ('redeemed', 'coupon', 'id') + fields = ('redeemed', 'coupon', 'user', 'id') diff --git a/starting_app/foo/coupons/tests/test_coupon_create.py b/starting_app/foo/coupons/tests/test_coupon_create.py index af0bd49..5ac8eaa 100644 --- a/starting_app/foo/coupons/tests/test_coupon_create.py +++ b/starting_app/foo/coupons/tests/test_coupon_create.py @@ -9,8 +9,8 @@ class CouponCreateTests(BasicTest): def setUp(self): u = get_user_model() - u.objects.create_superuser('admin', 'john@snow.com', 'password123') - self.user = u.objects.create_user('user', 'me@snow.com', 'password123') + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) def test_can_create_coupon(self): """ @@ -29,7 +29,6 @@ def test_can_create_coupon(self): coupon['code_l'] = coupon['code'].lower() coupon['repeat'] = 0 - coupon['bind'] = 'user' coupon['bound'] = False self.verify_built(coupon, response.data) @@ -51,7 +50,6 @@ def test_can_create_coupon_lowercase_verification(self): coupon['code_l'] = coupon['code'].lower() coupon['repeat'] = 0 - coupon['bind'] = 'user' coupon['bound'] = False self.verify_built(coupon, response.data) @@ -74,7 +72,6 @@ def test_cant_create_coupon_duplicate_name(self): coupon['code_l'] = coupon['code'].lower() coupon['repeat'] = 0 - coupon['bind'] = 'user' coupon['bound'] = False self.verify_built(coupon, response.data) @@ -151,7 +148,6 @@ def test_can_create_coupon_case_i(self): 'code': 'ASDF', 'type': 'percent', 'bound': True, - 'bind': 'user', 'user': self.user.id, 'repeat': 1, } @@ -194,7 +190,6 @@ def test_can_create_coupon_case_iii(self): 'code': 'ASDF', 'type': 'percent', 'bound': True, - 'bind': 'user', 'user': self.user.id, 'repeat': 0, } @@ -236,7 +231,6 @@ def test_can_create_coupon_case_v(self): 'code': 'ASDF', 'type': 'percent', 'bound': True, - 'bind': 'user', 'user': self.user.id, 'repeat': 10, } diff --git a/starting_app/foo/coupons/tests/test_coupon_redeem.py b/starting_app/foo/coupons/tests/test_coupon_redeem.py new file mode 100644 index 0000000..30cd6a6 --- /dev/null +++ b/starting_app/foo/coupons/tests/test_coupon_redeem.py @@ -0,0 +1,59 @@ +from django.contrib.auth import get_user_model +from rest_framework import status +from datetime import datetime, timedelta +from time import sleep + +from coupons.tests.base import BasicTest + + +class CouponRedeemTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + + def test_cant_redeem_expired(self): + """ + Verify that if a coupon is expired, it can't be redeemed. + """ + + future = datetime.utcnow() + timedelta(seconds=5) + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + 'expires': str(future), + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + # sleep until it's expired. + sleep(5) + + + + def test_cant_redeem_wrong_user(self): + """ + Verify that you can't redeem a coupon that is bound to another user. + """ + + self.assertTrue(True) + + def test_can_redeem_nonbound(self): + """ + Verify that you can redeem a coupon that isn't bound to a specific user. + """ + + self.assertTrue(True) + + def test_can_redeem_bound_to_you(self): + """ + Verify that you can redeem a bound coupon if it's bound to you. + """ + + self.assertTrue(True) + diff --git a/starting_app/foo/coupons/tests/test_coupon_update.py b/starting_app/foo/coupons/tests/test_coupon_update.py index f6309d2..c890dd4 100644 --- a/starting_app/foo/coupons/tests/test_coupon_update.py +++ b/starting_app/foo/coupons/tests/test_coupon_update.py @@ -8,8 +8,8 @@ class CouponUpdateTests(BasicTest): def setUp(self): u = get_user_model() - u.objects.create_superuser('admin', 'john@snow.com', 'password123') - self.user = u.objects.create_user('user', 'me@snow.com', 'password123') + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) def test_can_update_coupon(self): """ @@ -28,7 +28,6 @@ def test_can_update_coupon(self): coupon['code_l'] = coupon['code'].lower() coupon['repeat'] = 0 - coupon['bind'] = 'user' coupon['bound'] = False self.verify_built(coupon, response.data) @@ -58,7 +57,6 @@ def test_can_update_coupon_lowercase_verification(self): coupon['code_l'] = coupon['code'].lower() coupon['repeat'] = 0 - coupon['bind'] = 'user' coupon['bound'] = False self.verify_built(coupon, response.data) @@ -91,7 +89,6 @@ def test_update_ignores_code_lowercase(self): coupon['code_l'] = coupon['code'].lower() coupon['repeat'] = 0 - coupon['bind'] = 'user' coupon['bound'] = False self.verify_built(coupon, response.data) @@ -149,6 +146,6 @@ def test_cant_update_coupon_duplicate_name(self): self.logout() def test_can_update_coupon_change_binding(self): - # XXX: Verify we can update a coupon from binding to a user to an email. + # XXX: Verify we can update a coupon from bound to not bound, and vice versa. self.assertTrue(True) diff --git a/starting_app/foo/coupons/views.py b/starting_app/foo/coupons/views.py index c44ec09..f7a6231 100644 --- a/starting_app/foo/coupons/views.py +++ b/starting_app/foo/coupons/views.py @@ -1,15 +1,16 @@ from django.shortcuts import get_object_or_404 from rest_framework import status from rest_framework import viewsets +from rest_framework.decorators import detail_route from rest_framework.response import Response -from coupons.models import Coupon -from coupons.serializers import CouponSerializer +from coupons.models import Coupon, ClaimedCoupon +from coupons.serializers import CouponSerializer, ClaimedCouponSerializer class CouponViewSet(viewsets.ModelViewSet): """ - API endpoint that lets a user manipulate their folders. + API endpoint that lets you create, delete, retrieve coupons. """ serializer_class = CouponSerializer @@ -24,12 +25,24 @@ def update(self, request, pk=None, **kwargs): """ queryset = self.get_queryset() - obj = get_object_or_404(queryset, pk=pk) + coupon = get_object_or_404(queryset, pk=pk) - serializer = CouponSerializer(obj, data=request.data, context={'request': request}) + serializer = CouponSerializer(coupon, data=request.data, context={'request': request}) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_202_ACCEPTED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @detail_route(methods=['put']) + def redeem(self, request, pk=None, **kwargs): + """ + Convenience endpoint for redeeming. + """ + + queryset = self.get_queryset() + coupon = get_object_or_404(queryset, pk=pk) + + # Maybe should do coupon.redeem(user). + # if data['expires'] < now(): + From 7a7826684d5c53065192230cb5113fb9561f1ae6 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Mon, 9 Jan 2017 20:33:50 -0800 Subject: [PATCH 18/46] Building up the redeem code. --- starting_app/foo/coupons/models.py | 7 ++ starting_app/foo/coupons/serializers.py | 20 +++++ .../foo/coupons/tests/test_coupon_redeem.py | 74 ++++++++++++++++++- starting_app/foo/coupons/views.py | 12 +++ 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/starting_app/foo/coupons/models.py b/starting_app/foo/coupons/models.py index c11bac0..f2c9ad9 100644 --- a/starting_app/foo/coupons/models.py +++ b/starting_app/foo/coupons/models.py @@ -72,6 +72,13 @@ class Coupon(models.Model): # specific number of times globally # repeat => X, bound = False + def redeem(self, user): + """ + Attempt to redeem the coupon + """ + + return + class ClaimedCoupon(models.Model): """ diff --git a/starting_app/foo/coupons/serializers.py b/starting_app/foo/coupons/serializers.py index 9b8aba9..82754c8 100644 --- a/starting_app/foo/coupons/serializers.py +++ b/starting_app/foo/coupons/serializers.py @@ -73,6 +73,26 @@ class ClaimedCouponSerializer(serializers.ModelSerializer): RW ClaimedCoupon serializer. """ + def validate(self, data): + """ + Verify the coupon can be redeemed. + """ + + coupon = data['coupon'] + user = data['user'] + + # Is the coupon expired? + if coupon.expires and coupon.expires < now(): + raise serializers.ValidationError("Coupon has expired.") + + # Is the coupon bound to someone else? + if coupon.bound and coupon.user.id != user.id: + raise serializers.ValidationError("Coupon bound to another user.") + + # Is the coupon redeemed aleady beyond what's allowed? + + return data + class Meta: model = apps.get_model('coupons.ClaimedCoupon') fields = ('redeemed', 'coupon', 'user', 'id') diff --git a/starting_app/foo/coupons/tests/test_coupon_redeem.py b/starting_app/foo/coupons/tests/test_coupon_redeem.py index 30cd6a6..d5b771f 100644 --- a/starting_app/foo/coupons/tests/test_coupon_redeem.py +++ b/starting_app/foo/coupons/tests/test_coupon_redeem.py @@ -29,31 +29,97 @@ def test_cant_redeem_expired(self): self.login(username='admin') response = self.client.post('/coupon', coupon, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) + coupon_id = response.data['id'] self.logout() # sleep until it's expired. sleep(5) - + self.login(username='admin') + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.logout() def test_cant_redeem_wrong_user(self): """ Verify that you can't redeem a coupon that is bound to another user. """ - self.assertTrue(True) + coupon = { + 'code': 'ASDF', + 'type': 'percent', + 'bound': True, + 'user': self.user.id, + 'repeat': 1, + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + coupon_id = response.data['id'] + self.logout() + + coupon['code_l'] = coupon['code'].lower() + + self.verify_built(coupon, response.data) + + self.login(username='admin') + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.logout() def test_can_redeem_nonbound(self): """ Verify that you can redeem a coupon that isn't bound to a specific user. """ - self.assertTrue(True) + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + coupon_id = response.data['id'] + self.logout() + + self.login(username='admin') + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() def test_can_redeem_bound_to_you(self): """ Verify that you can redeem a bound coupon if it's bound to you. """ - self.assertTrue(True) + coupon = { + 'code': 'ASDF', + 'type': 'percent', + 'bound': True, + 'user': self.user.id, + 'repeat': 1, + } + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + coupon_id = response.data['id'] + self.logout() + + coupon['code_l'] = coupon['code'].lower() + + self.verify_built(coupon, response.data) + + self.login(username='user') + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + def test_cant_redeem_beyond_repeat(self): + """ + Verify you can't redeem a coupon more than allowed. + """ + + self.assertTrue(True) diff --git a/starting_app/foo/coupons/views.py b/starting_app/foo/coupons/views.py index f7a6231..64a9a3e 100644 --- a/starting_app/foo/coupons/views.py +++ b/starting_app/foo/coupons/views.py @@ -46,3 +46,15 @@ def redeem(self, request, pk=None, **kwargs): # Maybe should do coupon.redeem(user). # if data['expires'] < now(): + data = { + 'coupon': pk, + 'user': self.request.user.id, + } + + serializer = ClaimedCouponSerializer(data=data, context={'request': request}) + if serializer.is_valid(): + serializer.save() + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) From 6cff8df56901d0ee9137ab184497f18770c75a54 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Wed, 11 Jan 2017 21:27:51 -0800 Subject: [PATCH 19/46] Enabled group specification for actions and added a test for the create coupon action. --- starting_app/foo/coupons/serializers.py | 2 +- .../tests/test_coupon_creation_settings.py | 81 +++++++++++++++++++ starting_app/foo/coupons/views.py | 41 ++++++++++ starting_app/foo/foo/apps.py | 7 ++ .../foo/foo/migrations/0001_initial.py | 24 ++++++ starting_app/foo/foo/migrations/__init__.py | 0 .../foo/foo/migrations/define_groups.py | 20 +++++ starting_app/foo/foo/models.py | 14 ++++ starting_app/foo/foo/settings.py | 6 ++ 9 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 starting_app/foo/coupons/tests/test_coupon_creation_settings.py create mode 100644 starting_app/foo/foo/apps.py create mode 100644 starting_app/foo/foo/migrations/0001_initial.py create mode 100644 starting_app/foo/foo/migrations/__init__.py create mode 100644 starting_app/foo/foo/migrations/define_groups.py diff --git a/starting_app/foo/coupons/serializers.py b/starting_app/foo/coupons/serializers.py index 82754c8..c45e28c 100644 --- a/starting_app/foo/coupons/serializers.py +++ b/starting_app/foo/coupons/serializers.py @@ -89,7 +89,7 @@ def validate(self, data): if coupon.bound and coupon.user.id != user.id: raise serializers.ValidationError("Coupon bound to another user.") - # Is the coupon redeemed aleady beyond what's allowed? + # Is the coupon redeemed already beyond what's allowed? return data diff --git a/starting_app/foo/coupons/tests/test_coupon_creation_settings.py b/starting_app/foo/coupons/tests/test_coupon_creation_settings.py new file mode 100644 index 0000000..162f3d9 --- /dev/null +++ b/starting_app/foo/coupons/tests/test_coupon_creation_settings.py @@ -0,0 +1,81 @@ +from django.conf import settings +from django.contrib.auth.models import Group +from django.contrib.auth import get_user_model +from rest_framework import status +from datetime import datetime, timedelta + +from coupons.tests.base import BasicTest + + +class CouponCreateSettingsTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + + def test_cant_create_coupon_if_not_in_group(self): + """ + Verify the user can restrict permissions. + """ + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + with self.settings(COUPON_PERMISSIONS={'CREATE': ['group_a']}): + + self.login(username='user') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.logout() + + def test_can_create_coupon_if_group_empty(self): + """ + Verify the user can restrict permissions. + """ + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + with self.settings(COUPON_PERMISSIONS={'CREATE': []}): + + self.login(username='user') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False + + self.verify_built(coupon, response.data) + + def test_can_create_coupon_if_in_group(self): + """ + Verify the user can restrict permissions. + """ + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + g, _ = Group.objects.get_or_create(name='group_a') + g.user_set.add(self.user) + + with self.settings(COUPON_PERMISSIONS={'CREATE': ['group_a']}): + + self.login(username='user') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False + + self.verify_built(coupon, response.data) \ No newline at end of file diff --git a/starting_app/foo/coupons/views.py b/starting_app/foo/coupons/views.py index 64a9a3e..62567f2 100644 --- a/starting_app/foo/coupons/views.py +++ b/starting_app/foo/coupons/views.py @@ -1,4 +1,7 @@ +from django.conf import settings from django.shortcuts import get_object_or_404 +from django.utils.decorators import method_decorator + from rest_framework import status from rest_framework import viewsets from rest_framework.decorators import detail_route @@ -7,6 +10,31 @@ from coupons.models import Coupon, ClaimedCoupon from coupons.serializers import CouponSerializer, ClaimedCouponSerializer +from django.contrib.auth.decorators import user_passes_test + + +# https://djangosnippets.org/snippets/1703/ +def group_required(api_command): + def in_groups(u): + if u.is_authenticated(): + # supervisor can do anything + if u.is_superuser: + return True + + # coupons have permissions set + if settings.COUPON_PERMISSIONS: + group_names = settings.COUPON_PERMISSIONS[api_command] + + # but no group specified, so anyone can. + if len(group_names) == 0: + return True + + # group specified, so only those in the group can. + if bool(u.groups.filter(name__in=group_names)): + return True + return False + return user_passes_test(in_groups) + class CouponViewSet(viewsets.ModelViewSet): """ @@ -16,6 +44,19 @@ class CouponViewSet(viewsets.ModelViewSet): serializer_class = CouponSerializer queryset = Coupon.objects.all() + @method_decorator(group_required('CREATE')) + def create(self, request, **kwargs): + """ + Create a coupon + """ + + serializer = CouponSerializer(data=request.data, context={'request': request}) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + def partial_update(self, request, pk=None, **kwargs): return Response(status=status.HTTP_404_NOT_FOUND) diff --git a/starting_app/foo/foo/apps.py b/starting_app/foo/foo/apps.py new file mode 100644 index 0000000..7957942 --- /dev/null +++ b/starting_app/foo/foo/apps.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class FooAppConfig(AppConfig): + name = 'foo' diff --git a/starting_app/foo/foo/migrations/0001_initial.py b/starting_app/foo/foo/migrations/0001_initial.py new file mode 100644 index 0000000..bec9ac5 --- /dev/null +++ b/starting_app/foo/foo/migrations/0001_initial.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2017-01-12 04:48 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='MiscItem', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=64)), + ('value', models.IntegerField()), + ], + ), + ] diff --git a/starting_app/foo/foo/migrations/__init__.py b/starting_app/foo/foo/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/starting_app/foo/foo/migrations/define_groups.py b/starting_app/foo/foo/migrations/define_groups.py new file mode 100644 index 0000000..9c42b53 --- /dev/null +++ b/starting_app/foo/foo/migrations/define_groups.py @@ -0,0 +1,20 @@ +from django.contrib.auth.models import Group +from django.db import migrations + + +def create_groups(apps, schema_editor): + if not schema_editor.connection.alias == 'default': + return + + Group.objects.get_or_create(name='fun_users') + + +class Migration(migrations.Migration): + + dependencies = [ + ('foo', '0001_initial'), + ] + + operations = [ + migrations.RunPython(create_groups), + ] diff --git a/starting_app/foo/foo/models.py b/starting_app/foo/foo/models.py index e69de29..513eed0 100644 --- a/starting_app/foo/foo/models.py +++ b/starting_app/foo/foo/models.py @@ -0,0 +1,14 @@ +from __future__ import unicode_literals + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.db import models + + +class MiscItem(models.Model): + """ + These are the instances of claimed coupons, each is an individual usage of a coupon by someone in the system. + """ + + name = models.CharField(max_length=64) + value = models.IntegerField() diff --git a/starting_app/foo/foo/settings.py b/starting_app/foo/foo/settings.py index e330b97..6f71060 100644 --- a/starting_app/foo/foo/settings.py +++ b/starting_app/foo/foo/settings.py @@ -83,6 +83,12 @@ } } +# drf-coupons settings +COUPON_PERMISSIONS = { + 'CREATE': [ + 'fun_users' + ], +} # Password validation # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators From eea8f2d17f75df1a08368eaf8a93d57b7ddd7cfd Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Thu, 12 Jan 2017 14:33:57 -0800 Subject: [PATCH 20/46] Update README.md --- README.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/README.md b/README.md index 2c8a52d..5b3c604 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,63 @@ # drf-coupons A django-rest-framework application that provides many varieties of coupons +## Setup instructions + +1. Install `drf-coupons` via pip: + ``` + $ pip install drf-coupons + ``` + +2. Add `'coupons'` to `INSTALLED_APPS` in `settings.py`. + +3. Migrate database: + + ``` + $ python manage.py migrate + ``` + +4. Specify permissions for interacting with coupon endpoints. + +You can specify a list of groups that can perform specific actions against the coupons, such as restricting who can +create or list coupons. + +By default all endpoints are open. + +``` +COUPON_PERMISSIONS = { + 'CREATE': ['groupa', 'groupb'], + 'LIST': ['groupa'], + 'DELETE': ['groupb'], + 'RETRIEVE': ['groupa'], + 'UPDATE': ['groupb'], +} +``` + +You don't need to specify every endpoint in the list and can provide an empty list. + +5. Communicate with coupon endpoints. + +You can place the urls into a subpath, however you like: + +``` +urlpatterns = [ + # just adding here, but you can put into a subordinate path. + url(r'^', include('coupons.urls')), +] +``` + +| Endpoint | Details | +| ------------------------- | ---------------------------------------------- | +| `GET /coupon` | List all coupons | +| `GET /coupon/{pk}` | Retrieve details about a coupon by database id | +| `POST /coupon` | Create a new coupon | +| `PUT /coupon/{pk}` | Update a coupon | +| `DELETE /coupon/{pk}` | Delete a coupon | +| `PUT /coupon/{pk}/redeem` | Redeem a coupon by database id | +| `PATCH /coupon/{pk}` | Not supported | + +## Coupon Types + It supports the following variations of coupons: 1. Coupons can be a value, or a percentage. From 7bc3cec88c92a9023f49fba948e68a99b7016f04 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Thu, 12 Jan 2017 14:35:56 -0800 Subject: [PATCH 21/46] Update README.md --- README.md | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 5b3c604..9e996cb 100644 --- a/README.md +++ b/README.md @@ -18,33 +18,33 @@ A django-rest-framework application that provides many varieties of coupons 4. Specify permissions for interacting with coupon endpoints. -You can specify a list of groups that can perform specific actions against the coupons, such as restricting who can -create or list coupons. + You can specify a list of groups that can perform specific actions against the coupons, such as restricting who can + create or list coupons. -By default all endpoints are open. + By default all endpoints are open. -``` -COUPON_PERMISSIONS = { - 'CREATE': ['groupa', 'groupb'], - 'LIST': ['groupa'], - 'DELETE': ['groupb'], - 'RETRIEVE': ['groupa'], - 'UPDATE': ['groupb'], -} -``` + ``` + COUPON_PERMISSIONS = { + 'CREATE': ['groupa', 'groupb'], + 'LIST': ['groupa'], + 'DELETE': ['groupb'], + 'RETRIEVE': ['groupa'], + 'UPDATE': ['groupb'], + } + ``` -You don't need to specify every endpoint in the list and can provide an empty list. + You don't need to specify every endpoint in the list and can provide an empty list. 5. Communicate with coupon endpoints. -You can place the urls into a subpath, however you like: + You can place the urls into a subpath, however you like: -``` -urlpatterns = [ - # just adding here, but you can put into a subordinate path. - url(r'^', include('coupons.urls')), -] -``` + ``` + urlpatterns = [ + # just adding here, but you can put into a subordinate path. + url(r'^', include('coupons.urls')), + ] + ``` | Endpoint | Details | | ------------------------- | ---------------------------------------------- | From 90adaf8b13edd02b1293c345155cc185e245a6e3 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Thu, 12 Jan 2017 14:38:09 -0800 Subject: [PATCH 22/46] Update README.md --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9e996cb..5b4cec2 100644 --- a/README.md +++ b/README.md @@ -46,15 +46,15 @@ A django-rest-framework application that provides many varieties of coupons ] ``` -| Endpoint | Details | -| ------------------------- | ---------------------------------------------- | -| `GET /coupon` | List all coupons | -| `GET /coupon/{pk}` | Retrieve details about a coupon by database id | -| `POST /coupon` | Create a new coupon | -| `PUT /coupon/{pk}` | Update a coupon | -| `DELETE /coupon/{pk}` | Delete a coupon | -| `PUT /coupon/{pk}/redeem` | Redeem a coupon by database id | -| `PATCH /coupon/{pk}` | Not supported | + | Endpoint | Details | + | ------------------------- | ---------------------------------------------- | + | `GET /coupon` | List all coupons | + | `GET /coupon/{pk}` | Retrieve details about a coupon by database id | + | `POST /coupon` | Create a new coupon | + | `PUT /coupon/{pk}` | Update a coupon | + | `DELETE /coupon/{pk}` | Delete a coupon | + | `PUT /coupon/{pk}/redeem` | Redeem a coupon by database id | + | `PATCH /coupon/{pk}` | Not supported | ## Coupon Types From cfc977b48f163571d37cb8b915a701583aecaec5 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Thu, 12 Jan 2017 14:39:36 -0800 Subject: [PATCH 23/46] Update README.md --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5b4cec2..caa2124 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,9 @@ A django-rest-framework application that provides many varieties of coupons $ python manage.py migrate ``` -4. Specify permissions for interacting with coupon endpoints. +## Usage + +1. Specify permissions for interacting with coupon endpoints. You can specify a list of groups that can perform specific actions against the coupons, such as restricting who can create or list coupons. @@ -35,7 +37,7 @@ A django-rest-framework application that provides many varieties of coupons You don't need to specify every endpoint in the list and can provide an empty list. -5. Communicate with coupon endpoints. +2. Communicate with coupon endpoints. You can place the urls into a subpath, however you like: From 502e4f2c55d92fb3115d6c78a210b6287835cc1b Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Thu, 12 Jan 2017 21:45:37 -0800 Subject: [PATCH 24/46] Fixed isolation in the coupons tests. --- README.md | 5 + starting_app/foo/coupons/models.py | 7 - .../foo/coupons/tests/test_coupon_create.py | 170 ++++++++++-------- .../tests/test_coupon_creation_settings.py | 49 ++--- .../foo/coupons/tests/test_coupon_redeem.py | 88 ++++----- .../foo/coupons/tests/test_coupon_update.py | 146 +++++++-------- starting_app/foo/coupons/views.py | 14 ++ starting_app/foo/foo/serializers.py | 16 ++ starting_app/foo/foo/tests/__init__.py | 0 starting_app/foo/foo/tests/base.py | 19 ++ .../foo/foo/tests/test_item_coupon_create.py | 33 ++++ .../foo/foo/tests/test_item_create.py | 31 ++++ starting_app/foo/foo/urls.py | 13 +- starting_app/foo/foo/views.py | 14 ++ 14 files changed, 382 insertions(+), 223 deletions(-) create mode 100644 starting_app/foo/foo/serializers.py create mode 100644 starting_app/foo/foo/tests/__init__.py create mode 100644 starting_app/foo/foo/tests/base.py create mode 100644 starting_app/foo/foo/tests/test_item_coupon_create.py create mode 100644 starting_app/foo/foo/tests/test_item_create.py diff --git a/README.md b/README.md index caa2124..0e48a41 100644 --- a/README.md +++ b/README.md @@ -76,3 +76,8 @@ It supports the following variations of coupons: 6. (They can be used by a specific list of users?) ... maybe later. You create coupons in the system that are then claimed by users. + +## Developing + +The unit-tests should automatically be run when you run `python manage.py test` and they are isolated. + diff --git a/starting_app/foo/coupons/models.py b/starting_app/foo/coupons/models.py index f2c9ad9..c11bac0 100644 --- a/starting_app/foo/coupons/models.py +++ b/starting_app/foo/coupons/models.py @@ -72,13 +72,6 @@ class Coupon(models.Model): # specific number of times globally # repeat => X, bound = False - def redeem(self, user): - """ - Attempt to redeem the coupon - """ - - return - class ClaimedCoupon(models.Model): """ diff --git a/starting_app/foo/coupons/tests/test_coupon_create.py b/starting_app/foo/coupons/tests/test_coupon_create.py index 5ac8eaa..8a66a43 100644 --- a/starting_app/foo/coupons/tests/test_coupon_create.py +++ b/starting_app/foo/coupons/tests/test_coupon_create.py @@ -22,16 +22,17 @@ def test_can_create_coupon(self): 'type': 'percent', } - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() - coupon['code_l'] = coupon['code'].lower() - coupon['repeat'] = 0 - coupon['bound'] = False + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False - self.verify_built(coupon, response.data) + self.verify_built(coupon, response.data) def test_can_create_coupon_lowercase_verification(self): """ @@ -43,16 +44,17 @@ def test_can_create_coupon_lowercase_verification(self): 'type': 'percent', } - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() - coupon['code_l'] = coupon['code'].lower() - coupon['repeat'] = 0 - coupon['bound'] = False + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False - self.verify_built(coupon, response.data) + self.verify_built(coupon, response.data) def test_cant_create_coupon_duplicate_name(self): """ @@ -65,27 +67,28 @@ def test_cant_create_coupon_duplicate_name(self): 'type': 'percent', } - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() - - coupon['code_l'] = coupon['code'].lower() - coupon['repeat'] = 0 - coupon['bound'] = False - - self.verify_built(coupon, response.data) - # Create duplicate. coupon2 = { 'code': 'AsdF', 'type': 'percent', } - self.login(username='admin') - response = self.client.post('/coupon', coupon2, format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False + + self.verify_built(coupon, response.data) + + self.login(username='admin') + response = self.client.post('/coupon', coupon2, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.logout() def test_cant_create_coupon_invalid_percentage(self): """ @@ -98,10 +101,11 @@ def test_cant_create_coupon_invalid_percentage(self): 'value': 2, } - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.logout() def test_can_create_coupon_expires(self): """ @@ -116,10 +120,11 @@ def test_can_create_coupon_expires(self): 'expires': str(future), } - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() def test_cant_create_coupon_after_expiration(self): """ @@ -134,10 +139,11 @@ def test_cant_create_coupon_after_expiration(self): 'expires': str(past), } - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.logout() def test_can_create_coupon_case_i(self): """ @@ -152,14 +158,15 @@ def test_can_create_coupon_case_i(self): 'repeat': 1, } - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() - coupon['code_l'] = coupon['code'].lower() + coupon['code_l'] = coupon['code'].lower() - self.verify_built(coupon, response.data) + self.verify_built(coupon, response.data) def test_can_create_coupon_case_ii(self): """ @@ -172,14 +179,15 @@ def test_can_create_coupon_case_ii(self): 'repeat': 1, } - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() - coupon['code_l'] = coupon['code'].lower() + coupon['code_l'] = coupon['code'].lower() - self.verify_built(coupon, response.data) + self.verify_built(coupon, response.data) def test_can_create_coupon_case_iii(self): """ @@ -194,14 +202,15 @@ def test_can_create_coupon_case_iii(self): 'repeat': 0, } - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() - coupon['code_l'] = coupon['code'].lower() + coupon['code_l'] = coupon['code'].lower() - self.verify_built(coupon, response.data) + self.verify_built(coupon, response.data) def test_can_create_coupon_case_iv(self): """ @@ -213,14 +222,15 @@ def test_can_create_coupon_case_iv(self): 'type': 'percent', } - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() - coupon['code_l'] = coupon['code'].lower() + coupon['code_l'] = coupon['code'].lower() - self.verify_built(coupon, response.data) + self.verify_built(coupon, response.data) def test_can_create_coupon_case_v(self): """ @@ -235,14 +245,15 @@ def test_can_create_coupon_case_v(self): 'repeat': 10, } - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() - coupon['code_l'] = coupon['code'].lower() + coupon['code_l'] = coupon['code'].lower() - self.verify_built(coupon, response.data) + self.verify_built(coupon, response.data) def test_can_create_coupon_case_vi(self): """ @@ -255,12 +266,13 @@ def test_can_create_coupon_case_vi(self): 'repeat': 10, } - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() - coupon['code_l'] = coupon['code'].lower() + coupon['code_l'] = coupon['code'].lower() - self.verify_built(coupon, response.data) + self.verify_built(coupon, response.data) diff --git a/starting_app/foo/coupons/tests/test_coupon_creation_settings.py b/starting_app/foo/coupons/tests/test_coupon_creation_settings.py index 162f3d9..9485249 100644 --- a/starting_app/foo/coupons/tests/test_coupon_creation_settings.py +++ b/starting_app/foo/coupons/tests/test_coupon_creation_settings.py @@ -24,12 +24,13 @@ def test_cant_create_coupon_if_not_in_group(self): 'type': 'percent', } - with self.settings(COUPON_PERMISSIONS={'CREATE': ['group_a']}): + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'CREATE': ['group_a']}): - self.login(username='user') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_302_FOUND) - self.logout() + self.login(username='user') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.logout() def test_can_create_coupon_if_group_empty(self): """ @@ -41,18 +42,19 @@ def test_can_create_coupon_if_group_empty(self): 'type': 'percent', } - with self.settings(COUPON_PERMISSIONS={'CREATE': []}): + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'CREATE': []}): - self.login(username='user') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() + self.login(username='user') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() - coupon['code_l'] = coupon['code'].lower() - coupon['repeat'] = 0 - coupon['bound'] = False + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False - self.verify_built(coupon, response.data) + self.verify_built(coupon, response.data) def test_can_create_coupon_if_in_group(self): """ @@ -67,15 +69,16 @@ def test_can_create_coupon_if_in_group(self): g, _ = Group.objects.get_or_create(name='group_a') g.user_set.add(self.user) - with self.settings(COUPON_PERMISSIONS={'CREATE': ['group_a']}): + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'CREATE': ['group_a']}): - self.login(username='user') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() + self.login(username='user') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() - coupon['code_l'] = coupon['code'].lower() - coupon['repeat'] = 0 - coupon['bound'] = False + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False - self.verify_built(coupon, response.data) \ No newline at end of file + self.verify_built(coupon, response.data) diff --git a/starting_app/foo/coupons/tests/test_coupon_redeem.py b/starting_app/foo/coupons/tests/test_coupon_redeem.py index d5b771f..936dfe1 100644 --- a/starting_app/foo/coupons/tests/test_coupon_redeem.py +++ b/starting_app/foo/coupons/tests/test_coupon_redeem.py @@ -26,19 +26,20 @@ def test_cant_redeem_expired(self): 'expires': str(future), } - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - coupon_id = response.data['id'] - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + coupon_id = response.data['id'] + self.logout() - # sleep until it's expired. - sleep(5) + # sleep until it's expired. + sleep(5) - self.login(username='admin') - response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.logout() + self.login(username='admin') + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.logout() def test_cant_redeem_wrong_user(self): """ @@ -53,20 +54,21 @@ def test_cant_redeem_wrong_user(self): 'repeat': 1, } - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - coupon_id = response.data['id'] - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + coupon_id = response.data['id'] + self.logout() - coupon['code_l'] = coupon['code'].lower() + coupon['code_l'] = coupon['code'].lower() - self.verify_built(coupon, response.data) + self.verify_built(coupon, response.data) - self.login(username='admin') - response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.logout() + self.login(username='admin') + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.logout() def test_can_redeem_nonbound(self): """ @@ -78,16 +80,17 @@ def test_can_redeem_nonbound(self): 'type': 'percent', } - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - coupon_id = response.data['id'] - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + coupon_id = response.data['id'] + self.logout() - self.login(username='admin') - response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() + self.login(username='admin') + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() def test_can_redeem_bound_to_you(self): """ @@ -102,20 +105,21 @@ def test_can_redeem_bound_to_you(self): 'repeat': 1, } - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - coupon_id = response.data['id'] - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + coupon_id = response.data['id'] + self.logout() - coupon['code_l'] = coupon['code'].lower() + coupon['code_l'] = coupon['code'].lower() - self.verify_built(coupon, response.data) + self.verify_built(coupon, response.data) - self.login(username='user') - response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() + self.login(username='user') + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() def test_cant_redeem_beyond_repeat(self): """ diff --git a/starting_app/foo/coupons/tests/test_coupon_update.py b/starting_app/foo/coupons/tests/test_coupon_update.py index c890dd4..2692aa6 100644 --- a/starting_app/foo/coupons/tests/test_coupon_update.py +++ b/starting_app/foo/coupons/tests/test_coupon_update.py @@ -21,24 +21,25 @@ def test_can_update_coupon(self): 'type': 'percent', } - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() - coupon['code_l'] = coupon['code'].lower() - coupon['repeat'] = 0 - coupon['bound'] = False + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False - self.verify_built(coupon, response.data) + self.verify_built(coupon, response.data) - coupon['repeat'] = 50 - self.login(username='admin') - response = self.client.put('/coupon/%s' % response.data['id'], coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - self.logout() + coupon['repeat'] = 50 + self.login(username='admin') + response = self.client.put('/coupon/%s' % response.data['id'], coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.logout() - self.verify_built(coupon, response.data) + self.verify_built(coupon, response.data) def test_can_update_coupon_lowercase_verification(self): """ @@ -50,27 +51,28 @@ def test_can_update_coupon_lowercase_verification(self): 'type': 'percent', } - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() - coupon['code_l'] = coupon['code'].lower() - coupon['repeat'] = 0 - coupon['bound'] = False + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False - self.verify_built(coupon, response.data) + self.verify_built(coupon, response.data) - coupon['code'] = 'PERSON' - del coupon['code_l'] - self.login(username='admin') - response = self.client.put('/coupon/%s' % response.data['id'], coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - self.logout() + coupon['code'] = 'PERSON' + del coupon['code_l'] + self.login(username='admin') + response = self.client.put('/coupon/%s' % response.data['id'], coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.logout() - coupon['code_l'] = coupon['code'].lower() + coupon['code_l'] = coupon['code'].lower() - self.verify_built(coupon, response.data) + self.verify_built(coupon, response.data) def test_update_ignores_code_lowercase(self): """ @@ -82,27 +84,28 @@ def test_update_ignores_code_lowercase(self): 'type': 'percent', } - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() - coupon['code_l'] = coupon['code'].lower() - coupon['repeat'] = 0 - coupon['bound'] = False + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False - self.verify_built(coupon, response.data) + self.verify_built(coupon, response.data) - # code_l doesn't match anymore. - coupon['code'] = 'PERSON' - self.login(username='admin') - response = self.client.put('/coupon/%s' % response.data['id'], coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - self.logout() + # code_l doesn't match anymore. + coupon['code'] = 'PERSON' + self.login(username='admin') + response = self.client.put('/coupon/%s' % response.data['id'], coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.logout() - coupon['code_l'] = coupon['code'].lower() + coupon['code_l'] = coupon['code'].lower() - self.verify_built(coupon, response.data) + self.verify_built(coupon, response.data) def test_cant_update_coupon_duplicate_name(self): """ @@ -119,31 +122,32 @@ def test_cant_update_coupon_duplicate_name(self): 'type': 'percent', } - # First coupon - self.login(username='admin') - response = self.client.post('/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() - coupon_id = response.data['id'] - - coupon['code_l'] = coupon['code'].lower() - self.verify_built(coupon, response.data) - - # Second coupon - self.login(username='admin') - response = self.client.post('/coupon', coupon2, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() - - coupon2['code_l'] = coupon2['code'].lower() - self.verify_built(coupon2, response.data) - - # Update first coupon to be the same as the second. - coupon['code'] = 'SECOND' - self.login(username='admin') - response = self.client.put('/coupon/%s' % coupon_id, coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.logout() + with self.settings(ROOT_URLCONF='coupons.urls'): + # First coupon + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + coupon_id = response.data['id'] + + coupon['code_l'] = coupon['code'].lower() + self.verify_built(coupon, response.data) + + # Second coupon + self.login(username='admin') + response = self.client.post('/coupon', coupon2, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon2['code_l'] = coupon2['code'].lower() + self.verify_built(coupon2, response.data) + + # Update first coupon to be the same as the second. + coupon['code'] = 'SECOND' + self.login(username='admin') + response = self.client.put('/coupon/%s' % coupon_id, coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.logout() def test_can_update_coupon_change_binding(self): # XXX: Verify we can update a coupon from bound to not bound, and vice versa. diff --git a/starting_app/foo/coupons/views.py b/starting_app/foo/coupons/views.py index 62567f2..98165c6 100644 --- a/starting_app/foo/coupons/views.py +++ b/starting_app/foo/coupons/views.py @@ -2,6 +2,7 @@ from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator +from rest_framework import filters from rest_framework import status from rest_framework import viewsets from rest_framework.decorators import detail_route @@ -41,8 +42,20 @@ class CouponViewSet(viewsets.ModelViewSet): API endpoint that lets you create, delete, retrieve coupons. """ + filter_backends = (filters.SearchFilter,) serializer_class = CouponSerializer queryset = Coupon.objects.all() +# search_fields = ('code', 'code_l') + +# def list(self, request, **kwargs): +# """ +# List coupons +# """ +# +# queryset = Coupon.objects.all() +# serializer = CouponSerializer(queryset, many=True, context={'request': request}) +# +# return Response(serializer.data) @method_decorator(group_required('CREATE')) def create(self, request, **kwargs): @@ -60,6 +73,7 @@ def create(self, request, **kwargs): def partial_update(self, request, pk=None, **kwargs): return Response(status=status.HTTP_404_NOT_FOUND) + @method_decorator(group_required('UPDATE')) def update(self, request, pk=None, **kwargs): """ This forces it to return a 202 upon success instead of 200. diff --git a/starting_app/foo/foo/serializers.py b/starting_app/foo/foo/serializers.py new file mode 100644 index 0000000..86573d7 --- /dev/null +++ b/starting_app/foo/foo/serializers.py @@ -0,0 +1,16 @@ +from django.apps import apps +from django.utils.timezone import now +from rest_framework import serializers + +from foo.models import MiscItem + + +class MiscItemSerializer(serializers.ModelSerializer): + """ + RW MiscItem serializer. + """ + + class Meta: + model = apps.get_model('foo.MiscItem') + fields = ('name', 'value', 'id') + diff --git a/starting_app/foo/foo/tests/__init__.py b/starting_app/foo/foo/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/starting_app/foo/foo/tests/base.py b/starting_app/foo/foo/tests/base.py new file mode 100644 index 0000000..a6bb8d0 --- /dev/null +++ b/starting_app/foo/foo/tests/base.py @@ -0,0 +1,19 @@ +from rest_framework.test import APITestCase + + +class BasicTest(APITestCase): + """ + Generic testing stuff. + """ + + PW = 'password123' + + def login(self, username): + self.client.login(username=username, password=self.PW) + + def logout(self): + self.client.logout() + + def verify_built(self, expected, data): + for key in expected: + self.assertEqual(data[key], expected[key]) diff --git a/starting_app/foo/foo/tests/test_item_coupon_create.py b/starting_app/foo/foo/tests/test_item_coupon_create.py new file mode 100644 index 0000000..8398c0f --- /dev/null +++ b/starting_app/foo/foo/tests/test_item_coupon_create.py @@ -0,0 +1,33 @@ +from django.contrib.auth import get_user_model +from rest_framework import status + +from foo.tests.base import BasicTest + + +class ItemCouponCreateTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + + def test_can_create_coupon_within_item_app(self): + """ + Create a coupon (boringly). + """ + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + self.login(username='admin') + response = self.client.post('/api/v1/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False + + self.verify_built(coupon, response.data) diff --git a/starting_app/foo/foo/tests/test_item_create.py b/starting_app/foo/foo/tests/test_item_create.py new file mode 100644 index 0000000..d1f0b60 --- /dev/null +++ b/starting_app/foo/foo/tests/test_item_create.py @@ -0,0 +1,31 @@ +from django.contrib.auth import get_user_model +from rest_framework import status + +from foo.tests.base import BasicTest + + +class ItemCreateTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + + def test_can_create_item(self): + """ + Create an item that is globally bound and infinite. + """ + + item = { + 'name': 'ASDF', + 'value': 0, + } + + self.login(username='admin') + response = self.client.post('/api/v1/item', item, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.verify_built(item, response.data) + + diff --git a/starting_app/foo/foo/urls.py b/starting_app/foo/foo/urls.py index b0a1e63..a4f869f 100644 --- a/starting_app/foo/foo/urls.py +++ b/starting_app/foo/foo/urls.py @@ -13,11 +13,22 @@ 1. Import the include() function: from django.conf.urls import url, include 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) """ + from django.conf.urls import url, include from django.contrib import admin +from rest_framework import routers + +import views + +router = routers.DefaultRouter(trailing_slash=False) +router.register(r'item', views.MiscItemViewSet, base_name='miscitem') urlpatterns = [ url(r'^admin/', admin.site.urls), url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), - url(r'^', include('coupons.urls')), + + url(r'^api/(?P(v1))/', include([ + url(r'^', include('coupons.urls')), + url(r'^', include(router.urls)), + ])), ] diff --git a/starting_app/foo/foo/views.py b/starting_app/foo/foo/views.py index e69de29..a75fb01 100644 --- a/starting_app/foo/foo/views.py +++ b/starting_app/foo/foo/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from foo.models import MiscItem +from foo.serializers import MiscItemSerializer + + +class MiscItemViewSet(viewsets.ModelViewSet): + """ + API endpoint that lets you create, delete, retrieve miscellaneous items. + """ + + serializer_class = MiscItemSerializer + queryset = MiscItem.objects.all() + From 3f292211350a038ff5bc72e7790db37ad2a67324 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 07:43:32 -0800 Subject: [PATCH 25/46] Added filtering, starting on adding some missing unit-tests in. --- README.md | 38 +++++++++---- starting_app/foo/coupons/filters.py | 17 ++++++ ...ettings.py => test_coupon_create_group.py} | 0 .../foo/coupons/tests/test_coupon_list.py | 43 ++++++++++++++ starting_app/foo/coupons/urls.py | 1 - starting_app/foo/coupons/views.py | 57 +++++++++++++------ 6 files changed, 126 insertions(+), 30 deletions(-) create mode 100644 starting_app/foo/coupons/filters.py rename starting_app/foo/coupons/tests/{test_coupon_creation_settings.py => test_coupon_create_group.py} (100%) create mode 100644 starting_app/foo/coupons/tests/test_coupon_list.py diff --git a/README.md b/README.md index 0e48a41..6c7a0f4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ # drf-coupons A django-rest-framework application that provides many varieties of coupons +## Dependencies + +This project depends on: +1. `djangorestframework` +2. `django-filter` + ## Setup instructions 1. Install `drf-coupons` via pip: @@ -8,9 +14,11 @@ A django-rest-framework application that provides many varieties of coupons $ pip install drf-coupons ``` -2. Add `'coupons'` to `INSTALLED_APPS` in `settings.py`. +2. Add `'rest_framework'` to `INSTALLED_APPS` in `settings.py`. + +3. Add `'coupons'` to `INSTALLED_APPS` in `settings.py`. -3. Migrate database: +4. Migrate database: ``` $ python manage.py migrate @@ -23,7 +31,7 @@ A django-rest-framework application that provides many varieties of coupons You can specify a list of groups that can perform specific actions against the coupons, such as restricting who can create or list coupons. - By default all endpoints are open. + By default all endpoints are open except list. ``` COUPON_PERMISSIONS = { @@ -48,15 +56,21 @@ A django-rest-framework application that provides many varieties of coupons ] ``` - | Endpoint | Details | - | ------------------------- | ---------------------------------------------- | - | `GET /coupon` | List all coupons | - | `GET /coupon/{pk}` | Retrieve details about a coupon by database id | - | `POST /coupon` | Create a new coupon | - | `PUT /coupon/{pk}` | Update a coupon | - | `DELETE /coupon/{pk}` | Delete a coupon | - | `PUT /coupon/{pk}/redeem` | Redeem a coupon by database id | - | `PATCH /coupon/{pk}` | Not supported | + As stated above, by default any user in the system can touch any of the below endpoints, except where specified in bold. + + | Endpoint | Details | + | ------------------------- | --------------------------------------------------------------------------- | + | `GET /coupon` | List all coupons in the system, **only superuser or in group can do this**. | + | `GET /coupon/{pk}` | Retrieve details about a coupon by database id | + | `POST /coupon` | Create a new coupon | + | `PUT /coupon/{pk}` | Update a coupon | + | `DELETE /coupon/{pk}` | Delete a coupon | + | `PUT /coupon/{pk}/redeem` | Redeem a coupon by database id | + | `PATCH /coupon/{pk}` | **Not supported** | + +## Querying + +`GET /coupon` supports querying by coupon code, and filter by `user`, `bound`, `type` or by ranges of discount via `max_value`, `min_value` ## Coupon Types diff --git a/starting_app/foo/coupons/filters.py b/starting_app/foo/coupons/filters.py new file mode 100644 index 0000000..785f4ca --- /dev/null +++ b/starting_app/foo/coupons/filters.py @@ -0,0 +1,17 @@ +from django_filters import FilterSet, NumberFilter +from django.apps import apps + + +class CouponFilter(FilterSet): + """ + An initial basic filter for Coupons. This could be handled with filter_fields = () until I add in range filtering + on the discount value, then it is more helpful to do this. + """ + + min_value = NumberFilter(name='value', lookup_expr='gte') + max_value = NumberFilter(name='value', lookup_expr='lte') + + class Meta: + model = apps.get_model('coupons.Coupon') + fields = ['user', 'bound', 'type', 'min_value', 'max_value'] + diff --git a/starting_app/foo/coupons/tests/test_coupon_creation_settings.py b/starting_app/foo/coupons/tests/test_coupon_create_group.py similarity index 100% rename from starting_app/foo/coupons/tests/test_coupon_creation_settings.py rename to starting_app/foo/coupons/tests/test_coupon_create_group.py diff --git a/starting_app/foo/coupons/tests/test_coupon_list.py b/starting_app/foo/coupons/tests/test_coupon_list.py new file mode 100644 index 0000000..4dc1ae0 --- /dev/null +++ b/starting_app/foo/coupons/tests/test_coupon_list.py @@ -0,0 +1,43 @@ +from django.contrib.auth import get_user_model +from rest_framework import status +from datetime import datetime, timedelta + +from coupons.tests.base import BasicTest + + +class CouponListTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + + def test_can_list_coupon(self): + """ + Verify admins can list coupons. + """ + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False + + self.verify_built(coupon, response.data) + + def test_cant_list_coupons(self): + """ + Verify normal users can't. The group specific test in a different file. + """ + + self.assertTrue(True) + diff --git a/starting_app/foo/coupons/urls.py b/starting_app/foo/coupons/urls.py index 22c839b..80f1ed0 100644 --- a/starting_app/foo/coupons/urls.py +++ b/starting_app/foo/coupons/urls.py @@ -14,7 +14,6 @@ 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) """ from django.conf.urls import url, include -from django.contrib import admin from rest_framework import routers import views diff --git a/starting_app/foo/coupons/views.py b/starting_app/foo/coupons/views.py index 98165c6..9ef6a58 100644 --- a/starting_app/foo/coupons/views.py +++ b/starting_app/foo/coupons/views.py @@ -2,12 +2,15 @@ from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator +from django_filters.rest_framework import DjangoFilterBackend + from rest_framework import filters from rest_framework import status from rest_framework import viewsets from rest_framework.decorators import detail_route from rest_framework.response import Response +from coupons.filters import CouponFilter from coupons.models import Coupon, ClaimedCoupon from coupons.serializers import CouponSerializer, ClaimedCouponSerializer @@ -16,14 +19,18 @@ # https://djangosnippets.org/snippets/1703/ def group_required(api_command): + """ + This is implemented such that it's default open. + """ + def in_groups(u): if u.is_authenticated(): # supervisor can do anything if u.is_superuser: return True - # coupons have permissions set - if settings.COUPON_PERMISSIONS: + # coupons have permissions set (I think I may set them by default to remove this check) + if settings.COUPON_PERMISSIONS and api_command in settings.COUPON_PERMISSIONS: group_names = settings.COUPON_PERMISSIONS[api_command] # but no group specified, so anyone can. @@ -42,20 +49,36 @@ class CouponViewSet(viewsets.ModelViewSet): API endpoint that lets you create, delete, retrieve coupons. """ - filter_backends = (filters.SearchFilter,) + filter_backends = (filters.SearchFilter, DjangoFilterBackend) + filter_class = CouponFilter + search_fields = ('code', 'code_l') serializer_class = CouponSerializer - queryset = Coupon.objects.all() -# search_fields = ('code', 'code_l') - -# def list(self, request, **kwargs): -# """ -# List coupons -# """ -# -# queryset = Coupon.objects.all() -# serializer = CouponSerializer(queryset, many=True, context={'request': request}) -# -# return Response(serializer.data) + + def get_queryset(self): + """ + Return a subset of coupons or all coupons depending on who is asking. + """ + + api_command = 'LIST' + qs_all = Coupon.objects.all() + qs_none = Coupon.objects.none() + + if self.request.user.is_superuser: + return qs_all + + # This is different from the normal check because it's default closed. + if settings.COUPON_PERMISSIONS and api_command in settings.COUPON_PERMISSIONS: + group_names = settings.COUPON_PERMISSIONS[api_command] + + # So the setting is left empty, so default behavior. + if len(group_names) == 0: + return qs_none + + # group specified, so only those in the group can. + if bool(self.request.user.groups.filter(name__in=group_names)): + return qs_all + + return qs_none @method_decorator(group_required('CREATE')) def create(self, request, **kwargs): @@ -79,7 +102,7 @@ def update(self, request, pk=None, **kwargs): This forces it to return a 202 upon success instead of 200. """ - queryset = self.get_queryset() + queryset = Coupon.objects.all() coupon = get_object_or_404(queryset, pk=pk) serializer = CouponSerializer(coupon, data=request.data, context={'request': request}) @@ -95,7 +118,7 @@ def redeem(self, request, pk=None, **kwargs): Convenience endpoint for redeeming. """ - queryset = self.get_queryset() + queryset = Coupon.objects.all() coupon = get_object_or_404(queryset, pk=pk) # Maybe should do coupon.redeem(user). From 585d36b8fc6ad01f741c04c1fd5e2931ced42b95 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 07:59:49 -0800 Subject: [PATCH 26/46] Added delete. --- README.md | 9 ++- .../foo/coupons/tests/test_coupon_create.py | 23 ++++++++ .../foo/coupons/tests/test_coupon_delete.py | 46 +++++++++++++++ .../foo/coupons/tests/test_coupon_list.py | 56 ++++++++++++++++++- .../coupons/tests/test_coupon_list_filter.py | 1 + .../coupons/tests/test_coupon_list_search.py | 1 + starting_app/foo/coupons/views.py | 14 ++++- 7 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 starting_app/foo/coupons/tests/test_coupon_delete.py create mode 100644 starting_app/foo/coupons/tests/test_coupon_list_filter.py create mode 100644 starting_app/foo/coupons/tests/test_coupon_list_search.py diff --git a/README.md b/README.md index 6c7a0f4..b12c20f 100644 --- a/README.md +++ b/README.md @@ -31,19 +31,22 @@ This project depends on: You can specify a list of groups that can perform specific actions against the coupons, such as restricting who can create or list coupons. - By default all endpoints are open except list. + By default all endpoints are open except list. + + `retrieve` does not allow restriction because it doesn't generally need to support such permissions. + + `patch` is not supported as an endpoint and is therefore also not in the `COUPON_PERMISSIONS`. ``` COUPON_PERMISSIONS = { 'CREATE': ['groupa', 'groupb'], 'LIST': ['groupa'], 'DELETE': ['groupb'], - 'RETRIEVE': ['groupa'], 'UPDATE': ['groupb'], } ``` - You don't need to specify every endpoint in the list and can provide an empty list. + You don't need to specify every endpoint in the list and can provide an empty list for an endpoint. 2. Communicate with coupon endpoints. diff --git a/starting_app/foo/coupons/tests/test_coupon_create.py b/starting_app/foo/coupons/tests/test_coupon_create.py index 8a66a43..337ee2f 100644 --- a/starting_app/foo/coupons/tests/test_coupon_create.py +++ b/starting_app/foo/coupons/tests/test_coupon_create.py @@ -34,6 +34,29 @@ def test_can_create_coupon(self): self.verify_built(coupon, response.data) + def test_can_create_coupon_code_codel_mismatch(self): + """ + Verify that even if you specify the lowercase version of the code when creating, it'll be ignored. + """ + + coupon = { + 'code': 'ASDF', + 'code_l': 'mismatch', + 'type': 'percent', + } + + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False + + self.verify_built(coupon, response.data) + def test_can_create_coupon_lowercase_verification(self): """ Verify creating a coupon creates a lowercase version if it's identifier. diff --git a/starting_app/foo/coupons/tests/test_coupon_delete.py b/starting_app/foo/coupons/tests/test_coupon_delete.py new file mode 100644 index 0000000..f8dbcff --- /dev/null +++ b/starting_app/foo/coupons/tests/test_coupon_delete.py @@ -0,0 +1,46 @@ +from django.contrib.auth import get_user_model +from rest_framework import status + +from coupons.tests.base import BasicTest + + +class CouponDeleteTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + + def test_can_delete_coupon(self): + """ + Verify we can delete a coupon. By default, anyone can. + """ + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False + + self.verify_built(coupon, response.data) + + coupon_id = response.data['id'] + + self.login(username='admin') + response = self.client.delete('/coupon/%s' % coupon_id, coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + response = self.client.get('/coupon', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(0, len(response.data)) + self.logout() + diff --git a/starting_app/foo/coupons/tests/test_coupon_list.py b/starting_app/foo/coupons/tests/test_coupon_list.py index 4dc1ae0..4d61505 100644 --- a/starting_app/foo/coupons/tests/test_coupon_list.py +++ b/starting_app/foo/coupons/tests/test_coupon_list.py @@ -24,9 +24,9 @@ def test_can_list_coupon(self): with self.settings(ROOT_URLCONF='coupons.urls'): self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() coupon['code_l'] = coupon['code'].lower() coupon['repeat'] = 0 @@ -34,10 +34,62 @@ def test_can_list_coupon(self): self.verify_built(coupon, response.data) + coupon['code'] = 'new_one' + del coupon['code_l'] + + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + coupon['code_l'] = coupon['code'].lower() + + self.verify_built(coupon, response.data) + + response = self.client.get('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, len(response.data)) + + self.logout() + def test_cant_list_coupons(self): """ Verify normal users can't. The group specific test in a different file. """ - self.assertTrue(True) + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False + + self.verify_built(coupon, response.data) + + coupon['code'] = 'new_one' + del coupon['code_l'] + + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + coupon['code_l'] = coupon['code'].lower() + + self.verify_built(coupon, response.data) + + response = self.client.get('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, len(response.data)) + + self.logout() + + self.login(username='user') + response = self.client.get('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(0, len(response.data)) + self.logout() diff --git a/starting_app/foo/coupons/tests/test_coupon_list_filter.py b/starting_app/foo/coupons/tests/test_coupon_list_filter.py new file mode 100644 index 0000000..48b1e81 --- /dev/null +++ b/starting_app/foo/coupons/tests/test_coupon_list_filter.py @@ -0,0 +1 @@ +# XXX: Implement these diff --git a/starting_app/foo/coupons/tests/test_coupon_list_search.py b/starting_app/foo/coupons/tests/test_coupon_list_search.py new file mode 100644 index 0000000..265879e --- /dev/null +++ b/starting_app/foo/coupons/tests/test_coupon_list_search.py @@ -0,0 +1 @@ +# XXX: Implement these. diff --git a/starting_app/foo/coupons/views.py b/starting_app/foo/coupons/views.py index 9ef6a58..0b6db5f 100644 --- a/starting_app/foo/coupons/views.py +++ b/starting_app/foo/coupons/views.py @@ -93,6 +93,17 @@ def create(self, request, **kwargs): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @method_decorator(group_required('DELETE')) + def destroy(self, request, pk=None, **kwargs): + """ + Delete the coupon. + """ + + coupon = get_object_or_404(Coupon.objects.all(), pk=pk) + coupon.delete() + + return Response(status=status.HTTP_204_NO_CONTENT) + def partial_update(self, request, pk=None, **kwargs): return Response(status=status.HTTP_404_NOT_FOUND) @@ -102,8 +113,7 @@ def update(self, request, pk=None, **kwargs): This forces it to return a 202 upon success instead of 200. """ - queryset = Coupon.objects.all() - coupon = get_object_or_404(queryset, pk=pk) + coupon = get_object_or_404(Coupon.objects.all(), pk=pk) serializer = CouponSerializer(coupon, data=request.data, context={'request': request}) if serializer.is_valid(): From e36560354513987aa9c431b5d91b1f9ace2069bb Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 08:15:03 -0800 Subject: [PATCH 27/46] implemented search unit-tests. --- .../foo/coupons/tests/test_coupon_list.py | 6 +- .../coupons/tests/test_coupon_list_search.py | 120 +++++++++++++++++- 2 files changed, 122 insertions(+), 4 deletions(-) diff --git a/starting_app/foo/coupons/tests/test_coupon_list.py b/starting_app/foo/coupons/tests/test_coupon_list.py index 4d61505..95de16c 100644 --- a/starting_app/foo/coupons/tests/test_coupon_list.py +++ b/starting_app/foo/coupons/tests/test_coupon_list.py @@ -44,7 +44,7 @@ def test_can_list_coupon(self): self.verify_built(coupon, response.data) - response = self.client.get('/coupon', coupon, format='json') + response = self.client.get('/coupon', format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(2, len(response.data)) @@ -82,14 +82,14 @@ def test_cant_list_coupons(self): self.verify_built(coupon, response.data) - response = self.client.get('/coupon', coupon, format='json') + response = self.client.get('/coupon', format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(2, len(response.data)) self.logout() self.login(username='user') - response = self.client.get('/coupon', coupon, format='json') + response = self.client.get('/coupon', format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(0, len(response.data)) self.logout() diff --git a/starting_app/foo/coupons/tests/test_coupon_list_search.py b/starting_app/foo/coupons/tests/test_coupon_list_search.py index 265879e..b57cd69 100644 --- a/starting_app/foo/coupons/tests/test_coupon_list_search.py +++ b/starting_app/foo/coupons/tests/test_coupon_list_search.py @@ -1 +1,119 @@ -# XXX: Implement these. +from django.contrib.auth import get_user_model +from rest_framework import status + +from coupons.tests.base import BasicTest + + +class CouponListSearchTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + + def test_can_search_coupon(self): + """ + Verify admins can list coupons. + """ + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False + + self.verify_built(coupon, response.data) + + coupon['code'] = 'new_one' + del coupon['code_l'] + + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + coupon['code_l'] = coupon['code'].lower() + + self.verify_built(coupon, response.data) + + response = self.client.get('/coupon', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, len(response.data)) + + response = self.client.get('/coupon', {'search': 'as'}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + + response = self.client.get('/coupon', {'search': 'AS'}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + + response = self.client.get('/coupon', {'search': 'NEW'}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + + self.logout() + + def test_cant_search_coupons(self): + """ + Verify normal users can't. The group specific test in a different file. + """ + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False + + self.verify_built(coupon, response.data) + + coupon['code'] = 'new_one' + del coupon['code_l'] + + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + coupon['code_l'] = coupon['code'].lower() + + self.verify_built(coupon, response.data) + + response = self.client.get('/coupon', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, len(response.data)) + + self.logout() + + self.login(username='user') + response = self.client.get('/coupon', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(0, len(response.data)) + + response = self.client.get('/coupon', {'search': 'as'}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(0, len(response.data)) + + response = self.client.get('/coupon', {'search': 'AS'}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(0, len(response.data)) + + response = self.client.get('/coupon', {'search': 'NEW'}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(0, len(response.data)) + + self.logout() From fbbcef756de3722b359d7fecdb6addf21429ec78 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 09:25:22 -0800 Subject: [PATCH 28/46] Implemented unit-tests to somewhat verify and fix #2 --- .../coupons/tests/test_coupon_delete_group.py | 74 ++++++++++ .../coupons/tests/test_coupon_list_filter.py | 137 +++++++++++++++++- .../coupons/tests/test_coupon_list_group.py | 1 + .../coupons/tests/test_coupon_list_search.py | 2 +- .../coupons/tests/test_coupon_update_group.py | 77 ++++++++++ 5 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 starting_app/foo/coupons/tests/test_coupon_delete_group.py create mode 100644 starting_app/foo/coupons/tests/test_coupon_list_group.py create mode 100644 starting_app/foo/coupons/tests/test_coupon_update_group.py diff --git a/starting_app/foo/coupons/tests/test_coupon_delete_group.py b/starting_app/foo/coupons/tests/test_coupon_delete_group.py new file mode 100644 index 0000000..15da4fa --- /dev/null +++ b/starting_app/foo/coupons/tests/test_coupon_delete_group.py @@ -0,0 +1,74 @@ +from django.contrib.auth.models import Group +from django.contrib.auth import get_user_model +from rest_framework import status + +from coupons.tests.base import BasicTest + + +class CouponDeleteSettingsTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False + + self.verify_built(coupon, response.data) + + self.coupon_id = response.data['id'] + + def test_cant_delete_coupon_if_not_in_group(self): + """ + Verify the user can restrict permissions. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'DELETE': ['group_a']}): + + self.login(username='user') + response = self.client.delete('/coupon/%s' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.logout() + + def test_can_delete_coupon_if_group_empty(self): + """ + Verify the user can restrict permissions. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'DELETE': []}): + + self.login(username='user') + response = self.client.delete('/coupon/%s' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.logout() + + def test_can_delete_coupon_if_in_group(self): + """ + Verify the user can restrict permissions. + """ + + g, _ = Group.objects.get_or_create(name='group_a') + g.user_set.add(self.user) + + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'DELETE': ['group_a']}): + + self.login(username='user') + response = self.client.delete('/coupon/%s' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.logout() diff --git a/starting_app/foo/coupons/tests/test_coupon_list_filter.py b/starting_app/foo/coupons/tests/test_coupon_list_filter.py index 48b1e81..24aa96b 100644 --- a/starting_app/foo/coupons/tests/test_coupon_list_filter.py +++ b/starting_app/foo/coupons/tests/test_coupon_list_filter.py @@ -1 +1,136 @@ -# XXX: Implement these +from django.contrib.auth import get_user_model +from rest_framework import status + +from coupons.tests.base import BasicTest + + +class CouponListFilterTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + + # create unbound coupon + coupon = { + 'code': 'ten', + 'type': 'percent', + 'value': 0.10, + } + + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # create unbound coupon + coupon = { + 'code': 'fifty', + 'type': 'percent', + 'value': 0.50, + } + + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # create coupon for range testing + coupon = { + 'code': 'hundred', + 'type': 'value', + 'value': 100, + } + + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # create bound coupon + coupon = { + 'code': 'bound_test', + 'type': 'percent', + 'value': 0.10, + 'bound': True, + 'user': self.user.id, + } + + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + self.logout() + + def test_can_filter_coupon(self): + """ + Verify admins can filter coupons. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + + self.login(username='admin') + response = self.client.get('/coupon', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(4, len(response.data)) + + # filter by bound=False + response = self.client.get('/coupon', {'bound': False}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(3, len(response.data)) + + # filter by bound=True + response = self.client.get('/coupon', {'bound': True}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + + # filter by user + response = self.client.get('/coupon', {'user': self.user.id}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + + # filter by type + response = self.client.get('/coupon', {'type': 'percent'}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(3, len(response.data)) + + response = self.client.get('/coupon', {'type': 'value'}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + + # filter by value + response = self.client.get('/coupon', {'min_value': 0.50}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, len(response.data)) + + response = self.client.get('/coupon', {'max_value': 0.49}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, len(response.data)) + + response = self.client.get('/coupon', {'type': 'value', 'min_value': 0.50}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + self.assertEqual('value', response.data[0]['type']) + self.assertEqual(100, int(float(response.data[0]['value']))) + + self.logout() + + def test_cant_filter_coupons(self): + """ + Verify normal users can't. The group specific test in a different file. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + + self.login(username='admin') + response = self.client.get('/coupon', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(4, len(response.data)) + self.logout() + + self.login(username='user') + response = self.client.get('/coupon', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(0, len(response.data)) + + response = self.client.get('/coupon', {'type': 'value', 'min_value': 0.50}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(0, len(response.data)) + + self.logout() diff --git a/starting_app/foo/coupons/tests/test_coupon_list_group.py b/starting_app/foo/coupons/tests/test_coupon_list_group.py new file mode 100644 index 0000000..265879e --- /dev/null +++ b/starting_app/foo/coupons/tests/test_coupon_list_group.py @@ -0,0 +1 @@ +# XXX: Implement these. diff --git a/starting_app/foo/coupons/tests/test_coupon_list_search.py b/starting_app/foo/coupons/tests/test_coupon_list_search.py index b57cd69..5d78d1e 100644 --- a/starting_app/foo/coupons/tests/test_coupon_list_search.py +++ b/starting_app/foo/coupons/tests/test_coupon_list_search.py @@ -13,7 +13,7 @@ def setUp(self): def test_can_search_coupon(self): """ - Verify admins can list coupons. + Verify admins can search coupons. """ coupon = { diff --git a/starting_app/foo/coupons/tests/test_coupon_update_group.py b/starting_app/foo/coupons/tests/test_coupon_update_group.py new file mode 100644 index 0000000..fc82af3 --- /dev/null +++ b/starting_app/foo/coupons/tests/test_coupon_update_group.py @@ -0,0 +1,77 @@ +from django.contrib.auth.models import Group +from django.contrib.auth import get_user_model +from rest_framework import status + +from coupons.tests.base import BasicTest + + +class CouponUpdateSettingsTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False + + self.verify_built(coupon, response.data) + + coupon['repeat'] = 50 + + self.coupon_id = response.data['id'] + self.coupon = coupon + + def test_cant_update_coupon_if_not_in_group(self): + """ + Verify the user can restrict permissions. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'UPDATE': ['group_a']}): + + self.login(username='user') + response = self.client.put('/coupon/%s' % self.coupon_id, self.coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.logout() + + def test_can_update_coupon_if_group_empty(self): + """ + Verify the user can restrict permissions. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'UPDATE': []}): + + self.login(username='user') + response = self.client.put('/coupon/%s' % self.coupon_id, self.coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.logout() + + def test_can_update_coupon_if_in_group(self): + """ + Verify the user can restrict permissions. + """ + + g, _ = Group.objects.get_or_create(name='group_a') + g.user_set.add(self.user) + + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'UPDATE': ['group_a']}): + + self.login(username='user') + response = self.client.put('/coupon/%s' % self.coupon_id, self.coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.logout() From 64a8b2834687ce9b4dfe3d507de8cc09156a2a41 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 17:50:14 -0800 Subject: [PATCH 29/46] Added some tests, and it's neato. --- NOTES.md | 8 ++ .../coupons/tests/test_coupon_create_group.py | 2 - .../foo/coupons/tests/test_coupon_list.py | 57 +++++++++++-- .../coupons/tests/test_coupon_list_filter.py | 7 +- .../coupons/tests/test_coupon_list_group.py | 80 ++++++++++++++++++- starting_app/foo/coupons/views.py | 6 +- 6 files changed, 145 insertions(+), 15 deletions(-) create mode 100644 NOTES.md diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..1cc7e53 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,8 @@ + + +https://docs.djangoproject.com/en/1.10/intro/reusable-apps/ + +http://www.revsys.com/blog/2014/nov/21/recommended-django-project-layout/ + +http://python-packaging.readthedocs.io/en/latest/dependencies.html + diff --git a/starting_app/foo/coupons/tests/test_coupon_create_group.py b/starting_app/foo/coupons/tests/test_coupon_create_group.py index 9485249..7a8b113 100644 --- a/starting_app/foo/coupons/tests/test_coupon_create_group.py +++ b/starting_app/foo/coupons/tests/test_coupon_create_group.py @@ -1,8 +1,6 @@ -from django.conf import settings from django.contrib.auth.models import Group from django.contrib.auth import get_user_model from rest_framework import status -from datetime import datetime, timedelta from coupons.tests.base import BasicTest diff --git a/starting_app/foo/coupons/tests/test_coupon_list.py b/starting_app/foo/coupons/tests/test_coupon_list.py index 95de16c..70b02a7 100644 --- a/starting_app/foo/coupons/tests/test_coupon_list.py +++ b/starting_app/foo/coupons/tests/test_coupon_list.py @@ -11,6 +11,7 @@ def setUp(self): u = get_user_model() u.objects.create_superuser('admin', 'john@snow.com', self.PW) self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + self.user2 = u.objects.create_user('user1', 'me1@snow.com', self.PW) def test_can_list_coupon(self): """ @@ -52,17 +53,17 @@ def test_can_list_coupon(self): def test_cant_list_coupons(self): """ - Verify normal users can't. The group specific test in a different file. + Verify normal users can only see those bound to them. The group specific test in a different file. """ - coupon = { - 'code': 'ASDF', - 'type': 'percent', - } - with self.settings(ROOT_URLCONF='coupons.urls'): - self.login(username='admin') + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + self.login(username='admin') response = self.client.post('/coupon', coupon, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -85,7 +86,6 @@ def test_cant_list_coupons(self): response = self.client.get('/coupon', format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(2, len(response.data)) - self.logout() self.login(username='user') @@ -93,3 +93,44 @@ def test_cant_list_coupons(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(0, len(response.data)) self.logout() + + self.login(username='admin') + + coupon = { + 'code': 'ASDF2', + 'type': 'percent', + 'bound': True, + 'user': self.user2.id, + 'repeat': 0, + } + + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + coupon = { + 'code': 'ASDF3', + 'type': 'percent', + 'bound': True, + 'user': self.user.id, + 'repeat': 0, + } + + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + should_find_id = response.data['id'] + + coupon['code_l'] = coupon['code'].lower() + self.verify_built(coupon, response.data) + + response = self.client.get('/coupon', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(4, len(response.data)) + self.logout() + + self.login(username='user') + response = self.client.get('/coupon', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + self.logout() + + self.assertEqual(should_find_id, response.data[0]['id']) diff --git a/starting_app/foo/coupons/tests/test_coupon_list_filter.py b/starting_app/foo/coupons/tests/test_coupon_list_filter.py index 24aa96b..f5fa753 100644 --- a/starting_app/foo/coupons/tests/test_coupon_list_filter.py +++ b/starting_app/foo/coupons/tests/test_coupon_list_filter.py @@ -127,10 +127,15 @@ def test_cant_filter_coupons(self): self.login(username='user') response = self.client.get('/coupon', format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(0, len(response.data)) + self.assertEqual(1, len(response.data)) response = self.client.get('/coupon', {'type': 'value', 'min_value': 0.50}, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(0, len(response.data)) + response = self.client.get('/coupon', {'type': 'percent', 'bound': True}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + self.logout() + diff --git a/starting_app/foo/coupons/tests/test_coupon_list_group.py b/starting_app/foo/coupons/tests/test_coupon_list_group.py index 265879e..3e247a4 100644 --- a/starting_app/foo/coupons/tests/test_coupon_list_group.py +++ b/starting_app/foo/coupons/tests/test_coupon_list_group.py @@ -1 +1,79 @@ -# XXX: Implement these. +# if the user is in the group, they can see all of them. + +from django.contrib.auth.models import Group +from django.contrib.auth import get_user_model +from rest_framework import status + +from coupons.tests.base import BasicTest + + +class CouponListSettingsTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False + + self.verify_built(coupon, response.data) + + self.coupon_id = response.data['id'] + + def test_cant_list_coupon_if_not_in_group(self): + """ + Verify the user can restrict permissions. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'LIST': ['group_a']}): + + self.login(username='user') + response = self.client.get('/coupon', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(0, len(response.data)) + self.logout() + + def test_can_list_coupon_if_group_empty(self): + """ + Verify the user can restrict permissions. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'LIST': []}): + + self.login(username='user') + response = self.client.get('/coupon', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(0, len(response.data)) + self.logout() + + def test_can_list_coupon_if_in_group(self): + """ + Verify the user can restrict permissions. + """ + + g, _ = Group.objects.get_or_create(name='group_a') + g.user_set.add(self.user) + + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'LIST': ['group_a']}): + + self.login(username='user') + response = self.client.get('/coupon', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + self.logout() diff --git a/starting_app/foo/coupons/views.py b/starting_app/foo/coupons/views.py index 0b6db5f..4977da5 100644 --- a/starting_app/foo/coupons/views.py +++ b/starting_app/foo/coupons/views.py @@ -61,7 +61,7 @@ def get_queryset(self): api_command = 'LIST' qs_all = Coupon.objects.all() - qs_none = Coupon.objects.none() + qs_some = Coupon.objects.filter(bound=True, user=self.request.user.id) if self.request.user.is_superuser: return qs_all @@ -72,13 +72,13 @@ def get_queryset(self): # So the setting is left empty, so default behavior. if len(group_names) == 0: - return qs_none + return qs_some # group specified, so only those in the group can. if bool(self.request.user.groups.filter(name__in=group_names)): return qs_all - return qs_none + return qs_some @method_decorator(group_required('CREATE')) def create(self, request, **kwargs): From e142956640e01df4095ede1ad2332e75d1c4d90a Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 18:51:04 -0800 Subject: [PATCH 30/46] Redeemed list working and testing fine. --- README.md | 20 ++-- starting_app/foo/coupons/serializers.py | 1 + .../foo/coupons/tests/test_coupon_redeemed.py | 99 +++++++++++++++++++ .../tests/test_coupon_redeemed_group.py | 90 +++++++++++++++++ starting_app/foo/coupons/views.py | 80 +++++++++++++-- 5 files changed, 275 insertions(+), 15 deletions(-) create mode 100644 starting_app/foo/coupons/tests/test_coupon_redeemed.py create mode 100644 starting_app/foo/coupons/tests/test_coupon_redeemed_group.py diff --git a/README.md b/README.md index b12c20f..2c7280d 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ This project depends on: 'LIST': ['groupa'], 'DELETE': ['groupb'], 'UPDATE': ['groupb'], + 'REDEEMED': ['groupc'], } ``` @@ -61,15 +62,16 @@ This project depends on: As stated above, by default any user in the system can touch any of the below endpoints, except where specified in bold. - | Endpoint | Details | - | ------------------------- | --------------------------------------------------------------------------- | - | `GET /coupon` | List all coupons in the system, **only superuser or in group can do this**. | - | `GET /coupon/{pk}` | Retrieve details about a coupon by database id | - | `POST /coupon` | Create a new coupon | - | `PUT /coupon/{pk}` | Update a coupon | - | `DELETE /coupon/{pk}` | Delete a coupon | - | `PUT /coupon/{pk}/redeem` | Redeem a coupon by database id | - | `PATCH /coupon/{pk}` | **Not supported** | + | Endpoint | Details | + | --------------------------- | --------------------------------------------------------------------------------------- | + | `GET /coupon` | List all coupons in the system, **only superuser or in group can do this**. | + | `GET /coupon/{pk}` | Retrieve details about a coupon by database id | + | `POST /coupon` | Create a new coupon | + | `PUT /coupon/{pk}` | Update a coupon | + | `DELETE /coupon/{pk}` | Delete a coupon | + | `PUT /coupon/{pk}/redeem` | Redeem a coupon by database id | + | `GET /coupon/{pk}/redeemed` | List all times specified coupon was redeemed, **superuser or group member can see all** | + | `PATCH /coupon/{pk}` | **Not supported** | ## Querying diff --git a/starting_app/foo/coupons/serializers.py b/starting_app/foo/coupons/serializers.py index c45e28c..3ca62f8 100644 --- a/starting_app/foo/coupons/serializers.py +++ b/starting_app/foo/coupons/serializers.py @@ -90,6 +90,7 @@ def validate(self, data): raise serializers.ValidationError("Coupon bound to another user.") # Is the coupon redeemed already beyond what's allowed? + # XXX return data diff --git a/starting_app/foo/coupons/tests/test_coupon_redeemed.py b/starting_app/foo/coupons/tests/test_coupon_redeemed.py new file mode 100644 index 0000000..fd746ed --- /dev/null +++ b/starting_app/foo/coupons/tests/test_coupon_redeemed.py @@ -0,0 +1,99 @@ +# XXX: Verify you can only see your own redeemed entries. +# XXX: Verify the admin can list all for the specific coupon. + +from django.contrib.auth import get_user_model +from rest_framework import status + +from coupons.tests.base import BasicTest + + +class CouponRedeemedTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + self.user2 = u.objects.create_user('user1', 'me1@snow.com', self.PW) + + def test_can_redeemed_list_all_users(self): + """ + Verify that an admin can list all claims for a specific coupon. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + coupon_id = response.data['id'] + self.logout() + + self.login(username='user') + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='user1') + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='admin') + response = self.client.get('/coupon/%s/redeemed' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, len(response.data)) + self.logout() + + def test_can_redeemed_list_mine(self): + """ + Verify that a user can only see their claims for a specific coupon. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + coupon_id = response.data['id'] + self.logout() + + self.login(username='user') + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='user1') + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='admin') + response = self.client.get('/coupon/%s/redeemed' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, len(response.data)) + self.logout() + + self.login(username='user') + response = self.client.get('/coupon/%s/redeemed' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + self.assertEqual(self.user.id, response.data[0]['user']) + self.logout() + + self.login(username='user1') + response = self.client.get('/coupon/%s/redeemed' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + self.assertEqual(self.user2.id, response.data[0]['user']) + self.logout() + diff --git a/starting_app/foo/coupons/tests/test_coupon_redeemed_group.py b/starting_app/foo/coupons/tests/test_coupon_redeemed_group.py new file mode 100644 index 0000000..77d14f5 --- /dev/null +++ b/starting_app/foo/coupons/tests/test_coupon_redeemed_group.py @@ -0,0 +1,90 @@ +# if the user is in the group, they can see all of them. + +from django.contrib.auth.models import Group +from django.contrib.auth import get_user_model +from rest_framework import status + +from coupons.tests.base import BasicTest + + +class CouponRedeemedSettingsTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + self.user2 = u.objects.create_user('user1', 'me1@snow.com', self.PW) + + with self.settings(ROOT_URLCONF='coupons.urls'): + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.coupon_id = response.data['id'] + self.logout() + + self.login(username='user') + response = self.client.put('/coupon/%s/redeem' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='user1') + response = self.client.put('/coupon/%s/redeem' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='admin') + response = self.client.get('/coupon/%s/redeemed' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, len(response.data)) + self.logout() + + def test_cant_redeemed_coupon_if_not_in_group(self): + """ + Verify the user can restrict permissions. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'REDEEMED': ['group_a']}): + + self.login(username='user') + response = self.client.get('/coupon/%s/redeemed' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + self.logout() + + def test_cant_redeemed_coupon_if_group_empty(self): + """ + Verify the user can restrict permissions. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'REDEEMED': []}): + + self.login(username='user') + response = self.client.get('/coupon/%s/redeemed' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + self.logout() + + def test_can_redeemed_coupon_if_in_group(self): + """ + Verify the user can restrict permissions. + """ + + g, _ = Group.objects.get_or_create(name='group_a') + g.user_set.add(self.user) + + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'REDEEMED': ['group_a']}): + + self.login(username='user') + response = self.client.get('/coupon/%s/redeemed' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, len(response.data)) + self.logout() diff --git a/starting_app/foo/coupons/views.py b/starting_app/foo/coupons/views.py index 4977da5..41a2493 100644 --- a/starting_app/foo/coupons/views.py +++ b/starting_app/foo/coupons/views.py @@ -1,12 +1,11 @@ from django.conf import settings +from django.contrib.auth.decorators import user_passes_test from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import filters -from rest_framework import status -from rest_framework import viewsets +from rest_framework import filters, status, viewsets from rest_framework.decorators import detail_route from rest_framework.response import Response @@ -14,10 +13,8 @@ from coupons.models import Coupon, ClaimedCoupon from coupons.serializers import CouponSerializer, ClaimedCouponSerializer -from django.contrib.auth.decorators import user_passes_test - -# https://djangosnippets.org/snippets/1703/ +# based on https://djangosnippets.org/snippets/1703/ def group_required(api_command): """ This is implemented such that it's default open. @@ -44,6 +41,38 @@ def in_groups(u): return user_passes_test(in_groups) +def get_redeemed_queryset(user, coupon_id=None): + """ + Return a consistent list of the redeemed list. across the two endpoints. + """ + + api_command = 'REDEEMED' + + # If the a coupon isn't specified, get them all. + if coupon_id is None: + qs_all = ClaimedCoupon.objects.all() + qs_some = ClaimedCoupon.objects.filter(user=user.id) + else: + qs_all = ClaimedCoupon.objects.filter(coupon=coupon_id) + qs_some = ClaimedCoupon.objects.filter(coupon=coupon_id, user=user.id) + + if user.is_superuser: + return qs_all + + if settings.COUPON_PERMISSIONS and api_command in settings.COUPON_PERMISSIONS: + group_names = settings.COUPON_PERMISSIONS[api_command] + + # So the setting is left empty, so default behavior. + if len(group_names) == 0: + return qs_some + + # group specified, so only those in the group can. + if bool(user.groups.filter(name__in=group_names)): + return qs_all + + return qs_some + + class CouponViewSet(viewsets.ModelViewSet): """ API endpoint that lets you create, delete, retrieve coupons. @@ -122,6 +151,19 @@ def update(self, request, pk=None, **kwargs): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @detail_route(methods=['get']) + def redeemed(self, request, pk=None, **kwargs): + """ + Convenience endpoint for getting list of claimed instances for a coupon. + """ + + coupon = get_object_or_404(Coupon.objects.all(), pk=pk) + qs = get_redeemed_queryset(self.request.user, coupon.id) + + serializer = ClaimedCouponSerializer(qs, many=True, context={'request': request}) + + return Response(serializer.data) + @detail_route(methods=['put']) def redeem(self, request, pk=None, **kwargs): """ @@ -146,3 +188,29 @@ def redeem(self, request, pk=None, **kwargs): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ClaimedCouponViewSet(viewsets.ModelViewSet): + """ + API endpoint that lets you retrieve claimed coupon details. + """ + + filter_backends = (DjangoFilterBackend,) + filter_fields = ('user',) + serializer_class = ClaimedCouponSerializer + + def get_queryset(self): + return get_redeemed_queryset(self.request.user) + + def create(self, request, **kwargs): + return Response(status=status.HTTP_404_NOT_FOUND) + + def destroy(self, request, pk=None, **kwargs): + return Response(status=status.HTTP_404_NOT_FOUND) + + def partial_update(self, request, pk=None, **kwargs): + return Response(status=status.HTTP_404_NOT_FOUND) + + def update(self, request, pk=None, **kwargs): + return Response(status=status.HTTP_404_NOT_FOUND) + From 6ecdca3ceb77f868dfa7e2923d06834ed870385a Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 18:52:40 -0800 Subject: [PATCH 31/46] more details being checked. --- starting_app/foo/coupons/tests/test_coupon_redeemed_group.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/starting_app/foo/coupons/tests/test_coupon_redeemed_group.py b/starting_app/foo/coupons/tests/test_coupon_redeemed_group.py index 77d14f5..dfd4669 100644 --- a/starting_app/foo/coupons/tests/test_coupon_redeemed_group.py +++ b/starting_app/foo/coupons/tests/test_coupon_redeemed_group.py @@ -56,6 +56,7 @@ def test_cant_redeemed_coupon_if_not_in_group(self): response = self.client.get('/coupon/%s/redeemed' % self.coupon_id, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(1, len(response.data)) + self.assertEqual(self.user.id, response.data[0]['user']) self.logout() def test_cant_redeemed_coupon_if_group_empty(self): @@ -70,6 +71,7 @@ def test_cant_redeemed_coupon_if_group_empty(self): response = self.client.get('/coupon/%s/redeemed' % self.coupon_id, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(1, len(response.data)) + self.assertEqual(self.user.id, response.data[0]['user']) self.logout() def test_can_redeemed_coupon_if_in_group(self): From dbc7ac6d2ccb0919348279f1097d6fdaf7d93436 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 19:11:44 -0800 Subject: [PATCH 32/46] Working on redeemed endpoint. --- README.md | 3 +- .../foo/coupons/tests/test_redeemed_list.py | 68 ++++++++++++++ .../tests/test_redeemed_list_filter.py | 89 +++++++++++++++++++ .../coupons/tests/test_redeemed_list_group.py | 1 + starting_app/foo/coupons/urls.py | 1 + 5 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 starting_app/foo/coupons/tests/test_redeemed_list.py create mode 100644 starting_app/foo/coupons/tests/test_redeemed_list_filter.py create mode 100644 starting_app/foo/coupons/tests/test_redeemed_list_group.py diff --git a/README.md b/README.md index 2c7280d..df72e47 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ This project depends on: | Endpoint | Details | | --------------------------- | --------------------------------------------------------------------------------------- | - | `GET /coupon` | List all coupons in the system, **only superuser or in group can do this**. | + | `GET /coupon` | List all coupons in the system, **only superuser or in group can see all**. | | `GET /coupon/{pk}` | Retrieve details about a coupon by database id | | `POST /coupon` | Create a new coupon | | `PUT /coupon/{pk}` | Update a coupon | @@ -72,6 +72,7 @@ This project depends on: | `PUT /coupon/{pk}/redeem` | Redeem a coupon by database id | | `GET /coupon/{pk}/redeemed` | List all times specified coupon was redeemed, **superuser or group member can see all** | | `PATCH /coupon/{pk}` | **Not supported** | + | `GET /redeemed` | List all redeemed instances, filter-able **only superuser or in group can do see all** | ## Querying diff --git a/starting_app/foo/coupons/tests/test_redeemed_list.py b/starting_app/foo/coupons/tests/test_redeemed_list.py new file mode 100644 index 0000000..85ae8c6 --- /dev/null +++ b/starting_app/foo/coupons/tests/test_redeemed_list.py @@ -0,0 +1,68 @@ +from django.contrib.auth import get_user_model +from rest_framework import status + +from coupons.tests.base import BasicTest + + +class RedeemedListTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + self.user2 = u.objects.create_user('user1', 'me1@snow.com', self.PW) + + with self.settings(ROOT_URLCONF='coupons.urls'): + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.coupon_id = response.data['id'] + self.logout() + + self.login(username='user') + response = self.client.put('/coupon/%s/redeem' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='user1') + response = self.client.put('/coupon/%s/redeem' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='admin') + response = self.client.get('/coupon/%s/redeemed' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, len(response.data)) + self.logout() + + def test_can_list_redeemed(self): + """ + Verify admins can list redeemed. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.get('/redeemed', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, len(response.data)) + self.logout() + + def test_cant_list_redeemed(self): + """ + Verify normal users can only see those bound to them. The group specific test in a different file. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + + self.login(username='user') + response = self.client.get('/redeemed', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + self.assertEqual(self.user.id, response.data[0]['user']) + self.logout() diff --git a/starting_app/foo/coupons/tests/test_redeemed_list_filter.py b/starting_app/foo/coupons/tests/test_redeemed_list_filter.py new file mode 100644 index 0000000..11af51b --- /dev/null +++ b/starting_app/foo/coupons/tests/test_redeemed_list_filter.py @@ -0,0 +1,89 @@ +from django.contrib.auth import get_user_model +from rest_framework import status + +from coupons.tests.base import BasicTest + + +class RedeemedListFilterTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + self.user2 = u.objects.create_user('user1', 'me1@snow.com', self.PW) + + with self.settings(ROOT_URLCONF='coupons.urls'): + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.coupon_id = response.data['id'] + self.logout() + + self.login(username='user') + response = self.client.put('/coupon/%s/redeem' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='user1') + response = self.client.put('/coupon/%s/redeem' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='admin') + response = self.client.get('/coupon/%s/redeemed' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, len(response.data)) + self.logout() + + def test_can_list_redeemed_filtering(self): + """ + Verify admins can list redeemed. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.get('/redeemed', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, len(response.data)) + + response = self.client.get('/redeemed', {'user': self.user.id}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + self.assertEqual(self.user.id, response.data[0]['user']) + + response = self.client.get('/redeemed', {'user': self.user2.id}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + self.assertEqual(self.user2.id, response.data[0]['user']) + + self.logout() + + def test_cant_list_redeemed_filtering(self): + """ + Verify normal users can only see those bound to them. The group specific test in a different file. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + + self.login(username='user') + response = self.client.get('/redeemed', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + self.assertEqual(self.user.id, response.data[0]['user']) + + response = self.client.get('/redeemed', {'user': self.user.id}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + self.assertEqual(self.user.id, response.data[0]['user']) + + response = self.client.get('/redeemed', {'user': self.user2.id}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(0, len(response.data)) + self.logout() + diff --git a/starting_app/foo/coupons/tests/test_redeemed_list_group.py b/starting_app/foo/coupons/tests/test_redeemed_list_group.py new file mode 100644 index 0000000..1e0518b --- /dev/null +++ b/starting_app/foo/coupons/tests/test_redeemed_list_group.py @@ -0,0 +1 @@ +# XXX diff --git a/starting_app/foo/coupons/urls.py b/starting_app/foo/coupons/urls.py index 80f1ed0..e836020 100644 --- a/starting_app/foo/coupons/urls.py +++ b/starting_app/foo/coupons/urls.py @@ -20,6 +20,7 @@ router = routers.DefaultRouter(trailing_slash=False) router.register(r'coupon', views.CouponViewSet, base_name='coupon') +router.register(r'redeemed', views.ClaimedCouponViewSet, base_name='redeemed') urlpatterns = [ url(r'^', include(router.urls)), From 4e5f8881116e9a0c8275c40234c0a9646d26a4ee Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 19:16:04 -0800 Subject: [PATCH 33/46] promoted to a higher folder scope to reduce extra depth. --- README.md | 3 +++ {starting_app/foo => foo}/coupons/__init__.py | 0 {starting_app/foo => foo}/coupons/filters.py | 0 {starting_app/foo => foo}/coupons/migrations/0001_initial.py | 0 {starting_app/foo => foo}/coupons/migrations/__init__.py | 0 {starting_app/foo => foo}/coupons/models.py | 0 {starting_app/foo => foo}/coupons/serializers.py | 0 {starting_app/foo => foo}/coupons/tests/__init__.py | 0 {starting_app/foo => foo}/coupons/tests/base.py | 0 {starting_app/foo => foo}/coupons/tests/test_coupon_create.py | 0 .../foo => foo}/coupons/tests/test_coupon_create_group.py | 0 {starting_app/foo => foo}/coupons/tests/test_coupon_delete.py | 0 .../foo => foo}/coupons/tests/test_coupon_delete_group.py | 0 {starting_app/foo => foo}/coupons/tests/test_coupon_list.py | 0 .../foo => foo}/coupons/tests/test_coupon_list_filter.py | 0 .../foo => foo}/coupons/tests/test_coupon_list_group.py | 0 .../foo => foo}/coupons/tests/test_coupon_list_search.py | 0 {starting_app/foo => foo}/coupons/tests/test_coupon_redeem.py | 0 .../foo => foo}/coupons/tests/test_coupon_redeemed.py | 0 .../foo => foo}/coupons/tests/test_coupon_redeemed_group.py | 0 {starting_app/foo => foo}/coupons/tests/test_coupon_update.py | 0 .../foo => foo}/coupons/tests/test_coupon_update_group.py | 0 {starting_app/foo => foo}/coupons/tests/test_redeemed_list.py | 0 .../foo => foo}/coupons/tests/test_redeemed_list_filter.py | 0 .../foo => foo}/coupons/tests/test_redeemed_list_group.py | 0 {starting_app/foo => foo}/coupons/urls.py | 0 {starting_app/foo => foo}/coupons/views.py | 0 {starting_app/foo => foo}/foo/__init__.py | 0 {starting_app/foo => foo}/foo/apps.py | 0 {starting_app/foo => foo}/foo/migrations/0001_initial.py | 0 {starting_app/foo => foo}/foo/migrations/__init__.py | 0 {starting_app/foo => foo}/foo/migrations/define_groups.py | 0 {starting_app/foo => foo}/foo/models.py | 0 {starting_app/foo => foo}/foo/serializers.py | 0 {starting_app/foo => foo}/foo/settings.py | 0 {starting_app/foo => foo}/foo/tests/__init__.py | 0 {starting_app/foo => foo}/foo/tests/base.py | 0 {starting_app/foo => foo}/foo/tests/test_item_coupon_create.py | 0 {starting_app/foo => foo}/foo/tests/test_item_create.py | 0 {starting_app/foo => foo}/foo/urls.py | 0 {starting_app/foo => foo}/foo/views.py | 0 {starting_app/foo => foo}/foo/wsgi.py | 0 {starting_app/foo => foo}/manage.py | 0 43 files changed, 3 insertions(+) rename {starting_app/foo => foo}/coupons/__init__.py (100%) rename {starting_app/foo => foo}/coupons/filters.py (100%) rename {starting_app/foo => foo}/coupons/migrations/0001_initial.py (100%) rename {starting_app/foo => foo}/coupons/migrations/__init__.py (100%) rename {starting_app/foo => foo}/coupons/models.py (100%) rename {starting_app/foo => foo}/coupons/serializers.py (100%) rename {starting_app/foo => foo}/coupons/tests/__init__.py (100%) rename {starting_app/foo => foo}/coupons/tests/base.py (100%) rename {starting_app/foo => foo}/coupons/tests/test_coupon_create.py (100%) rename {starting_app/foo => foo}/coupons/tests/test_coupon_create_group.py (100%) rename {starting_app/foo => foo}/coupons/tests/test_coupon_delete.py (100%) rename {starting_app/foo => foo}/coupons/tests/test_coupon_delete_group.py (100%) rename {starting_app/foo => foo}/coupons/tests/test_coupon_list.py (100%) rename {starting_app/foo => foo}/coupons/tests/test_coupon_list_filter.py (100%) rename {starting_app/foo => foo}/coupons/tests/test_coupon_list_group.py (100%) rename {starting_app/foo => foo}/coupons/tests/test_coupon_list_search.py (100%) rename {starting_app/foo => foo}/coupons/tests/test_coupon_redeem.py (100%) rename {starting_app/foo => foo}/coupons/tests/test_coupon_redeemed.py (100%) rename {starting_app/foo => foo}/coupons/tests/test_coupon_redeemed_group.py (100%) rename {starting_app/foo => foo}/coupons/tests/test_coupon_update.py (100%) rename {starting_app/foo => foo}/coupons/tests/test_coupon_update_group.py (100%) rename {starting_app/foo => foo}/coupons/tests/test_redeemed_list.py (100%) rename {starting_app/foo => foo}/coupons/tests/test_redeemed_list_filter.py (100%) rename {starting_app/foo => foo}/coupons/tests/test_redeemed_list_group.py (100%) rename {starting_app/foo => foo}/coupons/urls.py (100%) rename {starting_app/foo => foo}/coupons/views.py (100%) rename {starting_app/foo => foo}/foo/__init__.py (100%) rename {starting_app/foo => foo}/foo/apps.py (100%) rename {starting_app/foo => foo}/foo/migrations/0001_initial.py (100%) rename {starting_app/foo => foo}/foo/migrations/__init__.py (100%) rename {starting_app/foo => foo}/foo/migrations/define_groups.py (100%) rename {starting_app/foo => foo}/foo/models.py (100%) rename {starting_app/foo => foo}/foo/serializers.py (100%) rename {starting_app/foo => foo}/foo/settings.py (100%) rename {starting_app/foo => foo}/foo/tests/__init__.py (100%) rename {starting_app/foo => foo}/foo/tests/base.py (100%) rename {starting_app/foo => foo}/foo/tests/test_item_coupon_create.py (100%) rename {starting_app/foo => foo}/foo/tests/test_item_create.py (100%) rename {starting_app/foo => foo}/foo/urls.py (100%) rename {starting_app/foo => foo}/foo/views.py (100%) rename {starting_app/foo => foo}/foo/wsgi.py (100%) rename {starting_app/foo => foo}/manage.py (100%) diff --git a/README.md b/README.md index df72e47..a07ed6c 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ This project depends on: `GET /coupon` supports querying by coupon code, and filter by `user`, `bound`, `type` or by ranges of discount via `max_value`, `min_value` +`GET /redeemed` supports filtering by `user`. + ## Coupon Types It supports the following variations of coupons: @@ -101,3 +103,4 @@ You create coupons in the system that are then claimed by users. The unit-tests should automatically be run when you run `python manage.py test` and they are isolated. +If you'd like to contribute, please fork, and develop, branch from the `development` branch to and submit a pull request when ready. diff --git a/starting_app/foo/coupons/__init__.py b/foo/coupons/__init__.py similarity index 100% rename from starting_app/foo/coupons/__init__.py rename to foo/coupons/__init__.py diff --git a/starting_app/foo/coupons/filters.py b/foo/coupons/filters.py similarity index 100% rename from starting_app/foo/coupons/filters.py rename to foo/coupons/filters.py diff --git a/starting_app/foo/coupons/migrations/0001_initial.py b/foo/coupons/migrations/0001_initial.py similarity index 100% rename from starting_app/foo/coupons/migrations/0001_initial.py rename to foo/coupons/migrations/0001_initial.py diff --git a/starting_app/foo/coupons/migrations/__init__.py b/foo/coupons/migrations/__init__.py similarity index 100% rename from starting_app/foo/coupons/migrations/__init__.py rename to foo/coupons/migrations/__init__.py diff --git a/starting_app/foo/coupons/models.py b/foo/coupons/models.py similarity index 100% rename from starting_app/foo/coupons/models.py rename to foo/coupons/models.py diff --git a/starting_app/foo/coupons/serializers.py b/foo/coupons/serializers.py similarity index 100% rename from starting_app/foo/coupons/serializers.py rename to foo/coupons/serializers.py diff --git a/starting_app/foo/coupons/tests/__init__.py b/foo/coupons/tests/__init__.py similarity index 100% rename from starting_app/foo/coupons/tests/__init__.py rename to foo/coupons/tests/__init__.py diff --git a/starting_app/foo/coupons/tests/base.py b/foo/coupons/tests/base.py similarity index 100% rename from starting_app/foo/coupons/tests/base.py rename to foo/coupons/tests/base.py diff --git a/starting_app/foo/coupons/tests/test_coupon_create.py b/foo/coupons/tests/test_coupon_create.py similarity index 100% rename from starting_app/foo/coupons/tests/test_coupon_create.py rename to foo/coupons/tests/test_coupon_create.py diff --git a/starting_app/foo/coupons/tests/test_coupon_create_group.py b/foo/coupons/tests/test_coupon_create_group.py similarity index 100% rename from starting_app/foo/coupons/tests/test_coupon_create_group.py rename to foo/coupons/tests/test_coupon_create_group.py diff --git a/starting_app/foo/coupons/tests/test_coupon_delete.py b/foo/coupons/tests/test_coupon_delete.py similarity index 100% rename from starting_app/foo/coupons/tests/test_coupon_delete.py rename to foo/coupons/tests/test_coupon_delete.py diff --git a/starting_app/foo/coupons/tests/test_coupon_delete_group.py b/foo/coupons/tests/test_coupon_delete_group.py similarity index 100% rename from starting_app/foo/coupons/tests/test_coupon_delete_group.py rename to foo/coupons/tests/test_coupon_delete_group.py diff --git a/starting_app/foo/coupons/tests/test_coupon_list.py b/foo/coupons/tests/test_coupon_list.py similarity index 100% rename from starting_app/foo/coupons/tests/test_coupon_list.py rename to foo/coupons/tests/test_coupon_list.py diff --git a/starting_app/foo/coupons/tests/test_coupon_list_filter.py b/foo/coupons/tests/test_coupon_list_filter.py similarity index 100% rename from starting_app/foo/coupons/tests/test_coupon_list_filter.py rename to foo/coupons/tests/test_coupon_list_filter.py diff --git a/starting_app/foo/coupons/tests/test_coupon_list_group.py b/foo/coupons/tests/test_coupon_list_group.py similarity index 100% rename from starting_app/foo/coupons/tests/test_coupon_list_group.py rename to foo/coupons/tests/test_coupon_list_group.py diff --git a/starting_app/foo/coupons/tests/test_coupon_list_search.py b/foo/coupons/tests/test_coupon_list_search.py similarity index 100% rename from starting_app/foo/coupons/tests/test_coupon_list_search.py rename to foo/coupons/tests/test_coupon_list_search.py diff --git a/starting_app/foo/coupons/tests/test_coupon_redeem.py b/foo/coupons/tests/test_coupon_redeem.py similarity index 100% rename from starting_app/foo/coupons/tests/test_coupon_redeem.py rename to foo/coupons/tests/test_coupon_redeem.py diff --git a/starting_app/foo/coupons/tests/test_coupon_redeemed.py b/foo/coupons/tests/test_coupon_redeemed.py similarity index 100% rename from starting_app/foo/coupons/tests/test_coupon_redeemed.py rename to foo/coupons/tests/test_coupon_redeemed.py diff --git a/starting_app/foo/coupons/tests/test_coupon_redeemed_group.py b/foo/coupons/tests/test_coupon_redeemed_group.py similarity index 100% rename from starting_app/foo/coupons/tests/test_coupon_redeemed_group.py rename to foo/coupons/tests/test_coupon_redeemed_group.py diff --git a/starting_app/foo/coupons/tests/test_coupon_update.py b/foo/coupons/tests/test_coupon_update.py similarity index 100% rename from starting_app/foo/coupons/tests/test_coupon_update.py rename to foo/coupons/tests/test_coupon_update.py diff --git a/starting_app/foo/coupons/tests/test_coupon_update_group.py b/foo/coupons/tests/test_coupon_update_group.py similarity index 100% rename from starting_app/foo/coupons/tests/test_coupon_update_group.py rename to foo/coupons/tests/test_coupon_update_group.py diff --git a/starting_app/foo/coupons/tests/test_redeemed_list.py b/foo/coupons/tests/test_redeemed_list.py similarity index 100% rename from starting_app/foo/coupons/tests/test_redeemed_list.py rename to foo/coupons/tests/test_redeemed_list.py diff --git a/starting_app/foo/coupons/tests/test_redeemed_list_filter.py b/foo/coupons/tests/test_redeemed_list_filter.py similarity index 100% rename from starting_app/foo/coupons/tests/test_redeemed_list_filter.py rename to foo/coupons/tests/test_redeemed_list_filter.py diff --git a/starting_app/foo/coupons/tests/test_redeemed_list_group.py b/foo/coupons/tests/test_redeemed_list_group.py similarity index 100% rename from starting_app/foo/coupons/tests/test_redeemed_list_group.py rename to foo/coupons/tests/test_redeemed_list_group.py diff --git a/starting_app/foo/coupons/urls.py b/foo/coupons/urls.py similarity index 100% rename from starting_app/foo/coupons/urls.py rename to foo/coupons/urls.py diff --git a/starting_app/foo/coupons/views.py b/foo/coupons/views.py similarity index 100% rename from starting_app/foo/coupons/views.py rename to foo/coupons/views.py diff --git a/starting_app/foo/foo/__init__.py b/foo/foo/__init__.py similarity index 100% rename from starting_app/foo/foo/__init__.py rename to foo/foo/__init__.py diff --git a/starting_app/foo/foo/apps.py b/foo/foo/apps.py similarity index 100% rename from starting_app/foo/foo/apps.py rename to foo/foo/apps.py diff --git a/starting_app/foo/foo/migrations/0001_initial.py b/foo/foo/migrations/0001_initial.py similarity index 100% rename from starting_app/foo/foo/migrations/0001_initial.py rename to foo/foo/migrations/0001_initial.py diff --git a/starting_app/foo/foo/migrations/__init__.py b/foo/foo/migrations/__init__.py similarity index 100% rename from starting_app/foo/foo/migrations/__init__.py rename to foo/foo/migrations/__init__.py diff --git a/starting_app/foo/foo/migrations/define_groups.py b/foo/foo/migrations/define_groups.py similarity index 100% rename from starting_app/foo/foo/migrations/define_groups.py rename to foo/foo/migrations/define_groups.py diff --git a/starting_app/foo/foo/models.py b/foo/foo/models.py similarity index 100% rename from starting_app/foo/foo/models.py rename to foo/foo/models.py diff --git a/starting_app/foo/foo/serializers.py b/foo/foo/serializers.py similarity index 100% rename from starting_app/foo/foo/serializers.py rename to foo/foo/serializers.py diff --git a/starting_app/foo/foo/settings.py b/foo/foo/settings.py similarity index 100% rename from starting_app/foo/foo/settings.py rename to foo/foo/settings.py diff --git a/starting_app/foo/foo/tests/__init__.py b/foo/foo/tests/__init__.py similarity index 100% rename from starting_app/foo/foo/tests/__init__.py rename to foo/foo/tests/__init__.py diff --git a/starting_app/foo/foo/tests/base.py b/foo/foo/tests/base.py similarity index 100% rename from starting_app/foo/foo/tests/base.py rename to foo/foo/tests/base.py diff --git a/starting_app/foo/foo/tests/test_item_coupon_create.py b/foo/foo/tests/test_item_coupon_create.py similarity index 100% rename from starting_app/foo/foo/tests/test_item_coupon_create.py rename to foo/foo/tests/test_item_coupon_create.py diff --git a/starting_app/foo/foo/tests/test_item_create.py b/foo/foo/tests/test_item_create.py similarity index 100% rename from starting_app/foo/foo/tests/test_item_create.py rename to foo/foo/tests/test_item_create.py diff --git a/starting_app/foo/foo/urls.py b/foo/foo/urls.py similarity index 100% rename from starting_app/foo/foo/urls.py rename to foo/foo/urls.py diff --git a/starting_app/foo/foo/views.py b/foo/foo/views.py similarity index 100% rename from starting_app/foo/foo/views.py rename to foo/foo/views.py diff --git a/starting_app/foo/foo/wsgi.py b/foo/foo/wsgi.py similarity index 100% rename from starting_app/foo/foo/wsgi.py rename to foo/foo/wsgi.py diff --git a/starting_app/foo/manage.py b/foo/manage.py similarity index 100% rename from starting_app/foo/manage.py rename to foo/manage.py From 8fdbee3f510a05d4c464c70b15c171859fd87c9f Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 19:26:32 -0800 Subject: [PATCH 34/46] More tests --- foo/coupons/tests/test_redeemed_list_group.py | 93 ++++++++++++++++++- foo/foo/tests/test_item_coupon_create.py | 35 +++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/foo/coupons/tests/test_redeemed_list_group.py b/foo/coupons/tests/test_redeemed_list_group.py index 1e0518b..9f53cb3 100644 --- a/foo/coupons/tests/test_redeemed_list_group.py +++ b/foo/coupons/tests/test_redeemed_list_group.py @@ -1 +1,92 @@ -# XXX +# if the user is in the group, they can see all of them. + +from django.contrib.auth.models import Group +from django.contrib.auth import get_user_model +from rest_framework import status + +from coupons.tests.base import BasicTest + + +class RedeemedSettingsTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + self.user2 = u.objects.create_user('user1', 'me1@snow.com', self.PW) + + with self.settings(ROOT_URLCONF='coupons.urls'): + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.coupon_id = response.data['id'] + self.logout() + + self.login(username='user') + response = self.client.put('/coupon/%s/redeem' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='user1') + response = self.client.put('/coupon/%s/redeem' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='admin') + response = self.client.get('/redeemed', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, len(response.data)) + self.logout() + + def test_cant_redeemed_if_not_in_group(self): + """ + Verify the user can restrict permissions. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'REDEEMED': ['group_a']}): + + self.login(username='user') + response = self.client.get('/redeemed', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + self.assertEqual(self.user.id, response.data[0]['user']) + self.logout() + + def test_cant_redeemed_if_group_empty(self): + """ + Verify the user can restrict permissions. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'REDEEMED': []}): + + self.login(username='user') + response = self.client.get('/redeemed', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + self.assertEqual(self.user.id, response.data[0]['user']) + self.logout() + + def test_can_redeemed_if_in_group(self): + """ + Verify the user can restrict permissions. + """ + + g, _ = Group.objects.get_or_create(name='group_a') + g.user_set.add(self.user) + + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'REDEEMED': ['group_a']}): + + self.login(username='user') + response = self.client.get('/redeemed', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, len(response.data)) + self.logout() diff --git a/foo/foo/tests/test_item_coupon_create.py b/foo/foo/tests/test_item_coupon_create.py index 8398c0f..98e08cc 100644 --- a/foo/foo/tests/test_item_coupon_create.py +++ b/foo/foo/tests/test_item_coupon_create.py @@ -31,3 +31,38 @@ def test_can_create_coupon_within_item_app(self): coupon['bound'] = False self.verify_built(coupon, response.data) + + def test_can_redeem_coupon_within_item_app(self): + """ + Redeem a coupon (boringly). + """ + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + self.login(username='admin') + response = self.client.post('/api/v1/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False + + self.verify_built(coupon, response.data) + + coupon_id = response.data['id'] + + response = self.client.put('/api/v1/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.get('/api/v1/coupon/%s/redeemed' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + + response = self.client.get('/api/v1/redeemed', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data)) + + self.logout() From cb47b82fbf35a26dfe50cd673d47cdc54fabfaa5 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 19:44:59 -0800 Subject: [PATCH 35/46] Now you can retrieve a coupon by short or database id. --- README.md | 11 +++++ foo/coupons/tests/test_coupon_retrieve.py | 59 +++++++++++++++++++++++ foo/coupons/views.py | 25 ++++++++++ 3 files changed, 95 insertions(+) create mode 100644 foo/coupons/tests/test_coupon_retrieve.py diff --git a/README.md b/README.md index a07ed6c..83095df 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ This project depends on: You don't need to specify every endpoint in the list and can provide an empty list for an endpoint. + The groups specified for `REDEEMED` are used in both `GET /coupon/{pk}/redeemed` and `GET /redeemed`. + + The groups specified for `DELETE` are used in both `DELETE /coupon/{pk}` and `DELETE /redeemed/{pk}`. + 2. Communicate with coupon endpoints. You can place the urls into a subpath, however you like: @@ -80,6 +84,13 @@ This project depends on: `GET /redeemed` supports filtering by `user`. +## Objects + +There are two objects provided: + +1. `Coupon` - allows you to specify the properties of the coupon itself. +2. `ClaimedCoupon` - allows you to track whenever a user redeems a coupon. + ## Coupon Types It supports the following variations of coupons: diff --git a/foo/coupons/tests/test_coupon_retrieve.py b/foo/coupons/tests/test_coupon_retrieve.py new file mode 100644 index 0000000..113360a --- /dev/null +++ b/foo/coupons/tests/test_coupon_retrieve.py @@ -0,0 +1,59 @@ +from django.contrib.auth import get_user_model +from rest_framework import status + +from coupons.tests.base import BasicTest + + +class CouponRetrieveTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + with self.settings(ROOT_URLCONF='coupons.urls'): + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + coupon['code_l'] = coupon['code'].lower() + coupon['repeat'] = 0 + coupon['bound'] = False + + self.verify_built(coupon, response.data) + + self.coupon_id = response.data['id'] + + def test_can_retrieve_coupon(self): + """ + Verify we can retrieve a coupon. By default, anyone can. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + + self.login(username='user') + response = self.client.get('/coupon/%s' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(self.coupon_id, response.data['id']) + self.logout() + + def test_can_retrieve_coupon_code(self): + """ + Verify we can retrieve a coupon by code (mixed case is fine). + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + + self.login(username='user') + response = self.client.get('/coupon/%s' % 'AsDf', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(self.coupon_id, response.data['id']) + self.logout() + + diff --git a/foo/coupons/views.py b/foo/coupons/views.py index 41a2493..50f00db 100644 --- a/foo/coupons/views.py +++ b/foo/coupons/views.py @@ -136,6 +136,28 @@ def destroy(self, request, pk=None, **kwargs): def partial_update(self, request, pk=None, **kwargs): return Response(status=status.HTTP_404_NOT_FOUND) + def retrieve(self, request, pk=None, **kwargs): + """ + Anybody can retrieve any coupon. + """ + + value_is_int = False + + try: + pk = int(pk) + value_is_int = True + except ValueError: + pass + + if value_is_int: + coupon = get_object_or_404(Coupon.objects.all(), pk=pk) + else: + coupon = get_object_or_404(Coupon.objects.all(), code_l=pk.lower()) + + serializer = CouponSerializer(coupon, context={'request': request}) + + return Response(serializer.data) + @method_decorator(group_required('UPDATE')) def update(self, request, pk=None, **kwargs): """ @@ -211,6 +233,9 @@ def destroy(self, request, pk=None, **kwargs): def partial_update(self, request, pk=None, **kwargs): return Response(status=status.HTTP_404_NOT_FOUND) + def retrieve(self, request, pk=None, **kwargs): + return Response(status=status.HTTP_404_NOT_FOUND) + def update(self, request, pk=None, **kwargs): return Response(status=status.HTTP_404_NOT_FOUND) From e0f3a04f98b2f352a2c2aaef5e69b058d1c12ade Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 19:46:44 -0800 Subject: [PATCH 36/46] You should be able to delete a claimed coupon instance to basically un-redeem a coupon. --- foo/coupons/tests/test_redeemed_delete.py | 0 foo/coupons/tests/test_redeemed_delete_group.py | 0 foo/coupons/views.py | 10 +++++++++- 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 foo/coupons/tests/test_redeemed_delete.py create mode 100644 foo/coupons/tests/test_redeemed_delete_group.py diff --git a/foo/coupons/tests/test_redeemed_delete.py b/foo/coupons/tests/test_redeemed_delete.py new file mode 100644 index 0000000..e69de29 diff --git a/foo/coupons/tests/test_redeemed_delete_group.py b/foo/coupons/tests/test_redeemed_delete_group.py new file mode 100644 index 0000000..e69de29 diff --git a/foo/coupons/views.py b/foo/coupons/views.py index 50f00db..b2329d3 100644 --- a/foo/coupons/views.py +++ b/foo/coupons/views.py @@ -227,8 +227,16 @@ def get_queryset(self): def create(self, request, **kwargs): return Response(status=status.HTTP_404_NOT_FOUND) + @method_decorator(group_required('DELETE')) def destroy(self, request, pk=None, **kwargs): - return Response(status=status.HTTP_404_NOT_FOUND) + """ + Basically un-redeem a coupon. + """ + + redeemed = get_object_or_404(ClaimedCoupon.objects.all(), pk=pk) + redeemed.delete() + + return Response(status=status.HTTP_204_NO_CONTENT) def partial_update(self, request, pk=None, **kwargs): return Response(status=status.HTTP_404_NOT_FOUND) From 982c102bdc2e1eaf5e5db5ec754727c5b8f0ae85 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 19:59:04 -0800 Subject: [PATCH 37/46] added field table. --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 83095df..ee848de 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,15 @@ This project depends on: There are two objects provided: 1. `Coupon` - allows you to specify the properties of the coupon itself. + + | Field | Type | Meaning | + | ----- | ---- | ------- | + 2. `ClaimedCoupon` - allows you to track whenever a user redeems a coupon. + | Field | Type | Meaning | + | ----- | ---- | ------- | + ## Coupon Types It supports the following variations of coupons: From 90e87d770973996d780234d331e33a2078bb9d8f Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 20:02:08 -0800 Subject: [PATCH 38/46] documentation updates. --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ee848de..a29a09a 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,11 @@ There are two objects provided: 2. `ClaimedCoupon` - allows you to track whenever a user redeems a coupon. - | Field | Type | Meaning | - | ----- | ---- | ------- | + | Field | Type | Meaning | + | ---------- | ----------- | ------------------------------------------------------ | + | `redeemed` | datetime | automatically set when a coupon is redeemed | + | `coupon` | foreign key | automatically set to point at the coupon when redeemed | + | `user` | foreign key | automatically set to point at the coupon when redeemed | ## Coupon Types From 89e64b63dde77aa51926f4379e7cf82c50e394cf Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 20:07:34 -0800 Subject: [PATCH 39/46] table. --- README.md | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a29a09a..dd96d6b 100644 --- a/README.md +++ b/README.md @@ -90,16 +90,24 @@ There are two objects provided: 1. `Coupon` - allows you to specify the properties of the coupon itself. - | Field | Type | Meaning | - | ----- | ---- | ------- | + | Field | Type | Meaning | + | --------- | ------------- | ------------------------------------------------------ | + | `code` | `string` | the code for the coupon, case insensitive | + | `code_l` | `string` | automatically set lowercase version of the coupon code | + | `type` | `string` | either `percent` or `value`, how the `value` field should be interpreted | + | `expires` | `datetime` | optional field to set when the coupon expires | + | `value` | `decimal` | the value for the coupon, such as `100` or `0.50` | + | `bound` | `boolean` | if `true` then the coupon can only be used by the specified user in the `user` field | + | `user` | `foreign key` | set when bound to point to the user | + | `repeat` | `integer` | if `0` the coupon can be used infinitely, otherwise it specifies how often any system user can use it | 2. `ClaimedCoupon` - allows you to track whenever a user redeems a coupon. - | Field | Type | Meaning | - | ---------- | ----------- | ------------------------------------------------------ | - | `redeemed` | datetime | automatically set when a coupon is redeemed | - | `coupon` | foreign key | automatically set to point at the coupon when redeemed | - | `user` | foreign key | automatically set to point at the coupon when redeemed | + | Field | Type | Meaning | + | ---------- | ------------- | ------------------------------------------------------ | + | `redeemed` | `datetime` | automatically set when a coupon is redeemed | + | `coupon` | `foreign key` | automatically set to point at the coupon when redeemed | + | `user` | `foreign key` | automatically set to point at the coupon when redeemed | ## Coupon Types From f3fbd62fde02f44d3518700826e8346f5e1fa396 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 20:14:25 -0800 Subject: [PATCH 40/46] Some unit-tests to verify stuff. --- foo/coupons/tests/test_redeemed_delete.py | 71 +++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/foo/coupons/tests/test_redeemed_delete.py b/foo/coupons/tests/test_redeemed_delete.py index e69de29..f7d4348 100644 --- a/foo/coupons/tests/test_redeemed_delete.py +++ b/foo/coupons/tests/test_redeemed_delete.py @@ -0,0 +1,71 @@ +from django.contrib.auth import get_user_model +from rest_framework import status + +from coupons.tests.base import BasicTest + + +class RedeemedDeleteTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + self.user2 = u.objects.create_user('user1', 'me1@snow.com', self.PW) + + with self.settings(ROOT_URLCONF='coupons.urls'): + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.coupon_id = response.data['id'] + self.logout() + + self.login(username='user') + response = self.client.put('/coupon/%s/redeem' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='user1') + response = self.client.put('/coupon/%s/redeem' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='admin') + response = self.client.get('/coupon/%s/redeemed' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, len(response.data)) + self.redeemed_id = response.data[0]['id'] + self.redeemed_id_2 = response.data[1]['id'] + self.logout() + + def test_can_delete_redeemed(self): + """ + Verify admins can delete redeemed. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + + self.login(username='admin') + response = self.client.delete('/redeemed/%s' % self.redeemed_id, format='json') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.logout() + + def test_cant_delete_redeemed(self): + """ + Verify users can't delete redeemed. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + + self.login(username='user') + response = self.client.delete('/redeemed/%s' % self.redeemed_id, format='json') + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + + response = self.client.delete('/redeemed/%s' % self.redeemed_id_2, format='json') + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.logout() From 1512a4b619a3c0c83d1a7620756c591a06b35159 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 20:20:31 -0800 Subject: [PATCH 41/46] More tests --- .../tests/test_redeemed_delete_group.py | 98 +++++++++++++++++++ foo/coupons/tests/test_redeemed_list_group.py | 8 +- 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/foo/coupons/tests/test_redeemed_delete_group.py b/foo/coupons/tests/test_redeemed_delete_group.py index e69de29..62e29c9 100644 --- a/foo/coupons/tests/test_redeemed_delete_group.py +++ b/foo/coupons/tests/test_redeemed_delete_group.py @@ -0,0 +1,98 @@ +# if the user is in the group, they can see all of them. + +from django.contrib.auth.models import Group +from django.contrib.auth import get_user_model +from rest_framework import status + +from coupons.tests.base import BasicTest + + +class RedeemedDeleteSettingsTests(BasicTest): + + def setUp(self): + u = get_user_model() + u.objects.create_superuser('admin', 'john@snow.com', self.PW) + self.user = u.objects.create_user('user', 'me@snow.com', self.PW) + self.user2 = u.objects.create_user('user1', 'me1@snow.com', self.PW) + + with self.settings(ROOT_URLCONF='coupons.urls'): + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + } + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.coupon_id = response.data['id'] + self.logout() + + self.login(username='user') + response = self.client.put('/coupon/%s/redeem' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='user1') + response = self.client.put('/coupon/%s/redeem' % self.coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='admin') + response = self.client.get('/redeemed', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, len(response.data)) + self.redeemed_id = response.data[0]['id'] + self.redeemed_id_2 = response.data[1]['id'] + self.logout() + + def test_cant_delete_redeemed_if_not_in_group(self): + """ + Verify the user can restrict permissions. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'DELETE': ['group_a']}): + + self.login(username='user') + response = self.client.delete('/redeemed/%s' % self.redeemed_id, format='json') + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + + response = self.client.delete('/redeemed/%s' % self.redeemed_id_2, format='json') + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.logout() + + def test_cant_delete_redeemed_if_group_empty(self): + """ + Verify the user can restrict permissions. + """ + + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'DELETE': []}): + + self.login(username='user') + response = self.client.delete('/redeemed/%s' % self.redeemed_id, format='json') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + response = self.client.delete('/redeemed/%s' % self.redeemed_id_2, format='json') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.logout() + + def test_can_delete_redeemed_if_in_group(self): + """ + Verify the user can restrict permissions. + """ + + g, _ = Group.objects.get_or_create(name='group_a') + g.user_set.add(self.user) + + with self.settings(ROOT_URLCONF='coupons.urls'): + with self.settings(COUPON_PERMISSIONS={'DELETE': ['group_a']}): + + self.login(username='user') + response = self.client.delete('/redeemed/%s' % self.redeemed_id, format='json') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + response = self.client.delete('/redeemed/%s' % self.redeemed_id_2, format='json') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.logout() diff --git a/foo/coupons/tests/test_redeemed_list_group.py b/foo/coupons/tests/test_redeemed_list_group.py index 9f53cb3..9ef2c98 100644 --- a/foo/coupons/tests/test_redeemed_list_group.py +++ b/foo/coupons/tests/test_redeemed_list_group.py @@ -7,7 +7,7 @@ from coupons.tests.base import BasicTest -class RedeemedSettingsTests(BasicTest): +class RedeemedListSettingsTests(BasicTest): def setUp(self): u = get_user_model() @@ -44,7 +44,7 @@ def setUp(self): self.assertEqual(2, len(response.data)) self.logout() - def test_cant_redeemed_if_not_in_group(self): + def test_cant_list_redeemed_if_not_in_group(self): """ Verify the user can restrict permissions. """ @@ -59,7 +59,7 @@ def test_cant_redeemed_if_not_in_group(self): self.assertEqual(self.user.id, response.data[0]['user']) self.logout() - def test_cant_redeemed_if_group_empty(self): + def test_cant_list_redeemed_if_group_empty(self): """ Verify the user can restrict permissions. """ @@ -74,7 +74,7 @@ def test_cant_redeemed_if_group_empty(self): self.assertEqual(self.user.id, response.data[0]['user']) self.logout() - def test_can_redeemed_if_in_group(self): + def test_can_list_redeemed_if_in_group(self): """ Verify the user can restrict permissions. """ From b234d60ad47664eb96e81e96c3eed94bcfbfa783 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 20:46:12 -0800 Subject: [PATCH 42/46] Starting to set up the plugin for upload to pypi. I haven't finished one remaining feature, then it should be good to go. --- MANIFEST.in | 2 ++ NOTES.md | 1 + README.rst | 24 ++++++++++++++++++++++++ setup.py | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+) create mode 100644 MANIFEST.in create mode 100644 README.rst create mode 100644 setup.py diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..9d5d250 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include LICENSE +include README.rst diff --git a/NOTES.md b/NOTES.md index 1cc7e53..fd01632 100644 --- a/NOTES.md +++ b/NOTES.md @@ -6,3 +6,4 @@ http://www.revsys.com/blog/2014/nov/21/recommended-django-project-layout/ http://python-packaging.readthedocs.io/en/latest/dependencies.html +http://www.mkdocs.org/ diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..8acbe9e --- /dev/null +++ b/README.rst @@ -0,0 +1,24 @@ +===== +Coupons +===== + +A django-rest-framework application that provides many varieties of coupons + +Detailed documentation is in the README.md file. + +Quick start +----------- + +1. Add "coupons" to your INSTALLED_APPS setting like this:: + + INSTALLED_APPS = [ + ... + 'coupons', + ] + +2. Include the coupons URLconf in your project urls.py like this:: + + url(r'^', include('coupons.urls')), + +3. Run `python manage.py migrate` to create the polls models. + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..8f66673 --- /dev/null +++ b/setup.py @@ -0,0 +1,35 @@ +import os +from setuptools import find_packages, setup + +with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme: + README = readme.read() + +setup(name='drf-coupons', + version='1.0', + packages=find_packages(), + include_package_data=True, + description='A django-rest-framework application that provides many varieties of coupons', + long_description=README, + url='https://github.com/pstrinkle/drf-coupons', + author='Patrick Trinkle', + author_email='patrick@1shoe.net', + license='Apache 2.0', + install_requires=[ + 'djangorestframework', + 'django-filter', + ], + classifiers=[ + 'Environment :: Web Environment', + 'Framework :: Django', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache 2.0 License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + ]) From 1a7c6210ad9056f1425729bf0104d9dbb433e8c1 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 21:04:11 -0800 Subject: [PATCH 43/46] implemented verification that the repeat count is now checked and checked correctly. --- foo/coupons/serializers.py | 8 +++++++- foo/coupons/tests/test_coupon_redeem.py | 24 +++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/foo/coupons/serializers.py b/foo/coupons/serializers.py index 3ca62f8..689c214 100644 --- a/foo/coupons/serializers.py +++ b/foo/coupons/serializers.py @@ -90,7 +90,13 @@ def validate(self, data): raise serializers.ValidationError("Coupon bound to another user.") # Is the coupon redeemed already beyond what's allowed? - # XXX + redeemed = ClaimedCoupon.objects.filter(coupon=coupon.id, user=user.id).count() + if coupon.repeat > 0: + if redeemed >= coupon.repeat: + # Already too many times (note: we don't update the claimed coupons, so this is a fine test). + # Also, yes, > should never happen because the equals check will be hit first, but just in case + # you somehow get beyond that... ;) + raise serializers.ValidationError("Coupon has been used to its limit.") return data diff --git a/foo/coupons/tests/test_coupon_redeem.py b/foo/coupons/tests/test_coupon_redeem.py index 936dfe1..5b49575 100644 --- a/foo/coupons/tests/test_coupon_redeem.py +++ b/foo/coupons/tests/test_coupon_redeem.py @@ -126,4 +126,26 @@ def test_cant_redeem_beyond_repeat(self): Verify you can't redeem a coupon more than allowed. """ - self.assertTrue(True) + coupon = { + 'code': 'ASDF', + 'type': 'percent', + 'repeat': 2, + } + + with self.settings(ROOT_URLCONF='coupons.urls'): + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + coupon_id = response.data['id'] + + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.logout() From 98ca1198718b102a315a7200809ce7c034e7bfa2 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 21:13:10 -0800 Subject: [PATCH 44/46] verified with more tests --- foo/coupons/tests/test_coupon_redeem.py | 157 ++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/foo/coupons/tests/test_coupon_redeem.py b/foo/coupons/tests/test_coupon_redeem.py index 5b49575..091bdb8 100644 --- a/foo/coupons/tests/test_coupon_redeem.py +++ b/foo/coupons/tests/test_coupon_redeem.py @@ -148,4 +148,161 @@ def test_cant_redeem_beyond_repeat(self): response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.logout() + + def test_cant_redeem_beyond_repeat_singleuse(self): + """ + Verify you can't redeem a coupon more than allowed. No huge difference for this, but just in case. + """ + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + 'repeat': 1, + } + + with self.settings(ROOT_URLCONF='coupons.urls'): + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + coupon_id = response.data['id'] + + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.logout() + + def test_cant_redeem_beyond_repeat_multiple_users(self): + """ + Verify that it only takes into account your claims and not other users. + """ + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + 'repeat': 1, + } + + with self.settings(ROOT_URLCONF='coupons.urls'): + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + coupon_id = response.data['id'] + self.logout() + + self.login(username='admin') + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='user') + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='user') + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.logout() + + def test_can_redeem_repeat_infinite(self): + """ + Verify that it does support repeat being 0. + """ + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + 'repeat': 0, + } + + with self.settings(ROOT_URLCONF='coupons.urls'): + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + coupon_id = response.data['id'] + self.logout() + + self.login(username='admin') + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='user') + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + self.login(username='user') + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.logout() + + def test_can_redeem_beyond_repeat_singleuse_after_coupon_updated(self): + """ + Verify if the coupon is updated, you can claim it more if they increase the count. :) + """ + + coupon = { + 'code': 'ASDF', + 'type': 'percent', + 'repeat': 1, + } + + with self.settings(ROOT_URLCONF='coupons.urls'): + + self.login(username='admin') + response = self.client.post('/coupon', coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + coupon_id = response.data['id'] + + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + response = self.client.get('/coupon/%s' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + coupon = response.data + coupon['repeat'] = 2 + del coupon['created'] + del coupon['updated'] + del coupon['id'] + del coupon['expires'] + + response = self.client.put('/coupon/%s' % coupon_id, coupon, format='json') + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.put('/coupon/%s/redeem' % coupon_id, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.logout() From 5c0795116aa2ccbd1055fa0d5296888f2e45db09 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 21:32:27 -0800 Subject: [PATCH 45/46] Note. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index dd96d6b..f54ade9 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ This project depends on: $ python manage.py migrate ``` +**Note:** this package was not developed to be compatible side-by-side with `django-coupons`, as they serve very similar needs. + ## Usage 1. Specify permissions for interacting with coupon endpoints. From 84f9a5e490f50902b379e26893a9713ad0a4be93 Mon Sep 17 00:00:00 2001 From: Patrick Trinkle Date: Sun, 15 Jan 2017 21:53:37 -0800 Subject: [PATCH 46/46] Staged. --- NOTES.md | 7 + README.rst | 8 +- {foo/coupons => coupons}/__init__.py | 0 {foo/coupons => coupons}/filters.py | 0 .../migrations/0001_initial.py | 0 .../migrations/__init__.py | 0 {foo/coupons => coupons}/models.py | 0 {foo/coupons => coupons}/serializers.py | 0 {foo/coupons => coupons}/tests/__init__.py | 0 {foo/coupons => coupons}/tests/base.py | 0 .../tests/test_coupon_create.py | 0 .../tests/test_coupon_create_group.py | 0 .../tests/test_coupon_delete.py | 0 .../tests/test_coupon_delete_group.py | 0 .../tests/test_coupon_list.py | 0 .../tests/test_coupon_list_filter.py | 0 .../tests/test_coupon_list_group.py | 0 .../tests/test_coupon_list_search.py | 0 .../tests/test_coupon_redeem.py | 0 .../tests/test_coupon_redeemed.py | 0 .../tests/test_coupon_redeemed_group.py | 0 .../tests/test_coupon_retrieve.py | 0 .../tests/test_coupon_update.py | 0 .../tests/test_coupon_update_group.py | 0 .../tests/test_redeemed_delete.py | 0 .../tests/test_redeemed_delete_group.py | 0 .../tests/test_redeemed_list.py | 0 .../tests/test_redeemed_list_filter.py | 0 .../tests/test_redeemed_list_group.py | 0 {foo/coupons => coupons}/urls.py | 0 {foo/coupons => coupons}/views.py | 0 foo/foo/__init__.py | 0 foo/foo/apps.py | 7 - foo/foo/migrations/0001_initial.py | 24 ---- foo/foo/migrations/__init__.py | 0 foo/foo/migrations/define_groups.py | 20 --- foo/foo/models.py | 14 -- foo/foo/serializers.py | 16 --- foo/foo/settings.py | 129 ------------------ foo/foo/tests/__init__.py | 0 foo/foo/tests/base.py | 19 --- foo/foo/tests/test_item_coupon_create.py | 68 --------- foo/foo/tests/test_item_create.py | 31 ----- foo/foo/urls.py | 34 ----- foo/foo/views.py | 14 -- foo/foo/wsgi.py | 16 --- foo/manage.py | 22 --- setup.py | 2 +- 48 files changed, 12 insertions(+), 419 deletions(-) rename {foo/coupons => coupons}/__init__.py (100%) rename {foo/coupons => coupons}/filters.py (100%) rename {foo/coupons => coupons}/migrations/0001_initial.py (100%) rename {foo/coupons => coupons}/migrations/__init__.py (100%) rename {foo/coupons => coupons}/models.py (100%) rename {foo/coupons => coupons}/serializers.py (100%) rename {foo/coupons => coupons}/tests/__init__.py (100%) rename {foo/coupons => coupons}/tests/base.py (100%) rename {foo/coupons => coupons}/tests/test_coupon_create.py (100%) rename {foo/coupons => coupons}/tests/test_coupon_create_group.py (100%) rename {foo/coupons => coupons}/tests/test_coupon_delete.py (100%) rename {foo/coupons => coupons}/tests/test_coupon_delete_group.py (100%) rename {foo/coupons => coupons}/tests/test_coupon_list.py (100%) rename {foo/coupons => coupons}/tests/test_coupon_list_filter.py (100%) rename {foo/coupons => coupons}/tests/test_coupon_list_group.py (100%) rename {foo/coupons => coupons}/tests/test_coupon_list_search.py (100%) rename {foo/coupons => coupons}/tests/test_coupon_redeem.py (100%) rename {foo/coupons => coupons}/tests/test_coupon_redeemed.py (100%) rename {foo/coupons => coupons}/tests/test_coupon_redeemed_group.py (100%) rename {foo/coupons => coupons}/tests/test_coupon_retrieve.py (100%) rename {foo/coupons => coupons}/tests/test_coupon_update.py (100%) rename {foo/coupons => coupons}/tests/test_coupon_update_group.py (100%) rename {foo/coupons => coupons}/tests/test_redeemed_delete.py (100%) rename {foo/coupons => coupons}/tests/test_redeemed_delete_group.py (100%) rename {foo/coupons => coupons}/tests/test_redeemed_list.py (100%) rename {foo/coupons => coupons}/tests/test_redeemed_list_filter.py (100%) rename {foo/coupons => coupons}/tests/test_redeemed_list_group.py (100%) rename {foo/coupons => coupons}/urls.py (100%) rename {foo/coupons => coupons}/views.py (100%) delete mode 100644 foo/foo/__init__.py delete mode 100644 foo/foo/apps.py delete mode 100644 foo/foo/migrations/0001_initial.py delete mode 100644 foo/foo/migrations/__init__.py delete mode 100644 foo/foo/migrations/define_groups.py delete mode 100644 foo/foo/models.py delete mode 100644 foo/foo/serializers.py delete mode 100644 foo/foo/settings.py delete mode 100644 foo/foo/tests/__init__.py delete mode 100644 foo/foo/tests/base.py delete mode 100644 foo/foo/tests/test_item_coupon_create.py delete mode 100644 foo/foo/tests/test_item_create.py delete mode 100644 foo/foo/urls.py delete mode 100644 foo/foo/views.py delete mode 100644 foo/foo/wsgi.py delete mode 100755 foo/manage.py diff --git a/NOTES.md b/NOTES.md index fd01632..d84a824 100644 --- a/NOTES.md +++ b/NOTES.md @@ -7,3 +7,10 @@ http://www.revsys.com/blog/2014/nov/21/recommended-django-project-layout/ http://python-packaging.readthedocs.io/en/latest/dependencies.html http://www.mkdocs.org/ + +before trying to package for pypi: + +1. `pip install twine` +2. `pip install wheel` +3. `python setup.py sdist` +4. `python setup.py bdist_wheel` diff --git a/README.rst b/README.rst index 8acbe9e..e94a7c5 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,6 @@ -===== -Coupons -===== + +DRF Coupons +----------- A django-rest-framework application that provides many varieties of coupons @@ -20,5 +20,5 @@ Quick start url(r'^', include('coupons.urls')), -3. Run `python manage.py migrate` to create the polls models. +3. Run ``python manage.py migrate`` to create the polls models. diff --git a/foo/coupons/__init__.py b/coupons/__init__.py similarity index 100% rename from foo/coupons/__init__.py rename to coupons/__init__.py diff --git a/foo/coupons/filters.py b/coupons/filters.py similarity index 100% rename from foo/coupons/filters.py rename to coupons/filters.py diff --git a/foo/coupons/migrations/0001_initial.py b/coupons/migrations/0001_initial.py similarity index 100% rename from foo/coupons/migrations/0001_initial.py rename to coupons/migrations/0001_initial.py diff --git a/foo/coupons/migrations/__init__.py b/coupons/migrations/__init__.py similarity index 100% rename from foo/coupons/migrations/__init__.py rename to coupons/migrations/__init__.py diff --git a/foo/coupons/models.py b/coupons/models.py similarity index 100% rename from foo/coupons/models.py rename to coupons/models.py diff --git a/foo/coupons/serializers.py b/coupons/serializers.py similarity index 100% rename from foo/coupons/serializers.py rename to coupons/serializers.py diff --git a/foo/coupons/tests/__init__.py b/coupons/tests/__init__.py similarity index 100% rename from foo/coupons/tests/__init__.py rename to coupons/tests/__init__.py diff --git a/foo/coupons/tests/base.py b/coupons/tests/base.py similarity index 100% rename from foo/coupons/tests/base.py rename to coupons/tests/base.py diff --git a/foo/coupons/tests/test_coupon_create.py b/coupons/tests/test_coupon_create.py similarity index 100% rename from foo/coupons/tests/test_coupon_create.py rename to coupons/tests/test_coupon_create.py diff --git a/foo/coupons/tests/test_coupon_create_group.py b/coupons/tests/test_coupon_create_group.py similarity index 100% rename from foo/coupons/tests/test_coupon_create_group.py rename to coupons/tests/test_coupon_create_group.py diff --git a/foo/coupons/tests/test_coupon_delete.py b/coupons/tests/test_coupon_delete.py similarity index 100% rename from foo/coupons/tests/test_coupon_delete.py rename to coupons/tests/test_coupon_delete.py diff --git a/foo/coupons/tests/test_coupon_delete_group.py b/coupons/tests/test_coupon_delete_group.py similarity index 100% rename from foo/coupons/tests/test_coupon_delete_group.py rename to coupons/tests/test_coupon_delete_group.py diff --git a/foo/coupons/tests/test_coupon_list.py b/coupons/tests/test_coupon_list.py similarity index 100% rename from foo/coupons/tests/test_coupon_list.py rename to coupons/tests/test_coupon_list.py diff --git a/foo/coupons/tests/test_coupon_list_filter.py b/coupons/tests/test_coupon_list_filter.py similarity index 100% rename from foo/coupons/tests/test_coupon_list_filter.py rename to coupons/tests/test_coupon_list_filter.py diff --git a/foo/coupons/tests/test_coupon_list_group.py b/coupons/tests/test_coupon_list_group.py similarity index 100% rename from foo/coupons/tests/test_coupon_list_group.py rename to coupons/tests/test_coupon_list_group.py diff --git a/foo/coupons/tests/test_coupon_list_search.py b/coupons/tests/test_coupon_list_search.py similarity index 100% rename from foo/coupons/tests/test_coupon_list_search.py rename to coupons/tests/test_coupon_list_search.py diff --git a/foo/coupons/tests/test_coupon_redeem.py b/coupons/tests/test_coupon_redeem.py similarity index 100% rename from foo/coupons/tests/test_coupon_redeem.py rename to coupons/tests/test_coupon_redeem.py diff --git a/foo/coupons/tests/test_coupon_redeemed.py b/coupons/tests/test_coupon_redeemed.py similarity index 100% rename from foo/coupons/tests/test_coupon_redeemed.py rename to coupons/tests/test_coupon_redeemed.py diff --git a/foo/coupons/tests/test_coupon_redeemed_group.py b/coupons/tests/test_coupon_redeemed_group.py similarity index 100% rename from foo/coupons/tests/test_coupon_redeemed_group.py rename to coupons/tests/test_coupon_redeemed_group.py diff --git a/foo/coupons/tests/test_coupon_retrieve.py b/coupons/tests/test_coupon_retrieve.py similarity index 100% rename from foo/coupons/tests/test_coupon_retrieve.py rename to coupons/tests/test_coupon_retrieve.py diff --git a/foo/coupons/tests/test_coupon_update.py b/coupons/tests/test_coupon_update.py similarity index 100% rename from foo/coupons/tests/test_coupon_update.py rename to coupons/tests/test_coupon_update.py diff --git a/foo/coupons/tests/test_coupon_update_group.py b/coupons/tests/test_coupon_update_group.py similarity index 100% rename from foo/coupons/tests/test_coupon_update_group.py rename to coupons/tests/test_coupon_update_group.py diff --git a/foo/coupons/tests/test_redeemed_delete.py b/coupons/tests/test_redeemed_delete.py similarity index 100% rename from foo/coupons/tests/test_redeemed_delete.py rename to coupons/tests/test_redeemed_delete.py diff --git a/foo/coupons/tests/test_redeemed_delete_group.py b/coupons/tests/test_redeemed_delete_group.py similarity index 100% rename from foo/coupons/tests/test_redeemed_delete_group.py rename to coupons/tests/test_redeemed_delete_group.py diff --git a/foo/coupons/tests/test_redeemed_list.py b/coupons/tests/test_redeemed_list.py similarity index 100% rename from foo/coupons/tests/test_redeemed_list.py rename to coupons/tests/test_redeemed_list.py diff --git a/foo/coupons/tests/test_redeemed_list_filter.py b/coupons/tests/test_redeemed_list_filter.py similarity index 100% rename from foo/coupons/tests/test_redeemed_list_filter.py rename to coupons/tests/test_redeemed_list_filter.py diff --git a/foo/coupons/tests/test_redeemed_list_group.py b/coupons/tests/test_redeemed_list_group.py similarity index 100% rename from foo/coupons/tests/test_redeemed_list_group.py rename to coupons/tests/test_redeemed_list_group.py diff --git a/foo/coupons/urls.py b/coupons/urls.py similarity index 100% rename from foo/coupons/urls.py rename to coupons/urls.py diff --git a/foo/coupons/views.py b/coupons/views.py similarity index 100% rename from foo/coupons/views.py rename to coupons/views.py diff --git a/foo/foo/__init__.py b/foo/foo/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/foo/foo/apps.py b/foo/foo/apps.py deleted file mode 100644 index 7957942..0000000 --- a/foo/foo/apps.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import unicode_literals - -from django.apps import AppConfig - - -class FooAppConfig(AppConfig): - name = 'foo' diff --git a/foo/foo/migrations/0001_initial.py b/foo/foo/migrations/0001_initial.py deleted file mode 100644 index bec9ac5..0000000 --- a/foo/foo/migrations/0001_initial.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.2 on 2017-01-12 04:48 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='MiscItem', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=64)), - ('value', models.IntegerField()), - ], - ), - ] diff --git a/foo/foo/migrations/__init__.py b/foo/foo/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/foo/foo/migrations/define_groups.py b/foo/foo/migrations/define_groups.py deleted file mode 100644 index 9c42b53..0000000 --- a/foo/foo/migrations/define_groups.py +++ /dev/null @@ -1,20 +0,0 @@ -from django.contrib.auth.models import Group -from django.db import migrations - - -def create_groups(apps, schema_editor): - if not schema_editor.connection.alias == 'default': - return - - Group.objects.get_or_create(name='fun_users') - - -class Migration(migrations.Migration): - - dependencies = [ - ('foo', '0001_initial'), - ] - - operations = [ - migrations.RunPython(create_groups), - ] diff --git a/foo/foo/models.py b/foo/foo/models.py deleted file mode 100644 index 513eed0..0000000 --- a/foo/foo/models.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import unicode_literals - -from django.conf import settings -from django.contrib.auth import get_user_model -from django.db import models - - -class MiscItem(models.Model): - """ - These are the instances of claimed coupons, each is an individual usage of a coupon by someone in the system. - """ - - name = models.CharField(max_length=64) - value = models.IntegerField() diff --git a/foo/foo/serializers.py b/foo/foo/serializers.py deleted file mode 100644 index 86573d7..0000000 --- a/foo/foo/serializers.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.apps import apps -from django.utils.timezone import now -from rest_framework import serializers - -from foo.models import MiscItem - - -class MiscItemSerializer(serializers.ModelSerializer): - """ - RW MiscItem serializer. - """ - - class Meta: - model = apps.get_model('foo.MiscItem') - fields = ('name', 'value', 'id') - diff --git a/foo/foo/settings.py b/foo/foo/settings.py deleted file mode 100644 index 6f71060..0000000 --- a/foo/foo/settings.py +++ /dev/null @@ -1,129 +0,0 @@ -""" -Django settings for foo project. - -Generated by 'django-admin startproject' using Django 1.10.2. - -For more information on this file, see -https://docs.djangoproject.com/en/1.10/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/1.10/ref/settings/ -""" - -import os - -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = '=qp#2mo^t4yb+pm=or6fzrmd-9ae#3hs&)fw6*c4nl9dk7(ow5' - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = [] - - -# Application definition - -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'rest_framework', - 'coupons', - 'foo', -] - -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', -] - -ROOT_URLCONF = 'foo.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - -WSGI_APPLICATION = 'foo.wsgi.application' - - -# Database -# https://docs.djangoproject.com/en/1.10/ref/settings/#databases - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), - } -} - -# drf-coupons settings -COUPON_PERMISSIONS = { - 'CREATE': [ - 'fun_users' - ], -} - -# Password validation -# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - - -# Internationalization -# https://docs.djangoproject.com/en/1.10/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - -USE_I18N = True - -USE_L10N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/1.10/howto/static-files/ - -STATIC_URL = '/static/' diff --git a/foo/foo/tests/__init__.py b/foo/foo/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/foo/foo/tests/base.py b/foo/foo/tests/base.py deleted file mode 100644 index a6bb8d0..0000000 --- a/foo/foo/tests/base.py +++ /dev/null @@ -1,19 +0,0 @@ -from rest_framework.test import APITestCase - - -class BasicTest(APITestCase): - """ - Generic testing stuff. - """ - - PW = 'password123' - - def login(self, username): - self.client.login(username=username, password=self.PW) - - def logout(self): - self.client.logout() - - def verify_built(self, expected, data): - for key in expected: - self.assertEqual(data[key], expected[key]) diff --git a/foo/foo/tests/test_item_coupon_create.py b/foo/foo/tests/test_item_coupon_create.py deleted file mode 100644 index 98e08cc..0000000 --- a/foo/foo/tests/test_item_coupon_create.py +++ /dev/null @@ -1,68 +0,0 @@ -from django.contrib.auth import get_user_model -from rest_framework import status - -from foo.tests.base import BasicTest - - -class ItemCouponCreateTests(BasicTest): - - def setUp(self): - u = get_user_model() - u.objects.create_superuser('admin', 'john@snow.com', self.PW) - self.user = u.objects.create_user('user', 'me@snow.com', self.PW) - - def test_can_create_coupon_within_item_app(self): - """ - Create a coupon (boringly). - """ - - coupon = { - 'code': 'ASDF', - 'type': 'percent', - } - - self.login(username='admin') - response = self.client.post('/api/v1/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() - - coupon['code_l'] = coupon['code'].lower() - coupon['repeat'] = 0 - coupon['bound'] = False - - self.verify_built(coupon, response.data) - - def test_can_redeem_coupon_within_item_app(self): - """ - Redeem a coupon (boringly). - """ - - coupon = { - 'code': 'ASDF', - 'type': 'percent', - } - - self.login(username='admin') - response = self.client.post('/api/v1/coupon', coupon, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - coupon['code_l'] = coupon['code'].lower() - coupon['repeat'] = 0 - coupon['bound'] = False - - self.verify_built(coupon, response.data) - - coupon_id = response.data['id'] - - response = self.client.put('/api/v1/coupon/%s/redeem' % coupon_id, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - response = self.client.get('/api/v1/coupon/%s/redeemed' % coupon_id, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(1, len(response.data)) - - response = self.client.get('/api/v1/redeemed', format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(1, len(response.data)) - - self.logout() diff --git a/foo/foo/tests/test_item_create.py b/foo/foo/tests/test_item_create.py deleted file mode 100644 index d1f0b60..0000000 --- a/foo/foo/tests/test_item_create.py +++ /dev/null @@ -1,31 +0,0 @@ -from django.contrib.auth import get_user_model -from rest_framework import status - -from foo.tests.base import BasicTest - - -class ItemCreateTests(BasicTest): - - def setUp(self): - u = get_user_model() - u.objects.create_superuser('admin', 'john@snow.com', self.PW) - self.user = u.objects.create_user('user', 'me@snow.com', self.PW) - - def test_can_create_item(self): - """ - Create an item that is globally bound and infinite. - """ - - item = { - 'name': 'ASDF', - 'value': 0, - } - - self.login(username='admin') - response = self.client.post('/api/v1/item', item, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.logout() - - self.verify_built(item, response.data) - - diff --git a/foo/foo/urls.py b/foo/foo/urls.py deleted file mode 100644 index a4f869f..0000000 --- a/foo/foo/urls.py +++ /dev/null @@ -1,34 +0,0 @@ -"""foo URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/1.10/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.conf.urls import url, include - 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) -""" - -from django.conf.urls import url, include -from django.contrib import admin -from rest_framework import routers - -import views - -router = routers.DefaultRouter(trailing_slash=False) -router.register(r'item', views.MiscItemViewSet, base_name='miscitem') - -urlpatterns = [ - url(r'^admin/', admin.site.urls), - url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), - - url(r'^api/(?P(v1))/', include([ - url(r'^', include('coupons.urls')), - url(r'^', include(router.urls)), - ])), -] diff --git a/foo/foo/views.py b/foo/foo/views.py deleted file mode 100644 index a75fb01..0000000 --- a/foo/foo/views.py +++ /dev/null @@ -1,14 +0,0 @@ -from rest_framework import viewsets - -from foo.models import MiscItem -from foo.serializers import MiscItemSerializer - - -class MiscItemViewSet(viewsets.ModelViewSet): - """ - API endpoint that lets you create, delete, retrieve miscellaneous items. - """ - - serializer_class = MiscItemSerializer - queryset = MiscItem.objects.all() - diff --git a/foo/foo/wsgi.py b/foo/foo/wsgi.py deleted file mode 100644 index 168892c..0000000 --- a/foo/foo/wsgi.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -WSGI config for foo project. - -It exposes the WSGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ -""" - -import os - -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "foo.settings") - -application = get_wsgi_application() diff --git a/foo/manage.py b/foo/manage.py deleted file mode 100755 index 671c67e..0000000 --- a/foo/manage.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python -import os -import sys - -if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "foo.settings") - try: - from django.core.management import execute_from_command_line - except ImportError: - # The above import may fail for some other reason. Ensure that the - # issue is really that Django is missing to avoid masking other - # exceptions on Python 2. - try: - import django - except ImportError: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) - raise - execute_from_command_line(sys.argv) diff --git a/setup.py b/setup.py index 8f66673..178d1f9 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ 'Environment :: Web Environment', 'Framework :: Django', 'Intended Audience :: Developers', - 'License :: OSI Approved :: Apache 2.0 License', + 'License :: OSI Approved :: Apache Software License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 2',