├── .gitignore ├── README.md ├── accounts ├── __init__.py ├── admin.py ├── api │ ├── __init__.py │ ├── serializers.py │ ├── urls.py │ └── views.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py └── models.py ├── base ├── __init__.py ├── models.py └── utils.py ├── i2x_demo ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py ├── manage.py ├── requirements.txt ├── teams ├── __init__.py ├── admin.py ├── api │ ├── __init__.py │ ├── serializers.py │ ├── urls.py │ └── views.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py └── models.py └── templates ├── invitation_team ├── invitation_team_content.txt └── invitation_team_subject.txt ├── password_reset ├── password_reset_email_content.txt └── password_reset_email_subject.txt └── registration ├── activation_email_content.txt └── activation_email_subject.txt /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.py~ 3 | .idea/ 4 | */.DS_Store 5 | *.sqlite3 6 | local_settings.py 7 | sftp-config.json 8 | db.sqlite3 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Features: 2 | --------- 3 | * User registration 4 | 5 | * User email verification 6 | 7 | * Change password - with password reset email. 8 | 9 | * View user profile 10 | 11 | * Create team 12 | 13 | * Invite people to team 14 | 15 | 16 | API Documentation 17 | ----------------- 18 | 19 | Postman collection : https://www.getpostman.com/collections/f803c13bd223791d1b21 20 | 21 | i. User - Registration 22 | Create/ Register a new user. 23 | 24 | Endpoint : /api/accounts/register/ 25 | Request Type : POST 26 | Request Params : username, email, password, password_2, first_name, last_name, invite_code 27 | Non-mandatory params : invite_code 28 | 29 | Response Http status codes : HTTP_200_OK or HTTP_400_BAD_REQUEST 30 | 31 | Sample Input : https://api.myjson.com/bins/o1id5 32 | Sample Output : https://api.myjson.com/bins/v6pmh 33 | 34 | ii. User - Email Verification 35 | Verify the email inorder to activate the user account. 36 | 37 | User will recieve an email on successful registration with the verification_code. 38 | 39 | Endpoint : /api/accounts/verify// 40 | Request Type : GET 41 | Request Params : invite_code 42 | 43 | Response Http status codes : HTTP_200_OK or HTTP_404_NO_CONTENT 44 | 45 | iii. User - Login 46 | Obtain authentication token given the user credentials. 47 | 48 | Endpoint : /api/accounts/login/ 49 | Request Type : POST 50 | Request Params : email (or username) and password 51 | 52 | Response : { "token": } 53 | HTTP status code: HTTP_200_OK or HTTP_400_BAD_REQUEST 54 | 55 | iv. User - Request for Password Reset 56 | Receive an email with password reset link. 57 | 58 | Endpoint : /api/accounts/password_reset/ 59 | Request Type : POST 60 | Request Params : email 61 | Request Sample : {"email": "some_email_id@gmail.com"} 62 | 63 | HTTP status code: HTTP_200_OK 64 | 65 | v. User - Password Change 66 | Change the password. Link as recieved from email by above request (iv). 67 | 68 | Endpoint : /api/accounts/reset// 69 | Request Type : POST 70 | Request Sample : {"new_password": "33441122", "new_password_2": "33441122"} 71 | 72 | HTTP status code: HTTP_200_OK or HTTP_400_BAD_REQUEST 73 | 74 | vi. User - Retrieve Profile 75 | Retrieve logged in users profile. 76 | 77 | Endpoint : /api/accounts/user-profile/ 78 | Request Type : GET 79 | Request Headers : 80 | Authorization : Token 81 | 82 | HTTP status code: HTTP_200_OK or HTTP_401_UNAUTHORISED 83 | Response Sample : https://api.myjson.com/bins/18zyux 84 | 85 | vii. Team - Create 86 | Create a new team. 87 | 88 | Endpoint : /api/teams/create/ 89 | Request Type : POST 90 | Request Headers : 91 | Authorization : Token 92 | Request Payload : {"name": , "description": } 93 | 94 | HTTP status code: HTTP_200_OK or HTTP_400_BAD_REQUEST or HTTP_401_UNAUTHORISED 95 | 96 | viii. Team - Invite 97 | Invite people to join the team. 98 | 99 | Endpoint : /api/teams//invite/ 100 | Request Type : POST 101 | Request Headers : 102 | Authorization : Token 103 | Request Payload : {"emails": ["some_email_id@gmail.com"]} 104 | 105 | HTTP status code: HTTP_200_OK or HTTP_400_BAD_REQUEST or HTTP_401_UNAUTHORISED 106 | 107 | 108 | ## Run the project Locally ## 109 | 110 | i. Clone the repository. 111 | 112 | ii. Go to directory of manage.py and install the requirements. 113 | 114 | pip install -r requirements.txt 115 | 116 | **Note:** 117 | You may configure the virtual environment if required. 118 | 119 | For instructions, click here : https://virtualenv.pypa.io/en/latest/installation/ 120 | 121 | iii. Create local_settings.py inside i2x_demo directory. 122 | 123 | EMAIL_HOST_USER = '' 124 | 125 | EMAIL_HOST_PASSWORD = '' 126 | 127 | DEFAULT_FROM_EMAIL = '' 128 | 129 | **Note:** 130 | By default, Sqlite3 database is used. You may also use different database in local_settings file if required. 131 | 132 | iv. Run migrations 133 | 134 | python manage.py migrate 135 | 136 | v. Ready to run the server. 137 | 138 | python manage.py runserver 139 | 140 | ## Configuration Variables ## 141 | 142 | #### VERIFICATION_KEY_EXPIRY_DAYS #### 143 | 144 | Validity (in days) of user account activation email. Defaulted to 2 145 | 146 | #### SITE_NAME #### 147 | 148 | Name of Website to be displayed on outgoing emails and elsewhere. Defauled to i2x Demo 149 | 150 | #### PASSWORD_MIN_LENGTH #### 151 | 152 | A constraint that defines minimum length of password. Defaulted to 8 153 | 154 | #### INVITATION_VALIDITY_DAYS #### 155 | 156 | Validity (in days) of user team invitation email. Defaulted to 7 157 | 158 | 159 | ## Try it online: ## 160 | https://dry-stream-50652.herokuapp.com/ 161 | 162 | 163 | -------------------------------------------------------------------------------- /accounts/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'askar' 2 | -------------------------------------------------------------------------------- /accounts/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import UserProfile 4 | 5 | 6 | @admin.register(UserProfile) 7 | class UserProfileAdmin(admin.ModelAdmin): 8 | 9 | list_display = ('id', 'name', 'email', 'is_active', 'has_email_verified', 'team') 10 | 11 | def email(self, profile): 12 | return profile.user.email 13 | 14 | def name(self, profile): 15 | return profile.user.first_name + " " + profile.user.last_name 16 | 17 | def is_active(self, profile): 18 | return profile.user.is_active 19 | 20 | def team(self, profile): 21 | return profile.user.team 22 | 23 | -------------------------------------------------------------------------------- /accounts/api/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'askar' 2 | -------------------------------------------------------------------------------- /accounts/api/serializers.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from django.contrib.auth import get_user_model 3 | from django.contrib.sites.shortcuts import get_current_site 4 | from django.contrib.auth.tokens import default_token_generator 5 | from django.db.models import Q 6 | from django.conf import settings 7 | from rest_framework import serializers 8 | from rest_framework.authtoken.models import Token 9 | 10 | from base import utils as base_utils 11 | from accounts.models import UserProfile 12 | from teams.models import TeamInvitation 13 | from teams.api.serializers import TeamSerializer 14 | 15 | User = get_user_model() 16 | 17 | 18 | class UserRegistrationSerializer(serializers.ModelSerializer): 19 | 20 | email = serializers.EmailField( 21 | required=True, 22 | label="Email Address" 23 | ) 24 | 25 | password = serializers.CharField( 26 | required=True, 27 | label="Password", 28 | style={'input_type': 'password'} 29 | ) 30 | 31 | password_2 = serializers.CharField( 32 | required=True, 33 | label="Confirm Password", 34 | style={'input_type': 'password'} 35 | ) 36 | 37 | first_name = serializers.CharField( 38 | required=True 39 | ) 40 | 41 | last_name = serializers.CharField( 42 | required=True 43 | ) 44 | 45 | invite_code = serializers.CharField( 46 | required=False 47 | ) 48 | 49 | class Meta(object): 50 | model = User 51 | fields = ['username', 'email', 'password', 'password_2', 'first_name', 'last_name', 'invite_code'] 52 | 53 | def validate_email(self, value): 54 | if User.objects.filter(email=value).exists(): 55 | raise serializers.ValidationError("Email already exists.") 56 | return value 57 | 58 | def validate_password(self, value): 59 | if len(value) < getattr(settings, 'PASSWORD_MIN_LENGTH', 8): 60 | raise serializers.ValidationError( 61 | "Password should be atleast %s characters long." % getattr(settings, 'PASSWORD_MIN_LENGTH', 8) 62 | ) 63 | return value 64 | 65 | def validate_password_2(self, value): 66 | data = self.get_initial() 67 | password = data.get('password') 68 | if password != value: 69 | raise serializers.ValidationError("Passwords doesn't match.") 70 | return value 71 | 72 | def validate_username(self, value): 73 | if User.objects.filter(username=value).exists(): 74 | raise serializers.ValidationError("Email already exists.") 75 | return value 76 | 77 | def validate_invite_code(self, value): 78 | data = self.get_initial() 79 | email = data.get('email') 80 | if value: 81 | self.invitation = TeamInvitation.objects.validate_code(email, value) 82 | if not self.invitation: 83 | raise serializers.ValidationError("Invite code is not valid / expired.") 84 | self.team = self.invitation.invited_by.team.last() 85 | return value 86 | 87 | def create(self, validated_data): 88 | team = getattr(self, 'team', None) 89 | 90 | user_data = { 91 | 'username': validated_data.get('username'), 92 | 'email': validated_data.get('email'), 93 | 'password': validated_data.get('password'), 94 | 'first_name': validated_data.get('first_name'), 95 | 'last_name': validated_data.get('last_name') 96 | } 97 | 98 | is_active = True if team else False 99 | 100 | user = UserProfile.objects.create_user_profile( 101 | data=user_data, 102 | is_active=is_active, 103 | site=get_current_site(self.context['request']), 104 | send_email=True 105 | ) 106 | 107 | if team: 108 | team.members.add(user) 109 | 110 | if hasattr(self, 'invitation'): 111 | TeamInvitation.objects.accept_invitation(self.invitation) 112 | 113 | TeamInvitation.objects.decline_pending_invitations(email_ids=[validated_data.get('email')]) 114 | 115 | return validated_data 116 | 117 | 118 | class UserLoginSerializer(serializers.ModelSerializer): 119 | 120 | username = serializers.CharField( 121 | required=False, 122 | allow_blank=True, 123 | write_only=True, 124 | ) 125 | 126 | email = serializers.EmailField( 127 | required=False, 128 | allow_blank=True, 129 | write_only=True, 130 | label="Email Address" 131 | ) 132 | 133 | token = serializers.CharField( 134 | allow_blank=True, 135 | read_only=True 136 | ) 137 | 138 | password = serializers.CharField( 139 | required=True, 140 | write_only=True, 141 | style={'input_type': 'password'} 142 | ) 143 | 144 | class Meta(object): 145 | model = User 146 | fields = ['email', 'username', 'password', 'token'] 147 | 148 | def validate(self, data): 149 | email = data.get('email', None) 150 | username = data.get('username', None) 151 | password = data.get('password', None) 152 | 153 | if not email and not username: 154 | raise serializers.ValidationError("Please enter username or email to login.") 155 | 156 | user = User.objects.filter( 157 | Q(email=email) | Q(username=username) 158 | ).exclude( 159 | email__isnull=True 160 | ).exclude( 161 | email__iexact='' 162 | ).distinct() 163 | 164 | if user.exists() and user.count() == 1: 165 | user_obj = user.first() 166 | else: 167 | raise serializers.ValidationError("This username/email is not valid.") 168 | 169 | if user_obj: 170 | if not user_obj.check_password(password): 171 | raise serializers.ValidationError("Invalid credentials.") 172 | 173 | if user_obj.is_active: 174 | token, created = Token.objects.get_or_create(user=user_obj) 175 | data['token'] = token 176 | else: 177 | raise serializers.ValidationError("User not active.") 178 | 179 | return data 180 | 181 | 182 | class PasswordResetSerializer(serializers.Serializer): 183 | 184 | email = serializers.EmailField( 185 | required=True 186 | ) 187 | 188 | def validate_email(self, value): 189 | # Not validating email to have data privacy. 190 | # Otherwise, one can check if an email is already existing in database. 191 | return value 192 | 193 | 194 | class PasswordResetConfirmSerializer(serializers.Serializer): 195 | 196 | token_generator = default_token_generator 197 | 198 | def __init__(self, *args, **kwargs): 199 | context = kwargs['context'] 200 | uidb64, token = context.get('uidb64'), context.get('token') 201 | if uidb64 and token: 202 | uid = base_utils.base36decode(uidb64) 203 | self.user = self.get_user(uid) 204 | self.valid_attempt = self.token_generator.check_token(self.user, token) 205 | super(PasswordResetConfirmSerializer, self).__init__(*args, **kwargs) 206 | 207 | def get_user(self, uid): 208 | try: 209 | user = User._default_manager.get(pk=uid) 210 | except (TypeError, ValueError, OverflowError, User.DoesNotExist): 211 | user = None 212 | return user 213 | 214 | new_password = serializers.CharField( 215 | style={'input_type': 'password'}, 216 | label="New Password", 217 | write_only=True 218 | ) 219 | 220 | new_password_2 = serializers.CharField( 221 | style={'input_type': 'password'}, 222 | label="Confirm New Password", 223 | write_only=True 224 | ) 225 | 226 | def validate_new_password_2(self, value): 227 | data = self.get_initial() 228 | new_password = data.get('new_password') 229 | if new_password != value: 230 | raise serializers.ValidationError("Passwords doesn't match.") 231 | return value 232 | 233 | def validate(self, data): 234 | if not self.valid_attempt: 235 | raise serializers.ValidationError("Operation not allowed.") 236 | return data 237 | 238 | 239 | class UserSerializer(serializers.ModelSerializer): 240 | 241 | team = TeamSerializer(many=True) 242 | 243 | class Meta: 244 | model = User 245 | fields = ['email', 'first_name', 'last_name', 'team'] 246 | 247 | 248 | class UserProfileSerializer(serializers.ModelSerializer): 249 | 250 | user = UserSerializer() 251 | 252 | class Meta: 253 | model = UserProfile 254 | fields = ['user', 'has_email_verified'] 255 | 256 | -------------------------------------------------------------------------------- /accounts/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | 4 | from . import views 5 | 6 | urlpatterns = [ 7 | 8 | url(r'^login/$', 9 | views.UserLoginAPIView.as_view(), 10 | name='login'), 11 | 12 | url(r'^register/$', 13 | views.UserRegistrationAPIView.as_view(), 14 | name='register'), 15 | 16 | url(r'^verify/(?P.+)/$', 17 | views.UserEmailVerificationAPIView.as_view(), 18 | name='email_verify'), 19 | 20 | url(r'^password_reset/$', 21 | views.PasswordResetAPIView.as_view(), 22 | name='password_change'), 23 | 24 | url(r'^reset/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', 25 | views.PasswordResetConfirmView.as_view(), 26 | name='password_reset_confirm'), 27 | 28 | url(r'^user-profile/$', 29 | views.UserProfileAPIView.as_view(), 30 | name='user_profile'), 31 | 32 | 33 | ] 34 | -------------------------------------------------------------------------------- /accounts/api/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.sites.shortcuts import get_current_site 3 | from rest_framework import generics, permissions, status, views 4 | from rest_framework.authentication import TokenAuthentication 5 | from rest_framework.response import Response 6 | 7 | from accounts.models import UserProfile 8 | from . import serializers 9 | 10 | User = get_user_model() 11 | 12 | 13 | class UserRegistrationAPIView(generics.CreateAPIView): 14 | """ 15 | Endpoint for user registration. 16 | 17 | """ 18 | 19 | permission_classes = (permissions.AllowAny, ) 20 | serializer_class = serializers.UserRegistrationSerializer 21 | queryset = User.objects.all() 22 | 23 | 24 | class UserEmailVerificationAPIView(views.APIView): 25 | """ 26 | Endpoint for verifying email address. 27 | 28 | """ 29 | 30 | permission_classes = (permissions.AllowAny, ) 31 | 32 | def get(self, request, verification_key): 33 | activated_user = self.activate(verification_key) 34 | if activated_user: 35 | return Response(status=status.HTTP_200_OK) 36 | return Response(status=status.HTTP_204_NO_CONTENT) 37 | 38 | def activate(self, verification_key): 39 | return UserProfile.objects.activate_user(verification_key) 40 | 41 | 42 | class UserLoginAPIView(views.APIView): 43 | """ 44 | Endpoint for user login. Returns authentication token on success. 45 | 46 | """ 47 | 48 | permission_classes = (permissions.AllowAny, ) 49 | serializer_class = serializers.UserLoginSerializer 50 | 51 | def post(self, request): 52 | serializer = self.serializer_class(data=request.data) 53 | if serializer.is_valid(raise_exception=True): 54 | return Response(serializer.data, status=status.HTTP_200_OK) 55 | 56 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 57 | 58 | 59 | class PasswordResetAPIView(views.APIView): 60 | """ 61 | Endpoint to send email to user with password reset link. 62 | 63 | """ 64 | 65 | permission_classes = (permissions.AllowAny, ) 66 | serializer_class = serializers.PasswordResetSerializer 67 | 68 | def post(self, request): 69 | user_profile = self.get_user_profile(request.data.get('email')) 70 | if user_profile: 71 | user_profile.send_password_reset_email( 72 | site=get_current_site(request) 73 | ) # To be made asynchronous in production 74 | return Response(status=status.HTTP_200_OK) 75 | 76 | # Forcing Http status to 200 even if failure to support user privacy. 77 | # Will show message at frontend like "If the email is valid, you must have received password reset email" 78 | return Response(status=status.HTTP_200_OK) 79 | 80 | def get_user_profile(self, email): 81 | try: 82 | user_profile = UserProfile.objects.get(user__email=email) 83 | except: 84 | return None 85 | return user_profile 86 | 87 | 88 | class PasswordResetConfirmView(views.APIView): 89 | """ 90 | Endpoint to change user password. 91 | 92 | """ 93 | 94 | permission_classes = (permissions.AllowAny, ) 95 | serializer_class = serializers.PasswordResetConfirmSerializer 96 | 97 | def post(self, request, *args, **kwargs): 98 | 99 | serializer = self.serializer_class( 100 | data=request.data, 101 | context={ 102 | 'uidb64': kwargs['uidb64'], 103 | 'token': kwargs['token'] 104 | }) 105 | 106 | if serializer.is_valid(raise_exception=True): 107 | new_password = serializer.validated_data.get('new_password') 108 | user = serializer.user 109 | user.set_password(new_password) 110 | user.save() 111 | return Response(serializer.data, status=status.HTTP_200_OK) 112 | 113 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 114 | 115 | 116 | class UserProfileAPIView(generics.RetrieveAPIView): 117 | """ 118 | Endpoint to retrieve user profile. 119 | 120 | """ 121 | 122 | permission_classes = (permissions.IsAuthenticated, ) 123 | authentication_classes = (TokenAuthentication, ) 124 | serializer_class = serializers.UserProfileSerializer 125 | 126 | def get_object(self): 127 | return self.request.user.userprofile 128 | 129 | -------------------------------------------------------------------------------- /accounts/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2017-05-07 09:43 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='UserProfile', 21 | fields=[ 22 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 23 | ('timestamp_created', models.DateTimeField(auto_now_add=True)), 24 | ('timestamp_updated', models.DateTimeField(auto_now=True)), 25 | ('has_email_verified', models.BooleanField(default=False)), 26 | ('verification_key', models.CharField(max_length=40)), 27 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 28 | ], 29 | options={ 30 | 'verbose_name': 'user profile', 31 | 'verbose_name_plural': 'user profiles', 32 | }, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /accounts/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beingaskar/django-rest-framework-user-registration/db7e623b4291b034a14902ba764b5fa77ed9db3a/accounts/migrations/__init__.py -------------------------------------------------------------------------------- /accounts/models.py: -------------------------------------------------------------------------------- 1 | import re 2 | import hashlib 3 | import datetime 4 | 5 | from django.conf import settings 6 | from django.db import models, transaction 7 | from django.contrib.auth import get_user_model 8 | from django.utils.crypto import get_random_string 9 | from django.template.loader import render_to_string 10 | from django.core.mail import EmailMultiAlternatives 11 | from django.core.exceptions import ObjectDoesNotExist 12 | from django.utils import timezone 13 | from django.contrib.auth.tokens import default_token_generator 14 | 15 | from base import utils as base_utils 16 | from base import models as base_models 17 | 18 | User = get_user_model() 19 | 20 | token_generator = default_token_generator 21 | 22 | SHA1_RE = re.compile('^[a-f0-9]{40}$') 23 | 24 | 25 | class Verification(models.Model): 26 | """ 27 | An abstract model that provides fields related to user 28 | verification. 29 | 30 | """ 31 | 32 | has_email_verified = models.BooleanField( 33 | default=False 34 | ) 35 | 36 | class Meta: 37 | abstract = True 38 | 39 | 40 | class UserProfileRegistrationManager(models.Manager): 41 | """ 42 | Custom manager for ``UserProfile`` model. 43 | 44 | The methods defined here provide shortcuts for user profile creation 45 | and activation (including generation and emailing of activation 46 | keys), and for cleaning out expired inactive accounts. 47 | 48 | """ 49 | 50 | @transaction.atomic 51 | def create_user_profile(self, data, is_active=False, site=None, send_email=True): 52 | """ 53 | Create a new user and its associated ``UserProfile``. 54 | Also, send user account activation (verification) email. 55 | 56 | """ 57 | 58 | password = data.pop('password') 59 | user = User(**data) 60 | user.is_active = is_active 61 | user.set_password(password) 62 | user.save() 63 | 64 | user_profile = self.create_profile(user) 65 | 66 | if send_email: 67 | user_profile.send_activation_email(site) # To be made asynchronous in production 68 | 69 | return user 70 | 71 | def create_profile(self, user): 72 | """ 73 | Create UserProfile for give user. 74 | Returns created user profile on success. 75 | 76 | """ 77 | 78 | username = str(getattr(user, User.USERNAME_FIELD)) 79 | hash_input = (get_random_string(5) + username).encode('utf-8') 80 | verification_key = hashlib.sha1(hash_input).hexdigest() 81 | 82 | profile = self.create( 83 | user=user, 84 | verification_key=verification_key 85 | ) 86 | 87 | return profile 88 | 89 | def activate_user(self, verification_key): 90 | """ 91 | Validate an verification key and activate the corresponding user 92 | if valid. Returns the user account on success, ``None`` on 93 | failure. 94 | 95 | """ 96 | 97 | if SHA1_RE.search(verification_key.lower()): 98 | try: 99 | user_profile = self.get(verification_key=verification_key) 100 | except ObjectDoesNotExist: 101 | return None 102 | if not user_profile.verification_key_expired(): 103 | user = user_profile.user 104 | user.is_active = True 105 | user.save() 106 | user_profile.verification_key = UserProfile.ACTIVATED 107 | user_profile.has_email_verified = True 108 | user_profile.save() 109 | return user 110 | return None 111 | 112 | def expired(self): 113 | """ 114 | Returns the list of inactive expired users. 115 | 116 | """ 117 | 118 | now = timezone.now() if settings.USE_TZ else datetime.datetime.now() 119 | 120 | return self.exclude( 121 | models.Q(user__is_active=True) | 122 | models.Q(verification_key=UserProfile.ACTIVATED) 123 | ).filter( 124 | user__date_joined__lt=now - datetime.timedelta( 125 | getattr(settings, 'VERIFICATION_KEY_EXPIRY_DAYS', 4) 126 | ) 127 | ) 128 | 129 | @transaction.atomic 130 | def delete_expired_users(self): 131 | """ 132 | Deletes all instances of inactive expired users. 133 | 134 | """ 135 | 136 | for profile in self.expired(): 137 | user = profile.user 138 | profile.delete() 139 | user.delete() 140 | 141 | 142 | class UserProfile(base_models.TimeStampedModel, Verification): 143 | """ 144 | A model for user profile that also stores verification key. 145 | Any methods under User will reside here. 146 | 147 | """ 148 | 149 | ACTIVATED = "ALREADY ACTIVATED" 150 | 151 | user = models.OneToOneField( 152 | settings.AUTH_USER_MODEL 153 | ) 154 | 155 | verification_key = models.CharField( 156 | max_length=40 157 | ) 158 | 159 | objects = UserProfileRegistrationManager() 160 | 161 | class Meta: 162 | verbose_name = u'user profile' 163 | verbose_name_plural = u'user profiles' 164 | 165 | def __str__(self): 166 | return str(self.user) 167 | 168 | def verification_key_expired(self): 169 | """ 170 | Validate whether the user's verification key has been expired 171 | or not. Returns ``True`` if expired, otherwise ``False``. 172 | 173 | """ 174 | 175 | expiration_date = datetime.timedelta( 176 | days=getattr(settings, 'VERIFICATION_KEY_EXPIRY_DAYS', 4) 177 | ) 178 | 179 | return self.verification_key == self.ACTIVATED or \ 180 | (self.user.date_joined + expiration_date <= timezone.now()) 181 | 182 | def send_activation_email(self, site): 183 | """ 184 | Sends an activation (verification) email to user. 185 | """ 186 | 187 | context = { 188 | 'verification_key': self.verification_key, 189 | 'expiration_days': getattr(settings, 'VERIFICATION_KEY_EXPIRY_DAYS', 4), 190 | 'user': self.user, 191 | 'site': site, 192 | 'site_name': getattr(settings, 'SITE_NAME', None) 193 | } 194 | 195 | subject = render_to_string( 196 | 'registration/activation_email_subject.txt', context 197 | ) 198 | 199 | subject = ''.join(subject.splitlines()) 200 | 201 | message = render_to_string( 202 | 'registration/activation_email_content.txt', context 203 | ) 204 | 205 | msg = EmailMultiAlternatives(subject, "", settings.DEFAULT_FROM_EMAIL, [self.user.email]) 206 | msg.attach_alternative(message, "text/html") 207 | msg.send() 208 | 209 | def send_password_reset_email(self, site): 210 | """ 211 | Sends a password reset email to user. 212 | 213 | """ 214 | 215 | context = { 216 | 'email': self.user.email, 217 | 'site': site, 218 | 'site_name': getattr(settings, 'SITE_NAME', None), 219 | 'uid': base_utils.base36encode(self.user.pk), 220 | 'user': self.user, 221 | 'token': token_generator.make_token(self.user) 222 | } 223 | subject = render_to_string( 224 | 'password_reset/password_reset_email_subject.txt', context 225 | ) 226 | 227 | subject = ''.join(subject.splitlines()) 228 | 229 | message = render_to_string( 230 | 'password_reset/password_reset_email_content.txt', context 231 | ) 232 | 233 | msg = EmailMultiAlternatives(subject, "", settings.DEFAULT_FROM_EMAIL, [self.user.email]) 234 | msg.attach_alternative(message, "text/html") 235 | msg.send() 236 | -------------------------------------------------------------------------------- /base/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'askar' 2 | -------------------------------------------------------------------------------- /base/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class TimeStampedModel(models.Model): 5 | """ 6 | An abstract model that provides fields required 7 | for most models such as creation time, updated time. 8 | 9 | """ 10 | 11 | timestamp_created = models.DateTimeField( 12 | auto_now_add=True 13 | ) 14 | 15 | timestamp_updated = models.DateTimeField( 16 | auto_now=True 17 | ) 18 | 19 | class Meta: 20 | abstract = True 21 | -------------------------------------------------------------------------------- /base/utils.py: -------------------------------------------------------------------------------- 1 | def base36encode(number, alphabet='0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'): 2 | """ 3 | Converts an integer to a base36 string. 4 | 5 | """ 6 | 7 | if not isinstance(number, (int, long)): 8 | raise TypeError('number must be an integer') 9 | 10 | base36 = '' 11 | sign = '' 12 | 13 | if number < 0: 14 | sign = '-' 15 | number = -number 16 | 17 | if 0 <= number < len(alphabet): 18 | return sign + alphabet[number] 19 | 20 | while number != 0: 21 | number, i = divmod(number, len(alphabet)) 22 | base36 = alphabet[i] + base36 23 | 24 | return sign + base36 25 | 26 | 27 | def base36decode(number): 28 | """ 29 | Converts a base36 string to integer. 30 | 31 | """ 32 | 33 | return int(number, 36) 34 | -------------------------------------------------------------------------------- /i2x_demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beingaskar/django-rest-framework-user-registration/db7e623b4291b034a14902ba764b5fa77ed9db3a/i2x_demo/__init__.py -------------------------------------------------------------------------------- /i2x_demo/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for i2x_demo project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.11. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.11/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = '&q(&9vy12r*3v1g4s-99pz=j@ws1e%s0j2mu&7uidmqlbwxaj3' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'rest_framework', 41 | 'rest_framework.authtoken', 42 | 'accounts', 43 | 'teams', 44 | ] 45 | 46 | MIDDLEWARE = [ 47 | 'django.middleware.security.SecurityMiddleware', 48 | 'django.contrib.sessions.middleware.SessionMiddleware', 49 | 'django.middleware.common.CommonMiddleware', 50 | 'django.middleware.csrf.CsrfViewMiddleware', 51 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 52 | 'django.contrib.messages.middleware.MessageMiddleware', 53 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 54 | ] 55 | 56 | ROOT_URLCONF = 'i2x_demo.urls' 57 | 58 | TEMPLATES = [ 59 | { 60 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 61 | 'DIRS': [os.path.join(BASE_DIR, 'templates')], 62 | 'APP_DIRS': True, 63 | 'OPTIONS': { 64 | 'context_processors': [ 65 | 'django.template.context_processors.debug', 66 | 'django.template.context_processors.request', 67 | 'django.contrib.auth.context_processors.auth', 68 | 'django.contrib.messages.context_processors.messages', 69 | ], 70 | }, 71 | }, 72 | ] 73 | 74 | WSGI_APPLICATION = 'i2x_demo.wsgi.application' 75 | 76 | 77 | # Database 78 | # https://docs.djangoproject.com/en/1.11/ref/settings/#databases 79 | 80 | DATABASES = { 81 | 'default': { 82 | 'ENGINE': 'django.db.backends.sqlite3', 83 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 84 | } 85 | } 86 | 87 | 88 | # Password validation 89 | # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators 90 | 91 | AUTH_PASSWORD_VALIDATORS = [ 92 | { 93 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 94 | }, 95 | { 96 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 97 | }, 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 100 | }, 101 | { 102 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 103 | }, 104 | ] 105 | 106 | 107 | # Internationalization 108 | # https://docs.djangoproject.com/en/1.11/topics/i18n/ 109 | 110 | LANGUAGE_CODE = 'en-us' 111 | 112 | TIME_ZONE = 'UTC' 113 | 114 | USE_I18N = True 115 | 116 | USE_L10N = True 117 | 118 | USE_TZ = True 119 | 120 | 121 | # Static files (CSS, JavaScript, Images) 122 | # https://docs.djangoproject.com/en/1.11/howto/static-files/ 123 | 124 | STATIC_URL = '/static/' 125 | 126 | EMAIL_USE_TLS = True 127 | EMAIL_HOST = 'smtp.gmail.com' 128 | EMAIL_HOST_USER = 'test@domain.com' 129 | EMAIL_HOST_PASSWORD = 'password' 130 | DEFAULT_FROM_EMAIL = "test@domain.com" 131 | EMAIL_PORT = 587 132 | 133 | VERIFICATION_KEY_EXPIRY_DAYS = 2 134 | 135 | SITE_NAME = "i2x Demo" 136 | 137 | try: 138 | from local_settings import * 139 | except ImportError: 140 | pass -------------------------------------------------------------------------------- /i2x_demo/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url, include 2 | from django.contrib import admin 3 | 4 | urlpatterns = [ 5 | 6 | url(r'^admin/', admin.site.urls), 7 | 8 | url(r'^api/accounts/', include('accounts.api.urls')), 9 | 10 | url(r'^api/teams/', include('teams.api.urls')), 11 | ] 12 | -------------------------------------------------------------------------------- /i2x_demo/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for i2x_demo project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "i2x_demo.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "i2x_demo.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | # The above import may fail for some other reason. Ensure that the 11 | # issue is really that Django is missing to avoid masking other 12 | # exceptions on Python 2. 13 | try: 14 | import django 15 | except ImportError: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) 21 | raise 22 | execute_from_command_line(sys.argv) 23 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | amqp==2.1.4 2 | appdirs==1.4.3 3 | appnope==0.1.0 4 | backports.shutil-get-terminal-size==1.0.0 5 | billiard==3.5.0.2 6 | celery==4.0.2 7 | decorator==4.0.11 8 | Django==1.11 9 | djangorestframework==3.6.2 10 | enum34==1.1.6 11 | ipython==5.3.0 12 | ipython-genutils==0.2.0 13 | kombu==4.0.2 14 | packaging==16.8 15 | pathlib2==2.2.1 16 | pexpect==4.2.1 17 | pickleshare==0.7.4 18 | prompt-toolkit==1.0.14 19 | ptyprocess==0.5.1 20 | Pygments==2.2.0 21 | pyparsing==2.2.0 22 | pytz==2017.2 23 | scandir==1.5 24 | simplegeneric==0.8.1 25 | six==1.10.0 26 | traitlets==4.3.2 27 | vine==1.1.3 28 | wcwidth==0.1.7 29 | -------------------------------------------------------------------------------- /teams/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'askar' 2 | -------------------------------------------------------------------------------- /teams/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Team, TeamInvitation 4 | 5 | 6 | @admin.register(Team) 7 | class TeamAdmin(admin.ModelAdmin): 8 | 9 | list_display = ('id', 'name', 'description', 'owner') 10 | 11 | 12 | @admin.register(TeamInvitation) 13 | class TeamInvitationAdmin(admin.ModelAdmin): 14 | 15 | list_display = ('id', 'email', 'invited_by', 'status') 16 | 17 | -------------------------------------------------------------------------------- /teams/api/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'askar' 2 | -------------------------------------------------------------------------------- /teams/api/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from teams.models import Team 4 | from django.contrib.auth import get_user_model 5 | 6 | User = get_user_model() 7 | 8 | 9 | class TeamCreateSerializer(serializers.ModelSerializer): 10 | 11 | class Meta: 12 | model = Team 13 | fields = ['name', 'description'] 14 | 15 | def validate(self, data): 16 | user = self.context.get('user', None) 17 | if not user: 18 | raise serializers.ValidationError("User not found.") 19 | if not Team.objects.has_create_permission(user): 20 | raise serializers.ValidationError("User not allowed to create team.") 21 | return data 22 | 23 | 24 | class TeamSerializer(serializers.ModelSerializer): 25 | 26 | class Meta: 27 | model = Team 28 | fields = ['id', 'name', 'description'] 29 | 30 | 31 | class TeamInvitationCreateSerializer(serializers.Serializer): 32 | 33 | MAXIMUM_EMAILS_ALLOWED = 5 34 | 35 | emails = serializers.ListField( 36 | write_only=True 37 | ) 38 | 39 | def validate(self, data): 40 | emails = data.get('emails') 41 | if len(emails) > self.MAXIMUM_EMAILS_ALLOWED: 42 | raise serializers.ValidationError("Not more than %s email ID's are allowed." % self.MAXIMUM_EMAILS_ALLOWED) 43 | 44 | team_pk = self.context.get('team_pk') 45 | user = self.context.get('user') 46 | 47 | try: 48 | team = Team.objects.get(pk=team_pk) 49 | except Team.DoesNotExist: 50 | raise serializers.ValidationError("Team does not exist.") 51 | 52 | if team.has_invite_permissions(user): 53 | email_ids_existing = User.objects.filter(email__in=emails).values_list('email', flat=True) 54 | if email_ids_existing: 55 | raise serializers.ValidationError( 56 | "One or more of the email ID's provided is already associated with accounts. (%s)" 57 | % ",".join(email_ids_existing)) 58 | return data 59 | 60 | raise serializers.ValidationError("Operation not allowed.") 61 | -------------------------------------------------------------------------------- /teams/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | 4 | from . import views 5 | 6 | urlpatterns = [ 7 | 8 | url(r'^create/$', 9 | views.CreateTeamAPIView.as_view(), 10 | name='login'), 11 | 12 | url(r'^(?P[0-9]+)/invite/$', 13 | views.InviteToTeamAPIView.as_view(), 14 | name='invite_to_team'), 15 | 16 | ] 17 | -------------------------------------------------------------------------------- /teams/api/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sites.shortcuts import get_current_site 2 | from rest_framework import generics, permissions, status 3 | from rest_framework.authentication import TokenAuthentication 4 | from rest_framework.response import Response 5 | 6 | from . import serializers 7 | from teams.models import Team, TeamInvitation 8 | 9 | 10 | class CreateTeamAPIView(generics.CreateAPIView): 11 | """ 12 | Endpoint to create a team. 13 | 14 | """ 15 | 16 | permission_classes = (permissions.IsAuthenticated, ) 17 | authentication_classes = (TokenAuthentication, ) 18 | serializer_class = serializers.TeamCreateSerializer 19 | queryset = Team.objects.all() 20 | 21 | def post(self, request, *args, **kwargs): 22 | serializer = self.serializer_class(data=request.data, 23 | context={'user': request.user} 24 | ) 25 | if serializer.is_valid(raise_exception=True): 26 | team = serializer.save(owner=request.user) 27 | team.members.add(request.user) 28 | return Response(serializer.data, status=status.HTTP_200_OK) 29 | 30 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 31 | 32 | 33 | class InviteToTeamAPIView(generics.CreateAPIView): 34 | """ 35 | Endpoint to invite people to a team. 36 | 37 | """ 38 | 39 | permission_classes = (permissions.IsAuthenticated, ) 40 | authentication_classes = (TokenAuthentication, ) 41 | serializer_class = serializers.TeamInvitationCreateSerializer 42 | queryset = TeamInvitation.objects.all() 43 | 44 | def post(self, request, *args, **kwargs): 45 | serializer = self.serializer_class(data=request.data, 46 | context={ 47 | 'user': request.user, 48 | 'team_pk': kwargs['pk'] 49 | }) 50 | if serializer.is_valid(raise_exception=True): 51 | email_ids = serializer.validated_data.get('emails') 52 | self.create_invitations(email_ids=email_ids, invited_by=request.user) 53 | return Response(serializer.data, status=status.HTTP_200_OK) 54 | 55 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 56 | 57 | def create_invitations(self, email_ids, invited_by): 58 | invitations = [TeamInvitation(email=email_id, invited_by=invited_by) 59 | for email_id in email_ids] 60 | invitations = TeamInvitation.objects.bulk_create(invitations) 61 | self.send_email_invites(invitations) 62 | 63 | def send_email_invites(self, invitations): 64 | # Sending email expected to be done asynchronously in production environment. 65 | for invitation in invitations: 66 | invitation.send_email_invite(get_current_site(self.request)) 67 | 68 | 69 | -------------------------------------------------------------------------------- /teams/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2017-05-07 09:43 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | import teams.models 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [ 16 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='Team', 22 | fields=[ 23 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 24 | ('timestamp_created', models.DateTimeField(auto_now_add=True)), 25 | ('timestamp_updated', models.DateTimeField(auto_now=True)), 26 | ('name', models.CharField(max_length=255)), 27 | ('description', models.TextField()), 28 | ('members', models.ManyToManyField(related_name='team', to=settings.AUTH_USER_MODEL)), 29 | ('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_teams', to=settings.AUTH_USER_MODEL)), 30 | ], 31 | options={ 32 | 'verbose_name': 'team', 33 | 'verbose_name_plural': 'teams', 34 | }, 35 | ), 36 | migrations.CreateModel( 37 | name='TeamInvitation', 38 | fields=[ 39 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 40 | ('timestamp_created', models.DateTimeField(auto_now_add=True)), 41 | ('timestamp_updated', models.DateTimeField(auto_now=True)), 42 | ('email', models.EmailField(max_length=254)), 43 | ('code', models.CharField(default=teams.models.generate_invite_code, max_length=25)), 44 | ('status', models.IntegerField(choices=[(0, b'PENDING'), (1, b'ACCEPTED'), (2, b'DECLINED'), (4, b'EXPIRED')], default=0)), 45 | ('invited_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invitations_sent', to=settings.AUTH_USER_MODEL)), 46 | ], 47 | options={ 48 | 'verbose_name': 'team invitation', 49 | 'verbose_name_plural': 'team invitations', 50 | }, 51 | ), 52 | migrations.AlterUniqueTogether( 53 | name='teaminvitation', 54 | unique_together=set([('email', 'code')]), 55 | ), 56 | ] 57 | -------------------------------------------------------------------------------- /teams/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beingaskar/django-rest-framework-user-registration/db7e623b4291b034a14902ba764b5fa77ed9db3a/teams/migrations/__init__.py -------------------------------------------------------------------------------- /teams/models.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import uuid 3 | import datetime 4 | from django.conf import settings 5 | from django.db import models 6 | from django.template.loader import render_to_string 7 | from django.core.mail import EmailMultiAlternatives 8 | from django.core.exceptions import ObjectDoesNotExist 9 | from django.contrib.auth import get_user_model 10 | from django.utils import timezone 11 | 12 | from base import models as base_models 13 | 14 | User = get_user_model() 15 | 16 | 17 | class TeamManager(models.Manager): 18 | """ 19 | Custom manager for Team model. 20 | 21 | The methods defined here provide shortcuts on a team level 22 | such as checking if user has permission to create team. 23 | 24 | """ 25 | 26 | def has_create_permission(self, user): 27 | """ 28 | Logic for user permissions to create a team. 29 | Returns a boolean ``True`` if user is not in a team, otherwise ``False``. 30 | 31 | """ 32 | 33 | return False if user.team.all().exists() else True 34 | 35 | 36 | class Team(base_models.TimeStampedModel): 37 | """ 38 | A model that stores the team related data such as name, 39 | description, owner, members. 40 | 41 | """ 42 | 43 | name = models.CharField( 44 | max_length=255 45 | ) 46 | 47 | description = models.TextField() 48 | 49 | owner = models.ForeignKey( 50 | User, 51 | related_name='owned_teams', 52 | null=True, 53 | blank=False, 54 | on_delete=models.SET_NULL 55 | ) 56 | 57 | members = models.ManyToManyField( 58 | User, 59 | related_name='team' 60 | ) 61 | 62 | objects = TeamManager() 63 | 64 | class Meta: 65 | verbose_name = u'team' 66 | verbose_name_plural = u'teams' 67 | 68 | def __str__(self): 69 | return str(self.name) 70 | 71 | def has_invite_permissions(self, user): 72 | """ 73 | Logic to check whether give user has invite permissions on team. 74 | Returns a boolean ``True`` if user is owner of team, otherwise ``False``. 75 | 76 | """ 77 | 78 | if self.owner == user: 79 | return True 80 | return False 81 | 82 | 83 | def generate_invite_code(): 84 | """ 85 | Generates a referral code for inviting people to team. 86 | 87 | """ 88 | 89 | return base64.urlsafe_b64encode(uuid.uuid1().bytes.encode("base64").rstrip())[:25] 90 | 91 | 92 | class TeamInvitationManager(models.Manager): 93 | """ 94 | Custom manager for the ``TeamInvitation`` model. 95 | 96 | The methods defined here provide shortcuts for validating 97 | invite code, accept/ decline invitations etc. 98 | 99 | """ 100 | 101 | def validate_code(self, email, value): 102 | """ 103 | Validates the invite code with the email address. 104 | Returns the ``TeamInvitation`` on success, otherwise ``None``. 105 | 106 | """ 107 | 108 | try: 109 | invitation = self.get( 110 | email=email, code=value, status=TeamInvitation.PENDING 111 | ) 112 | except ObjectDoesNotExist: 113 | return None 114 | return invitation 115 | 116 | def accept_invitation(self, invitation): 117 | """ 118 | Accepts the invitation. 119 | Returns a boolean ``True`` on success, otherwise ``False``. 120 | 121 | """ 122 | 123 | if invitation.status == TeamInvitation.PENDING: 124 | invitation.status = TeamInvitation.ACCEPTED 125 | invitation.save() 126 | return True 127 | return False 128 | 129 | def decline_pending_invitations(self, email_ids): 130 | """ 131 | Declines all pending invitations for given email addresses. 132 | 133 | """ 134 | 135 | self.filter( 136 | email__in=email_ids, 137 | status=TeamInvitation.PENDING 138 | ).update( 139 | status=TeamInvitation.DECLINED 140 | ) 141 | 142 | def expired(self): 143 | """ 144 | Returns the list of expired ``TeamInvitation`` 145 | 146 | """ 147 | 148 | now = timezone.now() if settings.USE_TZ else datetime.datetime.now() 149 | 150 | return self.filter( 151 | models.Q(status=TeamInvitation.PENDING) 152 | ).filter( 153 | timestamp_created__lt=now - datetime.timedelta( 154 | getattr(settings, 'INVITATION_VALIDITY_DAYS', 7) 155 | ) 156 | ) 157 | 158 | def expire_invitations(self): 159 | """ 160 | Deletes all the instances of expired ``TeamInvitation``. 161 | 162 | """ 163 | 164 | invitations = self.expired() 165 | invitations.update(status=TeamInvitation.EXPIRED) 166 | 167 | 168 | class TeamInvitation(base_models.TimeStampedModel): 169 | """ 170 | A model that stores the team invitation related data such as 171 | email, invite_code, invited_by, status etc. 172 | """ 173 | 174 | PENDING = 0 175 | ACCEPTED = 1 176 | DECLINED = 2 177 | EXPIRED = 4 178 | 179 | STATUS_CHOICES = ( 180 | (PENDING, 'PENDING'), 181 | (ACCEPTED, 'ACCEPTED'), 182 | (DECLINED, 'DECLINED'), 183 | (EXPIRED, 'EXPIRED'), 184 | ) 185 | 186 | invited_by = models.ForeignKey( 187 | User, 188 | related_name='invitations_sent', 189 | null=True, 190 | blank=False, 191 | on_delete=models.SET_NULL 192 | ) 193 | 194 | email = models.EmailField() 195 | 196 | code = models.CharField( 197 | max_length=25, 198 | default=generate_invite_code 199 | ) 200 | 201 | status = models.IntegerField( 202 | choices=STATUS_CHOICES, 203 | default=0 204 | ) 205 | 206 | objects = TeamInvitationManager() 207 | 208 | class Meta: 209 | unique_together = ('email', 'code',) 210 | verbose_name = u'team invitation' 211 | verbose_name_plural = u'team invitations' 212 | 213 | def __str__(self): 214 | return "To : %s | From %s" % (self.email, self.invited_by) 215 | 216 | def send_email_invite(self, site): 217 | """ 218 | Send a team invitation email to person referred by ``email`` 219 | """ 220 | 221 | context = { 222 | 'site': site, 223 | 'site_name': getattr(settings, 'SITE_NAME', None), 224 | 'code': self.code, 225 | 'invited_by': self.invited_by, 226 | 'team': self.invited_by.team.last(), 227 | 'email': self.email 228 | } 229 | 230 | subject = render_to_string( 231 | 'invitation_team/invitation_team_subject.txt', context 232 | ) 233 | 234 | subject = ''.join(subject.splitlines()) 235 | 236 | message = render_to_string( 237 | 'invitation_team/invitation_team_content.txt', context 238 | ) 239 | 240 | msg = EmailMultiAlternatives(subject, "", settings.DEFAULT_FROM_EMAIL, [self.email]) 241 | msg.attach_alternative(message, "text/html") 242 | msg.send() 243 | -------------------------------------------------------------------------------- /templates/invitation_team/invitation_team_content.txt: -------------------------------------------------------------------------------- 1 | You're invited to join the {{ team.name }} team on {{ site_name }} 2 |

