Overhauled entire project config, added notifications, email templates, optimized stripe subscriptions, redis caching, and webdriver utilities

This commit is contained in:
Keannu Christian Bernasol 2024-05-10 23:15:29 +08:00
parent 7cbe8fd720
commit 99dfcef67b
84 changed files with 4300 additions and 867 deletions

View file

@ -6,7 +6,7 @@ from .models import CustomUser
class CustomUserAdmin(UserAdmin):
model = CustomUser
list_display = ('id',) + UserAdmin.list_display
list_display = ('id', 'is_active', 'user_group',) + UserAdmin.list_display
# Editable fields per instance
fieldsets = UserAdmin.fieldsets + (
(None, {'fields': ('avatar',)}),

View file

@ -4,3 +4,6 @@ from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'accounts'
def ready(self):
import accounts.signals

View file

@ -1,46 +1,50 @@
# Generated by Django 5.0.1 on 2024-01-06 04:34
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
import django_resized.forms
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='CustomUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('avatar', django_resized.forms.ResizedImageField(crop=None, force_format='WEBP', keep_meta=True, null=True, quality=100, scale=None, size=[1920, 1080], upload_to='avatars/')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]
# Generated by Django 5.0.6 on 2024-05-10 06:37
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
import django_resized.forms
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('user_groups', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='CustomUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('avatar', django_resized.forms.ResizedImageField(crop=None, force_format='WEBP', keep_meta=True, null=True, quality=100, scale=None, size=[1920, 1080], upload_to='avatars/')),
('onboarding', models.BooleanField(default=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_group', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='user_groups.usergroup')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

View file

@ -1,10 +1,9 @@
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.urls import reverse
from django.db.models.signals import post_migrate
from django.dispatch import receiver
from django_resized import ResizedImageField
import os
from django.utils import timezone
from datetime import timedelta
class CustomUser(AbstractUser):
@ -18,65 +17,44 @@ class CustomUser(AbstractUser):
avatar = ResizedImageField(
null=True, force_format="WEBP", quality=100, upload_to='avatars/')
def avatar_url(self):
return f'/api/v1/media/avatars/{self.avatar.field.storage.name(self.avatar.path)}'
# Used for onboarding processes
# Set this to False later on once the user makes actions
onboarding = models.BooleanField(default=True)
user_group = models.ForeignKey(
'user_groups.UserGroup', on_delete=models.SET_NULL, null=True)
@property
def group_member(self):
if self.user_group:
return True
else:
return False
# Can be used to show tooltips for newer users
@property
def is_new(self):
current_date = timezone.now()
return self.date_joined + timedelta(days=1) < current_date
@property
def full_name(self):
return f"{self.first_name} {self.last_name}"
@property
def group_member(self):
if self.user_group:
return True
else:
return False
@property
def group_owner(self):
if self.user_group and self == self.user_group.owner:
return True
else:
return False
@property
def admin_url(self):
return reverse('admin:users_customuser_change', args=(self.pk,))
pass
@receiver(post_migrate)
def create_superuser(sender, **kwargs):
if sender.name == 'accounts':
# Add test users here if needed
# They will automatically be created after migrating the db
users = [
# Superadmin Account
{
'username': os.getenv('DJANGO_ADMIN_USERNAME'),
'email': os.getenv('DJANGO_ADMIN_EMAIL'),
'password': os.getenv('DJANGO_ADMIN_PASSWORD'),
'is_staff': True,
'is_superuser': True,
'first_name': 'Super',
'last_name': 'Admin'
},
# Debug User
{
'username': 'debug-user',
'email': os.getenv('DJANGO_ADMIN_EMAIL'),
'password': os.getenv('DJANGO_ADMIN_PASSWORD'),
'is_staff': False,
'is_superuser': False,
'first_name': "Test",
'last_name': "User"
},
]
for user in users:
if not CustomUser.objects.filter(username=user['username']).exists():
if (user['is_superuser']):
USER = CustomUser.objects.create_superuser(
username=user['username'],
password=user['password'],
email=user['email'],
)
print('Created Superuser:', user['username'])
else:
USER = CustomUser.objects.create_user(
username=user['username'],
password=user['password'],
email=user['email'],
)
print('Created User:', user['username'])
USER.first_name = user['first_name']
USER.last_name = user['last_name']
USER.is_active = True
USER.save()

View file

@ -1,52 +1,81 @@
from djoser.serializers import UserCreateSerializer as BaseUserRegistrationSerializer
from djoser.serializers import UserSerializer as BaseUserSerializer
from django.core import exceptions as django_exceptions
from rest_framework.serializers import ModelSerializer
from rest_framework import serializers
from accounts.models import CustomUser
from drf_extra_fields.fields import Base64ImageField
from user_groups.serializers import SimpleUserGroupSerializer
from django.core.cache import cache
from django.core import exceptions as django_exceptions
from rest_framework.settings import api_settings
from django.contrib.auth.password_validation import validate_password
from django.utils.encoding import smart_str
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes
from drf_extra_fields.fields import Base64ImageField
# There can be multiple subject instances with the same name, only differing in course, year level, and semester. We filter them here
class SimpleCustomUserSerializer(ModelSerializer):
class Meta(BaseUserSerializer.Meta):
model = CustomUser
fields = ('id', 'username', 'email', 'full_name')
class CustomUserSerializer(BaseUserSerializer):
avatar = Base64ImageField()
class Meta(BaseUserSerializer.Meta):
model = CustomUser
fields = ('id', 'username', 'email', 'avatar', 'first_name',
'last_name')
'last_name', 'user_group', 'group_member', 'group_owner')
read_only_fields = ('id', 'username', 'email', 'user_group',
'group_member', 'group_owner')
def to_representation(self, instance):
representation = super().to_representation(instance)
representation['user_group'] = SimpleUserGroupSerializer(
instance.user_group, many=False).data
return representation
def update(self, instance, validated_data):
cache.delete(f'user:{instance.id}')
return super().update(instance, validated_data)
class UserRegistrationSerializer(serializers.ModelSerializer):
email = serializers.EmailField(required=True)
username = serializers.CharField(required=True)
password = serializers.CharField(
write_only=True, style={'input_type': 'password', 'placeholder': 'Password'})
first_name = serializers.CharField(
required=True, allow_blank=False, allow_null=False)
last_name = serializers.CharField(
required=True, allow_blank=False, allow_null=False)
class Meta:
model = CustomUser # Use your custom user model here
fields = ('username', 'email', 'password', 'avatar',
'first_name', 'last_name')
model = CustomUser
fields = ['email', 'username', 'password',
'first_name', 'last_name']
def validate(self, attrs):
user = self.Meta.model(**attrs)
user_attrs = attrs.copy()
user = self.Meta.model(**user_attrs)
password = attrs.get("password")
try:
validate_password(password, user)
except django_exceptions.ValidationError as e:
serializer_error = serializers.as_serializer_error(e)
errors = serializer_error[api_settings.NON_FIELD_ERRORS_KEY]
if len(errors) > 1:
raise serializers.ValidationError({"password": errors[0]})
else:
raise serializers.ValidationError({"password": errors})
if self.Meta.model.objects.filter(username=attrs.get("username")).exists():
raise serializers.ValidationError(
{"password": serializer_error[api_settings.NON_FIELD_ERRORS_KEY]}
)
"A user with that username already exists.")
return super().validate(attrs)
def create(self, validated_data):
user = self.Meta.model(**validated_data)
user.username = validated_data['username']
user.is_active = False
user.set_password(validated_data['password'])
user.save()

103
backend/accounts/signals.py Normal file
View file

@ -0,0 +1,103 @@
from django.db.models.signals import post_migrate
from django.dispatch import receiver
from config.settings import SEED_DATA, ROOT_DIR, get_secret
from django_celery_beat.models import PeriodicTask, CrontabSchedule
from .models import CustomUser
import os
import json
# Function to fill in users table with test data on dev/staging
@receiver(post_migrate)
def create_users(sender, **kwargs):
if sender.name == "accounts":
with open(os.path.join(ROOT_DIR, 'seed_data.json'), "r") as f:
seed_data = json.loads(f.read())
for user in seed_data['users']:
USER = CustomUser.objects.filter(
email=user['email']).first()
if not USER:
if user['password'] == 'USE_REGULAR':
password = get_secret('SEED_DATA_PASSWORD')
elif user['password'] == 'USE_ADMIN':
password = get_secret('DJANGO_ADMIN_PASSWORD')
else:
password = user['password']
if (user['is_superuser'] == True):
# Admin users are created regardless of SEED_DATA value
USER = CustomUser.objects.create_superuser(
username=user['username'],
email=user['email'],
password=password,
)
print('Created Superuser:', user['email'])
else:
# Only create non-admin users if SEED_DATA=True
if SEED_DATA:
USER = CustomUser.objects.create_user(
username=user['email'],
email=user['email'],
password=password,
)
print('Created User:', user['email'])
USER.first_name = user['first_name']
USER.last_name = user['last_name']
USER.is_active = True
USER.save()
@receiver(post_migrate)
def create_celery_beat_schedules(sender, **kwargs):
if sender.name == "django_celery_beat":
with open(os.path.join(ROOT_DIR, 'seed_data.json'), "r") as f:
seed_data = json.loads(f.read())
# Creating Schedules
for schedule in seed_data['schedules']:
if schedule['type'] == 'crontab':
# Check if Schedule already exists
SCHEDULE = CrontabSchedule.objects.filter(minute=schedule['minute'],
hour=schedule['hour'],
day_of_week=schedule['day_of_week'],
day_of_month=schedule['day_of_month'],
month_of_year=schedule['month_of_year'],
timezone=schedule['timezone']
).first()
# If it does not exist, create a new Schedule
if not SCHEDULE:
SCHEDULE = CrontabSchedule.objects.create(
minute=schedule['minute'],
hour=schedule['hour'],
day_of_week=schedule['day_of_week'],
day_of_month=schedule['day_of_month'],
month_of_year=schedule['month_of_year'],
timezone=schedule['timezone']
)
print(
f'Created Crontab Schedule for Hour:{SCHEDULE.hour},Minute:{SCHEDULE.minute}')
else:
print(
f'Crontab Schedule for Hour:{SCHEDULE.hour},Minute:{SCHEDULE.minute} already exists')
for task in seed_data['scheduled_tasks']:
TASK = PeriodicTask.objects.filter(name=task['name']).first()
if not TASK:
if task['schedule']['type'] == 'crontab':
SCHEDULE = CrontabSchedule.objects.filter(minute=task['schedule']['minute'],
hour=task['schedule']['hour'],
day_of_week=task['schedule']['day_of_week'],
day_of_month=task['schedule']['day_of_month'],
month_of_year=task['schedule']['month_of_year'],
timezone=task['schedule']['timezone']
).first()
TASK = PeriodicTask.objects.create(
crontab=SCHEDULE,
name=task['name'],
task=task['task'],
enabled=task['enabled']
)
print(f'Created Periodic Task: {TASK.name}')
else:
raise Exception('Schedule for Periodic Task not found')
else:
print(f'Periodic Task: {TASK.name} already exists')

23
backend/accounts/tasks.py Normal file
View file

@ -0,0 +1,23 @@
from celery import shared_task
@shared_task
def get_paying_users():
from subscriptions.models import UserSubscription
# Get a list of user subscriptions
active_subscriptions = UserSubscription.objects.filter(
valid=True).distinct('user')
# Get paying users
active_users = []
# Paying regular users
active_users += [
subscription.user.id for subscription in active_subscriptions if subscription.user is not None and subscription.user.user_group is None]
# Paying users within groups
active_users += [
subscription.user_group.members for subscription in active_subscriptions if subscription.user_group is not None and subscription.user is None]
# Return paying users
return active_users

View file

@ -1,7 +1,12 @@
from django.contrib import admin
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from accounts import views
router = DefaultRouter()
router.register(r'users', views.CustomUserViewSet, basename='users')
urlpatterns = [
path('', include(router.urls)),
path('', include('djoser.urls')),
path('', include('djoser.urls.jwt')),
]

View file

@ -0,0 +1,44 @@
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
import re
class UppercaseValidator(object):
def validate(self, password, user=None):
if not re.findall('[A-Z]', password):
raise ValidationError(
_("The password must contain at least 1 uppercase letter (A-Z)."))
def get_help_text(self):
return _("Your password must contain at least 1 uppercase letter (A-Z).")
class LowercaseValidator(object):
def validate(self, password, user=None):
if not re.findall('[a-z]', password):
raise ValidationError(
_("The password must contain at least 1 lowercase letter (a-z)."))
def get_help_text(self):
return _("Your password must contain at least 1 lowercase letter (a-z).")
class SpecialCharacterValidator(object):
def validate(self, password, user=None):
if not re.findall('[@#$%^&*()_+/\<>;:!?]', password):
raise ValidationError(
_("The password must contain at least 1 special character (@, #, $, etc.)."))
def get_help_text(self):
return _("Your password must contain at least 1 special character (@, #, $, etc.).")
class NumberValidator(object):
def validate(self, password, user=None):
if not any(char.isdigit() for char in password):
raise ValidationError(
_("The password must contain at least one numerical digit (0-9)."))
def get_help_text(self):
return _("Your password must contain at least numerical digit (0-9).")

View file

@ -1,5 +1,118 @@
from rest_framework.permissions import IsAuthenticated
from rest_framework import generics
from accounts.serializers import CustomUserSerializer
from rest_framework.response import Response
from rest_framework import status
from accounts.models import CustomUser
from accounts import serializers
from rest_framework.decorators import action
from rest_framework.response import Response
from djoser.conf import settings
from djoser.views import UserViewSet as DjoserUserViewSet
from django.contrib.auth.tokens import default_token_generator
from djoser import signals
from djoser.compat import get_user_email
from django.core.cache import cache
from rest_framework.permissions import IsAuthenticated
class CustomUserViewSet(DjoserUserViewSet):
queryset = CustomUser.objects.all()
serializer_class = serializers.CustomUserSerializer
permission_classes = settings.PERMISSIONS.activation
token_generator = default_token_generator
def get_queryset(self):
user = self.request.user
# If user is admin, show all active users
if user.is_superuser:
key = 'users'
# Get cache
queryset = cache.get(key)
# Set cache if stale or does not exist
if not queryset:
queryset = CustomUser.objects.filter(is_active=True)
cache.set(key, queryset, 60*60)
return queryset
elif not user.user_group:
key = f'user:{user.id}'
queryset = cache.get(key)
if not queryset:
queryset = CustomUser.objects.filter(is_active=True)
cache.set(key, queryset, 60*60)
return queryset
elif user.user_group:
key = f'usergroup_users:{user.user_group.id}'
queryset = cache.get(key)
if not queryset:
queryset = CustomUser.objects.filter(
user_group=user.user_group)
cache.set(key, queryset, 60*60)
return queryset
else:
return CustomUser.objects.none()
def perform_update(self, serializer, *args, **kwargs):
user = self.request.user
# Clear cache
cache.delete(f'users')
cache.delete(f'user:{user.id}')
if user.user_group:
cache.delete(f'usergroup_users:{user.user_group.id}')
super().perform_update(serializer, *args, **kwargs)
user = serializer.instance
signals.user_updated.send(
sender=self.__class__, user=user, request=self.request
)
if settings.SEND_ACTIVATION_EMAIL and not user.is_active:
context = {"user": user}
to = [get_user_email(user)]
settings.EMAIL.activation(self.request, context).send(to)
def perform_create(self, serializer, *args, **kwargs):
user = serializer.save(*args, **kwargs)
# Silently error out if email sending fails
try:
signals.user_registered.send(
sender=self.__class__, user=user, request=self.request
)
context = {"user": user}
to = [get_user_email(user)]
if settings.SEND_ACTIVATION_EMAIL:
settings.EMAIL.activation(self.request, context).send(to)
elif settings.SEND_CONFIRMATION_EMAIL:
settings.EMAIL.confirmation(self.request, context).send(to)
# Clear cache
cache.delete('users')
cache.delete(f'user:{user.id}')
if user.user_group:
cache.delete(f'usergroup_users:{user.user_group.id}')
except Exception as e:
print('Warning: Unable to send email')
print(e)
@action(methods=['post'], detail=False, url_path='activation', url_name='activation')
def activation(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.user
user.is_active = True
user.save()
# Construct a response with user's first name, last name, and email
user_data = {
'first_name': user.first_name,
'last_name': user.last_name,
'email': user.email,
'username': user.username
}
# Clear cache
cache.delete('users')
cache.delete(f'user:{user.id}')
if user.user_group:
cache.delete(f'usergroup_users:{user.user_group.id}')
return Response(user_data, status=status.HTTP_200_OK)