3 | {{ invited_by.first_name }} {{ invited_by.last_name }} ({{ invited_by.email }}) sent you this invitation. 4 |

5 | You need to create another account even though you've used {{ site_name }} before with this email address. 6 |

7 | You may copy/paste this link into your browser: http://{{ site }}{% url 'register' %}?referer={{ code }} 8 |

9 | Your sign-in email is: 10 | {{ email }} 11 |

12 | Your invite code is: {{ code }} 13 |

14 | Thanks for using our site! 15 |

16 | The {{ site_name }} team -------------------------------------------------------------------------------- /templates/invitation_team/invitation_team_subject.txt: -------------------------------------------------------------------------------- 1 | {{ invited_by.first_name }} {{ invited_by.last_name }} has invited you to join {{ team.name }} team on {{ site_name }} -------------------------------------------------------------------------------- /templates/password_reset/password_reset_email_content.txt: -------------------------------------------------------------------------------- 1 | Dear {{ user.first_name }} {{ user.last_name }} 2 |

3 | You're receiving this email because you requested a password reset for your user account at {{ site_name }}. 4 |

5 | Please go to the following page and choose a new password: 6 |
7 | http://{{site}}{% url 'password_reset_confirm' uidb64=uid token=token %} 8 |

9 | Your username, in case you've forgotten: {{ user.get_username }} 10 |

11 | Thanks for using our site! 12 |

13 | The {{ site_name }} team -------------------------------------------------------------------------------- /templates/password_reset/password_reset_email_subject.txt: -------------------------------------------------------------------------------- 1 | Reset your {{ site_name }} password -------------------------------------------------------------------------------- /templates/registration/activation_email_content.txt: -------------------------------------------------------------------------------- 1 | Dear {{ user.first_name }} {{ user.last_name }} 2 |

3 | Thanks for signing up for {{ site_name }}. 4 |
5 | Please click here to activate your account. 6 |
7 | If the link doesn't work, please copy - paste the following link to your browser - http://{{ site }}{% url 'email_verify' verification_key=verification_key %} 8 |
9 | If you didn't sign up for the service, please ignore this email. 10 |

11 | Have a good time! 12 |
13 | Team {{ site_name }} 14 | -------------------------------------------------------------------------------- /templates/registration/activation_email_subject.txt: -------------------------------------------------------------------------------- 1 | Activate Your {{site_name}} account --------------------------------------------------------------------------------