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

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

4
.dockerignore Normal file
View file

@ -0,0 +1,4 @@
firefox/
chrome/
dumps/
media/

60
.env.sample Normal file
View file

@ -0,0 +1,60 @@
# Django
### Use https://djecrety.ir/ for generation!
SECRET_KEY = ''
# Production Switches
BACKEND_DEBUG = 'True'
# Superuser Credentials
DJANGO_ADMIN_USERNAME = 'admin'
DJANGO_ADMIN_EMAIL = 'admin@drf-template.com'
DJANGO_ADMIN_PASSWORD = ''
# Seed Data Credentials
SEED_DATA = 'True'
SEED_DATA_PASSWORD = '12345'
# Email Credentials
EMAIL_HOST = 'inbucket'
EMAIL_HOST_USER = ''
EMAIL_HOST_PASSWORD = ''
EMAIL_PORT = '1025'
EMAIL_USE_TLS = 'False'
EMAIL_ADDRESS = 'noreply-testing@drf-template.com'
# Database
### Have different credentials set on dev, staging, and prod!
DB_DATABASE = 'drf-template'
DB_USERNAME = 'root'
DB_PASSWORD = ''
DB_HOST = 'postgres'
DB_PORT = '5432'
DB_SSL_MODE = 'disable'
# Redis
### Used for DB cache and Celery broker
REDIS_HOST = 'redis'
REDIS_PORT = '6379'
# Celery
CELERY_BROKER = 'redis://redis:6379/0'
CELERY_RESULT_BACKEND = 'redis://redis:6379/0'
# Stripe
STRIPE_SECRET_KEY = ''
STRIPE_SECRET_WEBHOOK = ''
BACKEND_DOMAIN = 'localhost:8000'
DOMAIN = 'localhost:4200'
USE_HTTPS = 'False'
DJANGO_PORT = '8000'
# Proxy (For Selenium)
USE_PROXY = 'False'
## IP-Whitelisted Proxy Address
PROXY_IP_WHITELIST = 'proxy-here.com:12345'
## Username/Password Proxy Address
PROXY_USER_AUTH = 'username:password@proxy-here.com:12345'
# CAPTCHA
CAPTCHA_TESTING = 'True'

13
.gitignore vendored
View file

@ -61,15 +61,14 @@ cover/
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
backend/db.sqlite3
backend/.env
backend/static/*
backend/static/
backend/media/
.env
media/*
static/
dumps/
firefox/
chrome/
# Flask stuff:
instance/

1
.prettierignore Normal file
View file

@ -0,0 +1 @@
*.html

View file

@ -1,28 +1,41 @@
ARG DOCKER_PLATFORM=$TARGETPLATFORM
FROM --platform=$DOCKER_PLATFORM python:3.11.4-bookworm
FROM python:3.11.4-bookworm
ENV PYTHONBUFFERED 1
ENV DEBIAN_FRONTEND noninteractive
# Create directory
RUN mkdir /code
# Set the working directory to /code
WORKDIR /code
# Mirror the current directory to the working directory for hotreloading
# Directory mirroring
ADD . /code/
COPY . /code/
COPY start.sh /code/
RUN chmod +x /code/start.sh
# Install pipenv
RUN pip install --no-cache-dir -r requirements.txt
# Install packages
RUN apt-get update && apt-get install -y graphviz libgraphviz-dev graphviz-dev wget zip
RUN pip3 install --upgrade pip
RUN pip3 install --no-cache-dir -r requirements.txt
# Make migrations
RUN python backend/manage.py makemigrations
# Install Chrome
ENV CHROMEDRIVER_VERSION=124.0.6367.155
RUN wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
RUN apt-get install -y ./google-chrome-stable_current_amd64.deb
# Run custom migrate
RUN python backend/manage.py migrate
# Install Chromedriver
RUN wget https://storage.googleapis.com/chrome-for-testing-public/$CHROMEDRIVER_VERSION/linux64/chromedriver-linux64.zip \
&& unzip chromedriver-linux64.zip && rm -dfr chromedriver_linux64.zip \
&& mv chromedriver-linux64/chromedriver /usr/bin/chromedriver \
&& chmod +x /usr/bin/chromedriver
# Generate DRF Spectacular Documentation
RUN python backend/manage.py spectacular --color --file backend/schema.yml
# Install Firefox and Geckodriver
RUN apt-get update && apt-get install -y firefox-esr
# Download the latest Geckodriver and install it
ENV GECKODRIVER_VERSION=latest
RUN wget -O geckodriver.tar.gz https://github.com/mozilla/geckodriver/releases/download/v0.34.0/geckodriver-v0.34.0-linux64.tar.gz
RUN tar -zxf geckodriver.tar.gz -C /usr/bin
RUN chmod +x /usr/bin/geckodriver
# Expose port 8000 for the web server
EXPOSE 8000
ENTRYPOINT [ "/code/start.sh" ]

17
Pipfile
View file

@ -17,6 +17,23 @@ psycopg2 = "*"
django-simple-history = "*"
django-unfold = "*"
django-resized = "*"
stripe = "*"
celery = "*"
selenium = "*"
undetected-chromedriver = "*"
2captcha-python = "*"
python-whois = "*"
django-celery-beat = "*"
flower = "*"
kombu = "*"
redis = "*"
django-storages = "*"
django-extensions = "*"
django-celery-results = "*"
pygraphviz = "*"
gunicorn = "*"
django-silk = "*"
django-redis = "*"
[dev-packages]

1468
Pipfile.lock generated

File diff suppressed because it is too large Load diff

18
README.md Normal file
View file

@ -0,0 +1,18 @@
## DRF-Template
This is a Django batteries-included template I personally use for my projects. This covers the following
- Emails (and templated email designs)
- Celery (For asynchronous tasks)
- Celery Beat (For scheduled tasks)
- Caching (via Redis or optionally, Memcached)
- Performance profiling (via Django Silk)
- Selenium (Optional, for webscraping with support for Chrome and Firefox drivers)
- Stripe Subscriptions (Optional, with regular and pro-rated subscription support)
- Notifications (via traditional RESTful endpoints)
## Development
- Create a copy of the `.env.sample` file and name it as `.env` in the same directory
- Populate .env with values
- Run `docker-compose up`

View file

@ -1,21 +0,0 @@
# Django
SECRET_KEY = ""
# Superuser Credentials
DJANGO_ADMIN_USERNAME = ""
DJANGO_ADMIN_EMAIL = ""
DJANGO_ADMIN_PASSWORD = ""
# Production Email Credentials
PROD_EMAIL_HOST = ""
PROD_EMAIL_HOST_USER = ""
PROD_EMAIL_HOST_PASSWORD = ""
PROD_EMAIL_PORT = ""
PROD_EMAIL_TLS = ""
# Dev Email Credentials
DEV_EMAIL_HOST = ""
DEV_EMAIL_HOST_USER = ""
DEV_EMAIL_HOST_PASSWORD = ""
DEV_EMAIL_PORT = ""

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,7 +1,8 @@
# Generated by Django 5.0.1 on 2024-01-06 04:34
# 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
@ -13,6 +14,7 @@ class Migration(migrations.Migration):
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('user_groups', '0001_initial'),
]
operations = [
@ -31,7 +33,9 @@ class Migration(migrations.Migration):
('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={

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)

View file

@ -1,14 +1,17 @@
from django.conf.urls.static import static
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import path, include
from config import settings
from config.settings import DEBUG, CLOUD, MEDIA_ROOT
urlpatterns = [
path('accounts/', include('accounts.urls')),
path('subscriptions/', include('subscriptions.urls')),
path('notifications/', include('notifications.urls')),
path('billing/', include('billing.urls')),
path('stripe/', include('payments.urls'))
]
# Media files
if settings.DEBUG:
# URLs for local development
if DEBUG and not CLOUD:
urlpatterns += staticfiles_urlpatterns()
urlpatterns += static(
'media/', document_root=settings.MEDIA_ROOT)
'media/', document_root=MEDIA_ROOT)

View file

6
backend/billing/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class BillingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "billing"

View file

7
backend/billing/urls.py Normal file
View file

@ -0,0 +1,7 @@
from django.urls import path
from billing import views
urlpatterns = [
path('',
views.BillingHistoryView.as_view()),
]

61
backend/billing/views.py Normal file
View file

@ -0,0 +1,61 @@
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from config.settings import STRIPE_SECRET_KEY
from django.core.cache import cache
from datetime import datetime
import stripe
# Make sure to set your secret key
stripe.api_key = STRIPE_SECRET_KEY
class BillingHistoryView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, *args, **kwargs):
requesting_user = self.request.user
if requesting_user.user_group:
email = requesting_user.user_group.owner.email
else:
email = requesting_user.email
# Check cache
key = f'billing_user:{requesting_user.id}'
billing_history = cache.get(key)
if not billing_history:
# List customers and filter by email
customers = stripe.Customer.list(limit=1, email=email)
if customers:
customer = customers.data[0]
# List customers and filter by email
customers = stripe.Customer.list(limit=1, email=email)
if len(customers.data) > 0:
# Retrieve the customer's charges (billing history)
charges = stripe.Charge.list(
limit=10, customer=customer.id)
# Prepare the response
billing_history = [
{
'email': charge['billing_details']['email'],
'amount_charged': int(charge['amount']/100),
'paid': charge['paid'],
'refunded': int(charge['amount_refunded']/100) > 0,
'amount_refunded': int(charge['amount_refunded']/100),
'last_4': charge['payment_method_details']['card']['last4'],
'receipt_link': charge['receipt_url'],
'timestamp': datetime.fromtimestamp(charge['created']).strftime("%m-%d-%Y %I:%M %p"),
} for charge in charges.auto_paging_iter()
]
cache.set(key, billing_history, 60*60)
return Response(billing_history, status=status.HTTP_200_OK)

View file

@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ('celery_app',)

17
backend/config/celery.py Normal file
View file

@ -0,0 +1,17 @@
from celery import Celery
import os
# Set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
app = Celery('config')
# Using a string here means the worker doesn't have to serialize
# the configuration object to child processes.
# - namespace='CELERY' means all celery-related configuration keys
# should have a `CELERY_` prefix.
app.config_from_object('django.conf:settings', namespace='CELERY')
# Load task modules from all registered Django apps.
app.autodiscover_tasks()

View file

@ -12,50 +12,91 @@ https://docs.djangoproject.com/en/4.2/ref/settings/
from datetime import timedelta
from pathlib import Path
from dotenv import load_dotenv # Python dotenv
from dotenv import load_dotenv, find_dotenv # Python dotenv
import os
load_dotenv() # loads the configs from .env
# Build paths inside the project like this: BASE_DIR / 'subdir'.
# Backend folder (/backend)
BASE_DIR = Path(__file__).resolve().parent.parent
# Root folder where docker-compose.yml is located
ROOT_DIR = Path(__file__).resolve().parent.parent.parent
# If you're hosting this on the cloud, have this set
CLOUD = bool(os.getenv('CLOUD', False))
load_dotenv(find_dotenv())
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
def get_secret(secret_name):
if CLOUD:
try:
pass
# Add specific implementations here if deploying to Azure, GCP, or AWS to get secrets
except:
secret_value = ""
else:
# Fallback to .env or system environment variables for local development
secret_value = os.getenv(secret_name)
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = str(os.getenv('SECRET_KEY'))
if secret_value is None:
raise ValueError(f"Secret '{secret_name}' not found.")
else:
return secret_value
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
# Frontend Domain
DOMAIN = get_secret('DOMAIN')
# Backend Domain
BACKEND_DOMAIN = get_secret('BACKEND_DOMAIN')
# URL Prefixes
USE_HTTPS = (get_secret('USE_HTTPS') == 'True')
URL_PREFIX = 'https://' if CLOUD and USE_HTTPS else 'http://'
BACKEND_URL = f'{URL_PREFIX}{BACKEND_DOMAIN}'
FRONTEND_URL = f'{URL_PREFIX}{DOMAIN}'
ALLOWED_HOSTS = ['*']
CSRF_TRUSTED_ORIGINS = ["https://testing.keannu1.duckdns.org"]
CSRF_TRUSTED_ORIGINS = [
BACKEND_URL,
FRONTEND_URL
]
if CLOUD:
# TODO: If you require additional URLs to be trusted in cloud service providers, add them here
CSRF_TRUSTED_ORIGINS += []
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = (get_secret('BACKEND_DEBUG') == 'True')
# Determines whether or not to insert test data within tables
SEED_DATA = (get_secret('SEED_DATA') == 'True')
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = get_secret('SECRET_KEY')
# Selenium Config
# Initiate CAPTCHA solver in test mode
CAPTCHA_TESTING = (get_secret('CAPTCHA_TESTING') == 'True')
# If using Selenium and/or the provided CAPTCHA solver, determines whether or not to use proxies
USE_PROXY = (get_secret('USE_PROXY') == 'True')
# Stripe (For payments)
STRIPE_SECRET_KEY = get_secret(
"STRIPE_SECRET_KEY")
STRIPE_SECRET_WEBHOOK = get_secret('STRIPE_SECRET_WEBHOOK')
STRIPE_CHECKOUT_URL = f''
# Email credentials
EMAIL_HOST = ''
EMAIL_HOST_USER = ''
EMAIL_HOST_PASSWORD = ''
EMAIL_PORT = ''
EMAIL_USE_TLS = False
if (DEBUG == True):
EMAIL_HOST = str(os.getenv('DEV_EMAIL_HOST'))
EMAIL_HOST_USER = str(os.getenv('DEV_EMAIL_HOST_USER'))
EMAIL_HOST_PASSWORD = str(os.getenv('DEV_EMAIL_HOST_PASSWORD'))
EMAIL_PORT = str(os.getenv('DEV_EMAIL_PORT'))
else:
EMAIL_HOST = str(os.getenv('PROD_EMAIL_HOST'))
EMAIL_HOST_USER = str(os.getenv('PROD_EMAIL_HOST_USER'))
EMAIL_HOST_PASSWORD = str(os.getenv('PROD_EMAIL_HOST_PASSWORD'))
EMAIL_PORT = str(os.getenv('PROD_EMAIL_PORT'))
EMAIL_USE_TLS = str(os.getenv('PROD_EMAIL_TLS'))
EMAIL_HOST = get_secret('EMAIL_HOST')
EMAIL_HOST_USER = get_secret('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = get_secret('EMAIL_HOST_PASSWORD')
EMAIL_PORT = get_secret('EMAIL_PORT')
EMAIL_USE_TLS = get_secret('EMAIL_USE_TLS')
EMAIL_ADDRESS = (get_secret('EMAIL_ADDRESS') == 'True')
# Application definition
INSTALLED_APPS = [
'silk',
'config',
'unfold',
'unfold.contrib.filters',
'unfold.contrib.simple_history',
'django.contrib.admin',
'django.contrib.auth',
@ -63,20 +104,32 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'storages',
'django_extensions',
'rest_framework',
'rest_framework_simplejwt',
'django_celery_results',
'django_celery_beat',
'simple_history',
'djoser',
'corsheaders',
'drf_spectacular',
'drf_spectacular_sidecar',
'webdriver',
'accounts',
'user_groups',
'subscriptions',
'payments',
'billing',
'emails',
'notifications'
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
"whitenoise.middleware.WhiteNoiseMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware',
"silk.middleware.SilkyMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"corsheaders.middleware.CorsMiddleware",
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
@ -88,17 +141,50 @@ MIDDLEWARE = [
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = 'static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
MEDIA_URL = 'api/v1/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
ROOT_URLCONF = 'config.urls'
if CLOUD:
# Cloud Storage Settings
CLOUD_BUCKET = get_secret('CLOUD_BUCKET')
CLOUD_BUCKET_CONTAINER = get_secret('CLOUD_BUCKET_CONTAINER')
CLOUD_STATIC_CONTAINER = get_secret('CLOUD_STATIC_CONTAINER')
MEDIA_URL = f'https://{CLOUD_BUCKET}/{CLOUD_BUCKET_CONTAINER}/'
MEDIA_ROOT = f'https://{CLOUD_BUCKET}/'
STATIC_URL = f'https://{CLOUD_BUCKET}/{CLOUD_STATIC_CONTAINER}/'
STATIC_ROOT = f'https://{CLOUD_BUCKET}/{CLOUD_STATIC_CONTAINER}/'
# Consult django-storages documentation when filling in these values. This will vary depending on your cloud service provider
STORAGES = {
'default': {
# TODO: Set this up here if you're using cloud storage
'BACKEND': None,
'OPTIONS': {
# Optional parameters
},
},
'staticfiles': {
# TODO: Set this up here if you're using cloud storage
'BACKEND': None,
'OPTIONS': {
# Optional parameters
},
},
}
else:
STATIC_URL = 'static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
MEDIA_URL = 'api/v1/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'DIRS': [
BASE_DIR / 'templates',
],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@ -135,26 +221,30 @@ REST_FRAMEWORK = {
# DRF-Spectacular
SPECTACULAR_SETTINGS = {
'TITLE': 'Test Backend',
'DESCRIPTION': 'A Project by Keannu Bernasol',
'TITLE': 'DRF-Template',
'DESCRIPTION': 'A Template Project by Keannu Bernasol',
'VERSION': '1.0.0',
'SERVE_INCLUDE_SCHEMA': False,
'SWAGGER_UI_DIST': 'SIDECAR',
'SWAGGER_UI_FAVICON_HREF': 'SIDECAR',
'REDOC_DIST': 'SIDECAR',
# OTHER SETTINGS
}
WSGI_APPLICATION = 'config.wsgi.application'
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
"default": {
"ENGINE": "django.db.backends.postgresql",
# Have this set to True if you're using a connection bouncer
'DISABLE_SERVER_SIDE_CURSORS': True,
"NAME": get_secret("DB_DATABASE"),
"USER": get_secret("DB_USERNAME"),
"PASSWORD": get_secret("DB_PASSWORD"),
"HOST": get_secret("DB_HOST"),
"PORT": get_secret("DB_PORT"),
"OPTIONS": {
"sslmode": get_secret("DB_SSL_MODE")
},
}
}
@ -166,11 +256,22 @@ DJOSER = {
'PASSWORD_RESET_CONFIRM_URL': 'reset_password_confirm/{uid}/{token}',
'ACTIVATION_URL': 'activation/{uid}/{token}',
'USER_AUTHENTICATION_RULES': ['djoser.authentication.TokenAuthenticationRule'],
'EMAIL': {
'activation': 'emails.templates.ActivationEmail',
'password_reset': 'emails.templates.PasswordResetEmail'
},
'SERIALIZERS': {
'user': 'accounts.serializers.CustomUserSerializer',
'current_user': 'accounts.serializers.CustomUserSerializer',
'user_create': 'accounts.serializers.UserRegistrationSerializer',
},
'PERMISSIONS': {
# Disable some unneeded endpoints by setting them to admin only
'username_reset': ['rest_framework.permissions.IsAdminUser'],
'username_reset_confirm': ['rest_framework.permissions.IsAdminUser'],
'set_username': ['rest_framework.permissions.IsAdminUser'],
'set_password': ['rest_framework.permissions.IsAdminUser'],
}
}
# Password validation
@ -182,6 +283,9 @@ AUTH_PASSWORD_VALIDATORS = [
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
"OPTIONS": {
"min_length": 8,
}
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
@ -189,6 +293,19 @@ AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
# Additional password validators
{
'NAME': 'accounts.validators.SpecialCharacterValidator',
},
{
'NAME': 'accounts.validators.LowercaseValidator',
},
{
'NAME': 'accounts.validators.UppercaseValidator',
},
{
'NAME': 'accounts.validators.NumberValidator',
},
]
@ -209,18 +326,61 @@ USE_TZ = True
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
DOMAIN = 'testing.keannu1.duckdns.org/#'
SITE_NAME = 'DRF-Template'
SITE_NAME = 'Test Backend'
# 1 week access token lifetime
# JWT Token Lifetimes
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=10080),
"REFRESH_TOKEN_LIFETIME": timedelta(minutes=10080)
"ACCESS_TOKEN_LIFETIME": timedelta(hours=6),
"REFRESH_TOKEN_LIFETIME": timedelta(days=3)
}
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
CORS_ALLOW_ALL_ORIGINS = True
CORS_ALLOW_CREDENTIALS = True
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"root": {
"handlers": ["console"],
"level": "DEBUG",
},
}
DJANGO_LOG_LEVEL = "DEBUG"
# Enables VS Code debugger to break on raised exceptions
DEBUG_PROPAGATE_EXCEPTIONS = "DEBUG"
# Celery Configuration Options
CELERY_TIMEZONE = TIME_ZONE
CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = 30 * 60
CELERY_BROKER_URL = get_secret("CELERY_BROKER")
CELERY_BROKER = get_secret("CELERY_BROKER")
CELERY_BACKEND = get_secret("CELERY_BROKER")
CELERY_RESULT_BACKEND = get_secret("CELERY_RESULT_BACKEND")
CELERY_RESULT_EXTENDED = True
# Celery Beat Options
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
# Maximum number of rows that can be updated within the Django admin panel
DATA_UPLOAD_MAX_NUMBER_FIELDS = 20480
GRAPH_MODELS = {
'app_labels': ['accounts', 'user_groups', 'billing', 'emails', 'payments', 'subscriptions']
}
# Django/DRF Cache
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": f"redis://{get_secret('REDIS_HOST')}:{get_secret('REDIS_PORT')}/2",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
}
}
}

View file

@ -17,6 +17,7 @@ Including another URLconf
from django.contrib import admin
from django.urls import path, include
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
from config.settings import DEBUG
urlpatterns = [
path('admin/', admin.site.urls),
@ -27,3 +28,6 @@ urlpatterns = [
path('redoc/',
SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
]
if DEBUG:
urlpatterns += [path('silk/', include('silk.urls', namespace='silk'))]

View file

6
backend/emails/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class EmailsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'emails'

View file

@ -0,0 +1,49 @@
from djoser import email
from django.utils import timezone
class ActivationEmail(email.ActivationEmail):
template_name = 'templates/email_activation.html'
class PasswordResetEmail(email.PasswordResetEmail):
template_name = 'templates/password_change.html'
class SubscriptionAvailedEmail(email.BaseEmailMessage):
template_name = "templates/subscription_availed.html"
def get_context_data(self):
context = super().get_context_data()
context["user"] = context.get("user")
context["subscription_plan"] = context.get("subscription_plan")
context["subscription"] = context.get("subscription")
context["price_paid"] = context.get("price_paid")
context['date'] = timezone.now().strftime("%B %d, %I:%M %p")
context.update(self.context)
return context
class SubscriptionRefundedEmail(email.BaseEmailMessage):
template_name = "templates/subscription_refunded.html"
def get_context_data(self):
context = super().get_context_data()
context["user"] = context.get("user")
context["subscription_plan"] = context.get("subscription_plan")
context["refund"] = context.get("refund")
context['date'] = timezone.now().strftime("%B %d, %I:%M %p")
context.update(self.context)
return context
class SubscriptionCancelledEmail(email.BaseEmailMessage):
template_name = "templates/subscription_cancelled.html"
def get_context_data(self):
context = super().get_context_data()
context["user"] = context.get("user")
context["subscription_plan"] = context.get("subscription_plan")
context['date'] = timezone.now().strftime("%B %d, %I:%M %p")
context.update(self.context)
return context

View file

@ -0,0 +1,28 @@
{% load i18n %}
{% block subject %}
{% blocktrans %}Account activation on {{ site_name }}{% endblocktrans %}
{% endblock subject %}
{% block text_body %}
{% blocktrans %}You're receiving this email because you need to finish activation process on {{ site_name }}.{% endblocktrans %}
{% trans "Please go to the following page to activate account:" %}
{{ protocol }}://{{ domain }}/{{ url|safe }}
{% trans "Thanks for using our site!" %}
{% blocktrans %}The {{ site_name }} team{% endblocktrans %}
{% endblock text_body %}
{% block html_body %}
<p>{% blocktrans %}You're receiving this email because you need to finish activation process on {{ site_name }}.{% endblocktrans %}</p>
<p>{% trans "Please go to the following page to activate account:" %}</p>
<p><a href="{{ protocol }}://{{ domain }}/{{ url|safe }}">{{ protocol }}://{{ domain }}/{{ url|safe }}</a></p>
<p>{% trans "Thanks for using our site!" %}</p>
<p>{% blocktrans %}The {{ site_name }} team{% endblocktrans %}</p>
{% endblock html_body %}

View file

@ -0,0 +1,29 @@
{% load i18n %}
{% block subject %}
{% blocktrans %}Password reset on {{ site_name }}{% endblocktrans %}
{% endblock subject %}
{% block text_body %}
{% blocktrans %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktrans %}
{% trans "Please go to the following page and choose a new password:" %}
{{ protocol }}://{{ domain }}/{{ url|safe }}
{% trans "Your username, in case you've forgotten:" %} {{ user.get_username }}
{% trans "Thanks for using our site!" %}
{% blocktrans %}The {{ site_name }} team{% endblocktrans %}
{% endblock text_body %}
{% block html_body %}
<p>{% blocktrans %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktrans %}</p>
<p>{% trans "Please go to the following page and choose a new password:" %}</p>
<a href="{{ protocol }}://{{ domain }}/{{ url|safe }}">{{ protocol }}://{{ domain }}/{{ url|safe }}</a>
<p>{% trans "Your username, in case you've forgotten:" %} <b>{{ user.get_username }}</b></p>
<p>{% trans "Thanks for using our site!" %}</p>
<p>{% blocktrans %}The {{ site_name }} team{% endblocktrans %}</p>
{% endblock html_body %}

View file

@ -0,0 +1,20 @@
{% load i18n %}
{% block subject %}
{% blocktrans %}Subscription Availed on {{ site_name }}{% endblocktrans %}
{% endblock subject %}
{% block text_body %}
{% blocktrans %}You're receiving this email because you availed the {{ subscription_plan.name }} amounting to {{ price_paid.amount }}{{ price_paid.currency}} at {{ site_name }}.{% endblocktrans %}
{% trans "Thanks for using our site!" %}
{% endblock text_body %}
{% block html_body %}
<p>{% blocktrans %}You're receiving this email because you availed the {{ subscription_plan.name }} at {{ site_name }}.{% endblocktrans %}</p>
<p>{% trans "Thanks for using our site!" %}</p>
{% endblock html_body %}

View file

@ -0,0 +1,24 @@
{% load i18n %}
{% block subject %}
{% blocktrans %}Subscription Cancelled on {{ site_name }}{% endblocktrans %}
{% endblock subject %}
{% block text_body %}
{% blocktrans %}You're receiving this email because your subscription for the {{ subscription_plan.name }} Plan has been cancelled at {{ site_name }}.{% endblocktrans %}
{% trans "Thanks for using our site!" %}
{% endblock text_body %}
{% block html_body %}
<p>
{% blocktrans %}
You're receiving this email because your subscription for the {{ subscription_plan.name }} Plan has been cancelled at {{ site_name }}.
{% endblocktrans %}
</p>
<p>{% trans "Thanks for using our site!" %}</p>
{% endblock html_body %}

View file

@ -0,0 +1,20 @@
{% load i18n %}
{% block subject %}
{% blocktrans %}Subscription Refunded on {{ site_name }}{% endblocktrans %}
{% endblock subject %}
{% block text_body %}
{% blocktrans %}You're receiving this email because your subscription for the {{ subscription_plan.name }} Plan has been refunded at {{ site_name }}. You have been refunded {{ refund.amount }}{{ refund.currency }}{% endblocktrans %}
{% trans "Thanks for using our site!" %}
{% endblock text_body %}
{% block html_body %}
<p>{% blocktrans %}You're receiving this email because your subscription for the {{ subscription_plan.name }} Plan has been refunded at {{ site_name }}. You have been refunded {{ refund.amount }}{{ refund.currency }}{% endblocktrans %}</p>
<p>{% trans "Thanks for using our site!" %}</p>
{% endblock html_body %}

View file

View file

@ -0,0 +1,10 @@
from unfold.admin import ModelAdmin
from django.contrib import admin
from .models import Notification
@admin.register(Notification)
class NotificationAdmin(ModelAdmin):
model = Notification
search_fields = ('id', 'content')
list_display = ['id', 'dismissed']

View file

@ -0,0 +1,9 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'notifications'
def ready(self):
import notifications.signals

View file

@ -0,0 +1,27 @@
# Generated by Django 5.0.6 on 2024-05-10 13:56
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.CharField(max_length=1000, null=True)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('dismissed', models.BooleanField(default=False)),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -0,0 +1,12 @@
from django.db import models
class Notification(models.Model):
recipient = models.ForeignKey(
'accounts.CustomUser', on_delete=models.CASCADE)
content = models.CharField(max_length=1000, null=True)
timestamp = models.DateTimeField(auto_now_add=True, editable=False)
dismissed = models.BooleanField(default=False)
def __str__(self):
return self.content

View file

@ -0,0 +1,12 @@
from rest_framework import serializers
from notifications.models import Notification
class NotificationSerializer(serializers.ModelSerializer):
timestamp = serializers.DateTimeField(
format="%m-%d-%Y %I:%M %p", read_only=True)
class Meta:
model = Notification
fields = '__all__'
read_only_fields = ('id', 'recipient', 'content', 'timestamp')

View file

@ -0,0 +1,13 @@
from django.dispatch import receiver
from django.db.models.signals import post_save
from notifications.models import Notification
from django.core.cache import cache
# Template for running actions after user have paid for a subscription
@receiver(post_save, sender=Notification)
def clear_cache_after_notification_update(sender, instance, **kwargs):
# Clear cache
cache.delete('notifications')
cache.delete(f'notifications_user:{instance.recipient.id}')

View file

@ -0,0 +1,13 @@
from celery import shared_task
from django.utils import timezone
from notifications.models import Notification
@shared_task
def cleanup_notifications():
# Calculate the date 3 days ago
three_days_ago = timezone.now() - timezone.timedelta(days=3)
# Delete notifications that are older than 3 days and dismissed
Notification.objects.filter(
dismissed=True, timestamp__lte=three_days_ago).delete()

View file

@ -0,0 +1,10 @@
from django.urls import path, include
from notifications.views import NotificationViewSet
from rest_framework.routers import DefaultRouter
router = DefaultRouter()
router.register(r'', NotificationViewSet,
basename="Notifications")
urlpatterns = [
path('', include(router.urls)),
]

View file

@ -0,0 +1,35 @@
from rest_framework import viewsets
from notifications.models import Notification
from notifications.serializers import NotificationSerializer
from rest_framework.exceptions import PermissionDenied
from django.core.cache import cache
class NotificationViewSet(viewsets.ModelViewSet):
http_method_names = ['get', 'patch', 'delete']
serializer_class = NotificationSerializer
queryset = Notification.objects.all()
def get_queryset(self):
user = self.request.user
key = f'notifications_user:{user.id}'
queryset = cache.get(key)
if not queryset:
queryset = Notification.objects.filter(
recipient=user).order_by('-timestamp')
cache.set(key, queryset, 60*60)
return queryset
def update(self, request, *args, **kwargs):
instance = self.get_object()
if instance.recipient != request.user:
raise PermissionDenied(
"You do not have permission to update this notification.")
return super().update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
if instance.recipient != request.user:
raise PermissionDenied(
"You do not have permission to delete this notification.")
return super().destroy(request, *args, **kwargs)

View file

6
backend/payments/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class PaymentsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'payments'

View file

View file

@ -0,0 +1,6 @@
from rest_framework import serializers
class CheckoutSerializer(serializers.Serializer):
subscription_id = serializers.IntegerField()
annual = serializers.BooleanField()

8
backend/payments/urls.py Normal file
View file

@ -0,0 +1,8 @@
from django.urls import path
from payments import views
urlpatterns = [
path('checkout_session/', views.StripeCheckoutView.as_view()),
path('webhook/', views.stripe_webhook_view, name='Stripe Webhook'),
]

398
backend/payments/views.py Normal file
View file

@ -0,0 +1,398 @@
from config.settings import STRIPE_SECRET_KEY, DOMAIN, STRIPE_SECRET_WEBHOOK, CLOUD, BACKEND_URL, FRONTEND_URL
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView
from rest_framework.response import Response
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from rest_framework import status
import logging
import stripe
from subscriptions.models import SubscriptionPlan, UserSubscription
from accounts.models import CustomUser
from rest_framework.decorators import api_view
from subscriptions.tasks import get_user_subscription
import json
from emails.templates import SubscriptionAvailedEmail, SubscriptionRefundedEmail, SubscriptionCancelledEmail
from django.core.cache import cache
from payments.serializers import CheckoutSerializer
from drf_spectacular.utils import extend_schema
stripe.api_key = STRIPE_SECRET_KEY
@extend_schema(
request=CheckoutSerializer
)
class StripeCheckoutView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
# Get subscription ID from POST
USER = CustomUser.objects.get(id=self.request.user.id)
data = json.loads(request.body)
subscription_id = data.get('subscription_id')
annual = data.get('annual')
# Validation for subscription_id field
try:
subscription_id = int(subscription_id)
except:
return Response({
'error': 'Invalid value specified in subscription_id field'
}, status=status.HTTP_403_FORBIDDEN)
# Validation for annual field
try:
annual = bool(annual)
except:
return Response({
'error': 'Invalid value specified in annual field'
}, status=status.HTTP_403_FORBIDDEN)
# Return an error if the user already has an active subscription
EXISTING_SUBSCRIPTION = get_user_subscription(USER.id)
if EXISTING_SUBSCRIPTION:
return Response({
'error': f'User is already subscribed to: {EXISTING_SUBSCRIPTION.subscription.name}'
}, status=status.HTTP_403_FORBIDDEN)
# Attempt to query the subscription
SUBSCRIPTION = SubscriptionPlan.objects.filter(
id=subscription_id).first()
# Return an error if the plan does not exist
if not SUBSCRIPTION:
return Response({
'error': 'Subscription plan not found'
}, status=status.HTTP_404_NOT_FOUND)
# Get the stripe_price_id from the related StripePrice instances
PRICE = None
PRICE_ID = None
if annual:
PRICE = SUBSCRIPTION.annual_price
PRICE_ID = PRICE.stripe_price_id
else:
PRICE = SUBSCRIPTION.monthly_price
PRICE_ID = PRICE.stripe_price_id
# Return 404 if no price is set
if not PRICE or PRICE_ID:
return Response({
'error': 'Specified price does not exist for plan'
}, status=status.HTTP_404_NOT_FOUND)
prorated = PRICE.prorated
# Return an error if a user is in a user_group and is availing pro-rated plans
if not USER.user_group and SUBSCRIPTION.group_exclusive:
return Response({
'error': 'Regular users cannot avail prorated plans'
}, status=status.HTTP_403_FORBIDDEN)
success_url = FRONTEND_URL + \
'/user/subscription/payment?success=true&agency=False&session_id={CHECKOUT_SESSION_ID}'
cancel_url = FRONTEND_URL + '/user/subscription/payment?success=false&user_group=False'
checkout_session = stripe.checkout.Session.create(
line_items=[
{
'price': PRICE_ID,
'quantity': 1
} if not prorated else
{
'price': PRICE_ID,
}
],
mode='subscription',
payment_method_types=['card'],
success_url=success_url,
cancel_url=cancel_url,
)
return Response({"url": checkout_session.url})
except Exception as e:
logging.error(str(e))
return Response({
'error': str(e)
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@ api_view(['POST'])
@ csrf_exempt
def stripe_webhook_view(request):
payload = request.body
sig_header = request.META['HTTP_STRIPE_SIGNATURE']
event = None
try:
event = stripe.Webhook.construct_event(
payload, sig_header, STRIPE_SECRET_WEBHOOK
)
except ValueError:
# Invalid payload
return Response(status=400)
except stripe.error.SignatureVerificationError:
# Invalid signature
return Response(status=401)
if event['type'] == 'customer.subscription.created':
subscription = event['data']['object']
# Get the Invoice object from the Subscription object
invoice = stripe.Invoice.retrieve(subscription['latest_invoice'])[
'data']['object']
# Get the Charge object from the Invoice object
charge = stripe.Charge.retrieve(invoice['charge'])['data']['object']
# Get paying user
customer = stripe.Customer.retrieve(subscription["customer"])
USER = CustomUser.objects.filter(email=customer.email).first()
product = subscription["items"]["data"][0]
SUBSCRIPTION_PLAN = SubscriptionPlan.objects.get(
stripe_product_id=product["plan"]["product"])
SUBSCRIPTION = UserSubscription.objects.create(
subscription=SUBSCRIPTION_PLAN,
annual=product["plan"]["interval"] == "year",
valid=True,
user=USER,
stripe_id=subscription['id'])
email = SubscriptionAvailedEmail()
paid = {
"amount": charge['amount']/100,
"currency": str(charge['currency']).upper()
}
email.context = {
"user": USER,
"subscription_plan": SUBSCRIPTION_PLAN,
"subscription": SUBSCRIPTION,
"price_paid": paid,
}
email.send(to=[customer.email])
# Clear cache
cache.delete(f'billing_user:{USER.id}')
cache.delete(f'subscriptions_user:{USER.id}')
# On chargebacks/refunds, invalidate the subscription
elif event['type'] == 'charge.refunded':
charge = event['data']['object']
# Get the Invoice object from the Charge object
invoice = stripe.Invoice.retrieve(charge['invoice'])
# Check if the subscription exists
SUBSCRIPTION = UserSubscription.objects.filter(
stripe_id=invoice['subscription']).first()
if not (SUBSCRIPTION):
return HttpResponse(status=404)
if SUBSCRIPTION.user:
USER = SUBSCRIPTION.user
# Mark refunded subscription as invalid
SUBSCRIPTION.valid = False
SUBSCRIPTION.save()
SUBSCRIPTION_PLAN = SUBSCRIPTION.subscription
refund = {
"amount": charge['amount_refunded']/100,
"currency": str(charge['currency']).upper()
}
# Send an email
email = SubscriptionRefundedEmail()
email.context = {
"user": USER,
"subscription_plan": SUBSCRIPTION_PLAN,
"refund": refund
}
email.send(to=[USER.email])
# Clear cache
cache.delete(f'billing_user:{USER.id}')
elif SUBSCRIPTION.user_group:
OWNER = SUBSCRIPTION.user_group.owner
# Mark refunded subscription as invalid
SUBSCRIPTION.valid = False
SUBSCRIPTION.save()
SUBSCRIPTION_PLAN = SUBSCRIPTION.subscription
refund = {
"amount": charge['amount_refunded']/100,
"currency": str(charge['currency']).upper()
}
# Send en email
email = SubscriptionRefundedEmail()
email.context = {
"user": OWNER,
"subscription_plan": SUBSCRIPTION_PLAN,
"refund": refund
}
email.send(to=[OWNER.email])
# Clear cache
cache.delete(f'billing_user:{USER.id}')
cache.delete(f'subscriptions_user:{USER.id}')
elif event['type'] == 'customer.subscription.updated':
subscription = event['data']['object']
# Check if the subscription exists
SUBSCRIPTION = UserSubscription.objects.filter(
stripe_id=subscription['id']).first()
if not (SUBSCRIPTION):
return HttpResponse(status=404)
# Check if a subscription has been upgraded/downgraded
new_stripe_product_id = subscription['items']['data'][0]['plan']['product']
current_stripe_product_id = SUBSCRIPTION.subscription.stripe_product_id
if new_stripe_product_id != current_stripe_product_id:
SUBSCRIPTION_PLAN = SubscriptionPlan.objects.get(
stripe_product_id=new_stripe_product_id)
SUBSCRIPTION.subscription = SUBSCRIPTION_PLAN
SUBSCRIPTION.save()
# TODO: Add a plan upgraded email message here
# Subscription activation/reactivation
if subscription['status'] == 'active':
SUBSCRIPTION.valid = True
SUBSCRIPTION.save()
if SUBSCRIPTION.user:
USER = SUBSCRIPTION.user
# Clear cache
cache.delete(f'billing_user:{USER.id}')
cache.delete(
f'subscriptions_user:{USER.id}')
elif SUBSCRIPTION.user_group:
OWNER = SUBSCRIPTION.user_group.owner
# Clear cache
cache.delete(f'billing_user:{OWNER.id}')
cache.delete(
f'subscriptions_usergroup:{SUBSCRIPTION.user_group.id}')
# TODO: Add notification here to inform users if their plan has been reactivated
elif subscription['status'] == 'past_due':
# TODO: Add notification here to inform users if their payment method for an existing subscription payment is failing
pass
# If subscriptions get cancelled due to non-payment, invalidate the UserSubscription
elif subscription['status'] == 'cancelled':
if SUBSCRIPTION.user:
USER = SUBSCRIPTION.user
# Mark refunded subscription as invalid
SUBSCRIPTION.valid = False
SUBSCRIPTION.save()
SUBSCRIPTION_PLAN = SUBSCRIPTION.subscription
# Send an email
email = SubscriptionCancelledEmail()
email.context = {
"user": USER,
"subscription_plan": SUBSCRIPTION_PLAN,
"user_group": False,
}
email.send(to=[USER.email])
# Clear cache
cache.delete(f'billing_user:{USER.id}')
cache.delete(f'subscriptions_user:{USER.id}')
elif SUBSCRIPTION.user_group:
OWNER = SUBSCRIPTION.user_group.owner
# Mark refunded subscription as invalid
SUBSCRIPTION.valid = False
SUBSCRIPTION.save()
# Send an email
email = SubscriptionCancelledEmail()
SUBSCRIPTION_PLAN = SUBSCRIPTION.subscription
email.context = {
"user": OWNER,
"subscription_plan": SUBSCRIPTION_PLAN
}
email.send(to=[OWNER.email])
# Clear cache
cache.delete(f'billing_user:{OWNER.id}')
cache.delete(
f'subscriptions_usergroup:{SUBSCRIPTION.user_group.id}')
# If a subscription gets cancelled, invalidate it
elif event['type'] == 'customer.subscription.deleted':
subscription = event['data']['object']
# Check if the subscription exists
SUBSCRIPTION = UserSubscription.objects.filter(
stripe_id=subscription['id']).first()
if not (SUBSCRIPTION):
return HttpResponse(status=404)
if SUBSCRIPTION.user:
USER = SUBSCRIPTION.user
# Mark refunded subscription as invalid
SUBSCRIPTION.valid = False
SUBSCRIPTION.save()
SUBSCRIPTION_PLAN = SUBSCRIPTION.subscription
# Send an email
email = SubscriptionCancelledEmail()
email.context = {
"user": USER,
"subscription_plan": SUBSCRIPTION_PLAN,
"user_group": False,
}
email.send(to=[USER.email])
# Clear cache
cache.delete(f'billing_user:{USER.id}')
elif SUBSCRIPTION.user_group:
OWNER = SUBSCRIPTION.user_group.owner
# Mark refunded subscription as invalid
SUBSCRIPTION.valid = False
SUBSCRIPTION.save()
# Send an email
email = SubscriptionCancelledEmail()
SUBSCRIPTION_PLAN = SUBSCRIPTION.subscription
email.context = {
"user": OWNER,
"subscription_plan": SUBSCRIPTION_PLAN
}
email.send(to=[OWNER.email])
# Clear cache
cache.delete(f'billing_user:{OWNER.id}')
# Passed signature verification
return HttpResponse(status=200)

View file

@ -1,8 +1,8 @@
openapi: 3.0.3
info:
title: Test Backend
title: DRF-Template
version: 1.0.0
description: A Project by Keannu Bernasol
description: A Template Project by Keannu Bernasol
paths:
/api/v1/accounts/jwt/create/:
post:
@ -141,6 +141,7 @@ paths:
- api
security:
- jwtAuth: []
- {}
responses:
'200':
content:
@ -173,6 +174,7 @@ paths:
required: true
security:
- jwtAuth: []
- {}
responses:
'200':
content:
@ -204,6 +206,7 @@ paths:
$ref: '#/components/schemas/PatchedCustomUser'
security:
- jwtAuth: []
- {}
responses:
'200':
content:
@ -261,6 +264,7 @@ paths:
- api
security:
- jwtAuth: []
- {}
responses:
'200':
content:
@ -286,6 +290,7 @@ paths:
required: true
security:
- jwtAuth: []
- {}
responses:
'200':
content:
@ -310,6 +315,7 @@ paths:
$ref: '#/components/schemas/PatchedCustomUser'
security:
- jwtAuth: []
- {}
responses:
'200':
content:
@ -426,7 +432,6 @@ paths:
required: true
security:
- jwtAuth: []
- {}
responses:
'200':
content:
@ -453,7 +458,6 @@ paths:
required: true
security:
- jwtAuth: []
- {}
responses:
'200':
content:
@ -513,6 +517,250 @@ paths:
schema:
$ref: '#/components/schemas/SetUsername'
description: ''
/api/v1/billing/:
get:
operationId: api_v1_billing_retrieve
tags:
- api
security:
- jwtAuth: []
responses:
'200':
description: No response body
/api/v1/notifications/:
get:
operationId: api_v1_notifications_list
tags:
- api
security:
- jwtAuth: []
- {}
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Notification'
description: ''
/api/v1/notifications/{id}/:
get:
operationId: api_v1_notifications_retrieve
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this notification.
required: true
tags:
- api
security:
- jwtAuth: []
- {}
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/Notification'
description: ''
patch:
operationId: api_v1_notifications_partial_update
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this notification.
required: true
tags:
- api
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PatchedNotification'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/PatchedNotification'
multipart/form-data:
schema:
$ref: '#/components/schemas/PatchedNotification'
security:
- jwtAuth: []
- {}
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/Notification'
description: ''
delete:
operationId: api_v1_notifications_destroy
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this notification.
required: true
tags:
- api
security:
- jwtAuth: []
- {}
responses:
'204':
description: No response body
/api/v1/stripe/checkout_session/:
post:
operationId: api_v1_stripe_checkout_session_create
tags:
- api
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Checkout'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Checkout'
multipart/form-data:
schema:
$ref: '#/components/schemas/Checkout'
required: true
security:
- jwtAuth: []
responses:
'200':
description: No response body
/api/v1/stripe/webhook/:
post:
operationId: api_v1_stripe_webhook_create
tags:
- api
security:
- jwtAuth: []
- {}
responses:
'200':
description: No response body
/api/v1/subscriptions/plans/:
get:
operationId: api_v1_subscriptions_plans_list
tags:
- api
security:
- jwtAuth: []
- {}
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/SubscriptionPlan'
description: ''
/api/v1/subscriptions/plans/{id}/:
get:
operationId: api_v1_subscriptions_plans_retrieve
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this subscription plan.
required: true
tags:
- api
security:
- jwtAuth: []
- {}
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/SubscriptionPlan'
description: ''
/api/v1/subscriptions/self/:
get:
operationId: api_v1_subscriptions_self_list
tags:
- api
security:
- jwtAuth: []
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/UserSubscription'
description: ''
/api/v1/subscriptions/self/{id}/:
get:
operationId: api_v1_subscriptions_self_retrieve
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this user subscription.
required: true
tags:
- api
security:
- jwtAuth: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/UserSubscription'
description: ''
/api/v1/subscriptions/user_group/:
get:
operationId: api_v1_subscriptions_user_group_list
tags:
- api
security:
- jwtAuth: []
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/UserSubscription'
description: ''
/api/v1/subscriptions/user_group/{id}/:
get:
operationId: api_v1_subscriptions_user_group_retrieve
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this user subscription.
required: true
tags:
- api
security:
- jwtAuth: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/UserSubscription'
description: ''
components:
schemas:
Activation:
@ -525,6 +773,16 @@ components:
required:
- token
- uid
Checkout:
type: object
properties:
subscription_id:
type: integer
annual:
type: boolean
required:
- annual
- subscription_id
CustomUser:
type: object
properties:
@ -539,8 +797,8 @@ components:
email:
type: string
format: email
readOnly: true
title: Email address
maxLength: 254
avatar:
type: string
format: uri
@ -550,10 +808,48 @@ components:
last_name:
type: string
maxLength: 150
user_group:
type: integer
readOnly: true
nullable: true
group_member:
type: string
readOnly: true
group_owner:
type: string
readOnly: true
required:
- avatar
- email
- group_member
- group_owner
- id
- user_group
- username
Notification:
type: object
properties:
id:
type: integer
readOnly: true
timestamp:
type: string
format: date-time
readOnly: true
content:
type: string
readOnly: true
nullable: true
dismissed:
type: boolean
recipient:
type: integer
readOnly: true
required:
- content
- id
- recipient
- timestamp
PasswordResetConfirm:
type: object
properties:
@ -581,8 +877,8 @@ components:
email:
type: string
format: email
readOnly: true
title: Email address
maxLength: 254
avatar:
type: string
format: uri
@ -592,6 +888,35 @@ components:
last_name:
type: string
maxLength: 150
user_group:
type: integer
readOnly: true
nullable: true
group_member:
type: string
readOnly: true
group_owner:
type: string
readOnly: true
PatchedNotification:
type: object
properties:
id:
type: integer
readOnly: true
timestamp:
type: string
format: date-time
readOnly: true
content:
type: string
readOnly: true
nullable: true
dismissed:
type: boolean
recipient:
type: integer
readOnly: true
SendEmailReset:
type: object
properties:
@ -625,6 +950,30 @@ components:
required:
- current_password
- new_username
SubscriptionPlan:
type: object
properties:
id:
type: integer
readOnly: true
name:
type: string
maxLength: 100
description:
type: string
nullable: true
maxLength: 1024
annual_price:
type: integer
nullable: true
monthly_price:
type: integer
nullable: true
group_exclusive:
type: boolean
required:
- id
- name
TokenObtainPair:
type: object
properties:
@ -668,32 +1017,52 @@ components:
UserRegistration:
type: object
properties:
username:
type: string
description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_
only.
pattern: ^[\w.@+-]+$
maxLength: 150
email:
type: string
format: email
username:
type: string
password:
type: string
writeOnly: true
avatar:
type: string
format: uri
nullable: true
first_name:
type: string
maxLength: 150
last_name:
type: string
maxLength: 150
required:
- email
- first_name
- last_name
- password
- username
UserSubscription:
type: object
properties:
id:
type: integer
readOnly: true
user:
type: integer
nullable: true
user_group:
type: integer
nullable: true
subscription:
type: integer
nullable: true
date:
type: string
format: date-time
readOnly: true
valid:
type: boolean
annual:
type: boolean
required:
- annual
- date
- id
- valid
UsernameResetConfirm:
type: object
properties:

View file

View file

@ -0,0 +1,29 @@
from django.contrib import admin
from subscriptions.models import StripePrice, SubscriptionPlan, UserSubscription
from unfold.admin import ModelAdmin
from unfold.contrib.filters.admin import RangeDateFilter
@admin.register(StripePrice)
class StripePriceAdmin(ModelAdmin):
search_fields = ["id", "lookup_key",
"stripe_price_id","price","currency", "prorated", "annual"]
list_display = ["id", "lookup_key",
"stripe_price_id", "price", "currency", "prorated", "annual"]
@admin.register(SubscriptionPlan)
class SubscriptionPlanAdmin(ModelAdmin):
list_display = ["id", "__str__", "stripe_product_id", "group_exclusive"]
search_fields = ["id", "name", "stripe_product_id", "group_exclusive"]
@admin.register(UserSubscription)
class UserSubscriptionAdmin(ModelAdmin):
list_filter_submit = True
list_filter = ((
"date", RangeDateFilter
),)
list_display = ["id", "__str__", "valid", "annual",
"date"]
search_fields = ["id", "date"]

View file

@ -0,0 +1,9 @@
from django.apps import AppConfig
class SubscriptionConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'subscriptions'
def ready(self):
import subscriptions.signals

View file

@ -0,0 +1,56 @@
# Generated by Django 5.0.6 on 2024-05-10 06:37
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('user_groups', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='StripePrice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('annual', models.BooleanField(default=False)),
('stripe_price_id', models.CharField(max_length=100)),
('price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10)),
('currency', models.CharField(max_length=20)),
('lookup_key', models.CharField(blank=True, max_length=100, null=True)),
('prorated', models.BooleanField(default=False)),
],
),
migrations.CreateModel(
name='SubscriptionPlan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('description', models.TextField(max_length=1024, null=True)),
('stripe_product_id', models.CharField(max_length=100)),
('group_exclusive', models.BooleanField(default=False)),
('annual_price', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='annual_plan', to='subscriptions.stripeprice')),
('monthly_price', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='monthly_plan', to='subscriptions.stripeprice')),
],
),
migrations.CreateModel(
name='UserSubscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('stripe_id', models.CharField(max_length=100)),
('date', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('valid', models.BooleanField()),
('annual', models.BooleanField()),
('subscription', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='subscriptions.subscriptionplan')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('user_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='user_groups.usergroup')),
],
),
]

View file

@ -0,0 +1,56 @@
from django.db import models
from accounts.models import CustomUser
from user_groups.models import UserGroup
from django.utils.timezone import now
class StripePrice(models.Model):
annual = models.BooleanField(default=False)
stripe_price_id = models.CharField(max_length=100)
price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00)
currency = models.CharField(max_length=20)
lookup_key = models.CharField(max_length=100, blank=True, null=True)
prorated = models.BooleanField(default=False)
def __str__(self):
if self.annual:
return f"{self.price}{self.currency}/year"
else:
return f"{self.price}{self.currency}/month"
class SubscriptionPlan(models.Model):
name = models.CharField(max_length=100)
description = models.TextField(max_length=1024, null=True)
stripe_product_id = models.CharField(max_length=100)
annual_price = models.ForeignKey(
StripePrice, on_delete=models.SET_NULL, related_name='annual_plan', null=True)
monthly_price = models.ForeignKey(
StripePrice, on_delete=models.SET_NULL, related_name='monthly_plan', null=True)
group_exclusive = models.BooleanField(default=False)
def __str__(self):
return f"{self.name}"
# Model for User Subscriptions
class UserSubscription(models.Model):
user = models.ForeignKey(
CustomUser, on_delete=models.CASCADE, blank=True, null=True)
user_group = models.ForeignKey(
UserGroup, on_delete=models.CASCADE, blank=True, null=True)
subscription = models.ForeignKey(
SubscriptionPlan, on_delete=models.SET_NULL, blank=True, null=True)
stripe_id = models.CharField(max_length=100)
date = models.DateTimeField(default=now, editable=False)
valid = models.BooleanField()
annual = models.BooleanField()
def __str__(self):
if self.user:
return f'Subscription {self.subscription.name} for {self.user}'
else:
return f'Subscription {self.subscription.name} for {self.user_group}'

View file

@ -0,0 +1,44 @@
from rest_framework import serializers
from subscriptions.models import SubscriptionPlan, UserSubscription, StripePrice
from accounts.serializers import SimpleCustomUserSerializer
class SimpleStripePriceSerializer(serializers.ModelSerializer):
class Meta:
model = StripePrice
fields = ['price', 'currency', 'prorated']
class SubscriptionPlanSerializer(serializers.ModelSerializer):
class Meta:
model = SubscriptionPlan
fields = ['id', 'name', 'description',
'annual_price', 'monthly_price', 'group_exclusive']
def to_representation(self, instance):
representation = super().to_representation(instance)
representation['annual_price'] = SimpleStripePriceSerializer(
instance.annual_price, many=False).data
representation['monthly_price'] = SimpleStripePriceSerializer(
instance.monthly_price, many=False).data
return representation
class UserSubscriptionSerializer(serializers.ModelSerializer):
date = serializers.DateTimeField(
format="%m-%d-%Y %I:%M %p", read_only=True)
class Meta:
model = UserSubscription
fields = ['id', 'user', 'user_group', 'subscription',
'date', 'valid', 'annual']
def to_representation(self, instance):
representation = super().to_representation(instance)
representation['user'] = SimpleCustomUserSerializer(
instance.user, many=False).data
representation['subscription'] = SubscriptionPlanSerializer(
instance.subscription, many=False).data
return representation

View file

@ -0,0 +1,91 @@
from django.dispatch import receiver
from django.db.models.signals import post_migrate, post_save
from .models import UserSubscription, StripePrice, SubscriptionPlan
from django.core.cache import cache
from config.settings import STRIPE_SECRET_KEY
import stripe
stripe.api_key = STRIPE_SECRET_KEY
# Template for running actions after user have paid for a subscription
@receiver(post_save, sender=SubscriptionPlan)
def clear_cache_after_plan_updates(sender, instance, **kwargs):
# Clear cache
cache.delete('subscriptionplans')
@receiver(post_save, sender=UserSubscription)
def scan_after_payment(sender, instance, **kwargs):
# If the updated/created subscription is valid
if instance.valid and instance.user:
# TODO: Add any Celery task actions here for regular subscription payees
pass
@receiver(post_migrate)
def create_subscriptions(sender, **kwargs):
if sender.name == 'subscriptions':
print('Importing data from Stripe')
created_prices = 0
created_plans = 0
skipped_prices = 0
skipped_plans = 0
products = stripe.Product.list(active=True)
prices = stripe.Price.list(expand=["data.tiers"], active=True)
# Create the StripePrice
for price in prices['data']:
annual = (price['recurring']['interval'] ==
'year') if price['recurring'] else False
STRIPE_PRICE, CREATED = StripePrice.objects.get_or_create(
stripe_price_id=price['id'],
price=price['unit_amount'] / 100,
annual=annual,
lookup_key=price['lookup_key'],
prorated=price['recurring']['usage_type'] == 'metered',
currency=price['currency']
)
if CREATED:
created_prices += 1
else:
skipped_prices += 1
# Create the SubscriptionPlan
for product in products['data']:
ANNUAL_PRICE = None
MONTHLY_PRICE = None
for price in prices['data']:
if price['product'] == product['id']:
STRIPE_PRICE = StripePrice.objects.get(
stripe_price_id=price['id'],
)
if STRIPE_PRICE.annual:
ANNUAL_PRICE = STRIPE_PRICE
else:
MONTHLY_PRICE = STRIPE_PRICE
if ANNUAL_PRICE or MONTHLY_PRICE:
SUBSCRIPTION_PLAN, CREATED = SubscriptionPlan.objects.get_or_create(
name=product['name'],
description=product['description'],
stripe_product_id=product['id'],
annual_price=ANNUAL_PRICE,
monthly_price=MONTHLY_PRICE,
group_exclusive=product['metadata']['group_exclusive'] == 'True'
)
if CREATED:
created_plans += 1
else:
skipped_plans += 1
# Skip over plans with missing pricing rates
else:
print('Skipping plan' +
product['name'] + 'with missing pricing data')
# Assign the StripePrice to the SubscriptionPlan
SUBSCRIPTION_PLAN.save()
print('Created', created_plans, 'new plans')
print('Skipped', skipped_plans, 'existing plans')
print('Created', created_prices, 'new prices')
print('Skipped', skipped_prices, 'existing prices')

View file

@ -0,0 +1,42 @@
from celery import shared_task
@shared_task
def get_user_subscription(user_id):
from subscriptions.models import UserSubscription
from accounts.models import CustomUser
USER = CustomUser.objects.get(id=user_id)
# Get a list of subscriptions for the specified user
active_subscriptions = None
if USER.user_group:
active_subscriptions = UserSubscription.objects.filter(
user_group=USER.user_group, valid=True)
else:
active_subscriptions = UserSubscription.objects.filter(
user=USER, valid=True)
# Return first valid subscription if there is one
if len(active_subscriptions) > 0:
return active_subscriptions[0]
else:
return None
@shared_task
def get_user_group_subscription(user_group):
from subscriptions.models import UserSubscription
from user_groups.models import UserGroup
USER_GROUP = UserGroup.objects.get(id=user_group)
# Get a list of subscriptions for the specified user
active_subscriptions = None
active_subscriptions = UserSubscription.objects.filter(
user_group=USER_GROUP, valid=True)
# Return first valid subscription if there is one
if len(active_subscriptions) > 0:
return active_subscriptions[0]
else:
return None

View file

@ -0,0 +1,14 @@
from django.urls import path, include
from subscriptions import views
from rest_framework.routers import DefaultRouter
router = DefaultRouter()
router.register(r'plans', views.SubscriptionPlanViewset,
basename="Subscription Plans")
router.register(r'self', views.UserSubscriptionViewset,
basename="Self Subscriptions")
router.register(r'user_group', views.UserGroupSubscriptionViewet,
basename="Group Subscriptions")
urlpatterns = [
path('', include(router.urls)),
]

View file

@ -0,0 +1,56 @@
from subscriptions.serializers import SubscriptionPlanSerializer, UserSubscriptionSerializer
from subscriptions.models import SubscriptionPlan, UserSubscription
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework import viewsets
from django.core.cache import cache
class SubscriptionPlanViewset(viewsets.ModelViewSet):
http_method_names = ['get']
serializer_class = SubscriptionPlanSerializer
permission_classes = [AllowAny]
queryset = SubscriptionPlan.objects.all()
def get_queryset(self):
key = 'subscriptionplans'
queryset = cache.get(key)
if not queryset:
queryset = super().get_queryset()
cache.set(key, queryset, 60*60)
return queryset
class UserSubscriptionViewset(viewsets.ModelViewSet):
http_method_names = ['get']
serializer_class = UserSubscriptionSerializer
permission_classes = [IsAuthenticated]
queryset = UserSubscription.objects.all()
def get_queryset(self):
user = self.request.user
key = f'subscriptions_user:{user.id}'
queryset = cache.get(key)
if not queryset:
queryset = UserSubscription.objects.filter(user=user)
cache.set(key, queryset, 60*60)
return queryset
class UserGroupSubscriptionViewet(viewsets.ModelViewSet):
http_method_names = ['get']
serializer_class = UserSubscriptionSerializer
permission_classes = [IsAuthenticated]
queryset = UserSubscription.objects.all()
def get_queryset(self):
user = self.request.user
if not user.user_group:
return UserSubscription.objects.none()
else:
key = f'subscriptions_usergroup:{user.user_group.id}'
queryset = cache.get(key)
if not cache:
queryset = UserSubscription.objects.filter(
user_group=user.user_group)
cache.set(key, queryset, 60*60)
return queryset

View file

View file

@ -0,0 +1,15 @@
from django.contrib import admin
from unfold.admin import ModelAdmin
from .models import UserGroup
from unfold.contrib.filters.admin import RangeDateFilter
@admin.register(UserGroup)
class UserGroupAdmin(ModelAdmin):
list_filter_submit = True
list_filter = ((
"date_created", RangeDateFilter
),)
list_display = ['id', 'name']
search_fields = ['id', 'name']

View file

@ -0,0 +1,9 @@
from django.apps import AppConfig
class EnterpriseGroupsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "user_groups"
def ready(self):
import user_groups.signals

View file

@ -0,0 +1,23 @@
# Generated by Django 5.0.6 on 2024-05-10 06:37
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='UserGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=128)),
('date_created', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
],
),
]

View file

@ -0,0 +1,31 @@
# Generated by Django 5.0.6 on 2024-05-10 06:38
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('user_groups', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='usergroup',
name='managers',
field=models.ManyToManyField(related_name='usergroup_managers', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='usergroup',
name='members',
field=models.ManyToManyField(related_name='usergroup_members', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='usergroup',
name='owner',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='usergroup_owner', to=settings.AUTH_USER_MODEL),
),
]

View file

@ -0,0 +1,24 @@
from django.db import models
from django.utils.timezone import now
from config.settings import STRIPE_SECRET_KEY
import stripe
stripe.api_key = STRIPE_SECRET_KEY
class UserGroup(models.Model):
name = models.CharField(max_length=128, null=False)
owner = models.ForeignKey(
'accounts.CustomUser', on_delete=models.SET_NULL, null=True, related_name='usergroup_owner')
managers = models.ManyToManyField(
'accounts.CustomUser', related_name='usergroup_managers')
members = models.ManyToManyField(
'accounts.CustomUser', related_name='usergroup_members')
date_created = models.DateTimeField(default=now, editable=False)
# Derived from email of owner, may be used for billing
@property
def email(self):
return self.owner.email
def __str__(self):
return self.name

View file

@ -0,0 +1,12 @@
from rest_framework import serializers
from .models import UserGroup
class SimpleUserGroupSerializer(serializers.ModelSerializer):
date_created = serializers.DateTimeField(
format="%m-%d-%Y %I:%M %p", read_only=True)
class Meta:
model = UserGroup
fields = ['id', 'name', 'date_created']
read_only_fields = ['id', 'name', 'date_created']

View file

@ -0,0 +1,107 @@
from subscriptions.models import SubscriptionPlan
from accounts.models import CustomUser
from .models import UserGroup
from subscriptions.tasks import get_user_group_subscription
from django.db.models.signals import m2m_changed, post_migrate
from django.dispatch import receiver
from config.settings import STRIPE_SECRET_KEY, ROOT_DIR
import os
import json
import stripe
stripe.api_key = STRIPE_SECRET_KEY
@receiver(m2m_changed, sender=UserGroup.managers.through)
def update_group_managers(sender, instance, action, **kwargs):
# When adding new managers to a UserGroup, associate them with it
if action == 'post_add':
# Get the newly added managers
new_managers = kwargs.get('pk_set', set())
for manager in new_managers:
# Retrieve the member
USER = CustomUser.objects.get(pk=manager)
if not USER.user_group:
# Update their group assiociation
USER.user_group = instance
USER.save()
if USER not in instance.members.all():
instance.members.add(USER)
# When removing managers from a UserGroup, remove their association with it
elif action == 'post_remove':
for manager in kwargs['pk_set']:
# Retrieve the manager
USER = CustomUser.objects.get(pk=manager)
if USER not in instance.members.all():
USER.user_group = None
USER.save()
@receiver(m2m_changed, sender=UserGroup.members.through)
def update_group_members(sender, instance, action, **kwargs):
# When adding new members to a UserGroup, associate them with it
if action == 'post_add':
# Get the newly added members
new_members = kwargs.get('pk_set', set())
for member in new_members:
# Retrieve the member
USER = CustomUser.objects.get(pk=member)
if not USER.user_group:
# Update their group assiociation
USER.user_group = instance
USER.save()
# When removing members from a UserGroup, remove their association with it
elif action == 'post_remove':
for client in kwargs['pk_set']:
USER = CustomUser.objects.get(pk=client)
if USER not in instance.members.all() and USER not in instance.managers.all():
USER.user_group = None
USER.save()
# Update usage records
SUBSCRIPTION_GROUP = get_user_group_subscription(instance.id)
if SUBSCRIPTION_GROUP:
try:
print(f"Updating usage record for UserGroup {instance.name}")
# Update usage for members
SUBSCRIPTION_ITEM = SUBSCRIPTION_GROUP.subscription
stripe.SubscriptionItem.create_usage_record(
SUBSCRIPTION_ITEM.stripe_id,
quantity=len(instance.members.all()),
action="set"
)
except:
print(
f'Warning: Unable to update usage record for SubscriptionGroup ID:{instance.id}')
@receiver(post_migrate)
def create_groups(sender, **kwargs):
if sender.name == "agencies":
with open(os.path.join(ROOT_DIR, 'seed_data.json'), "r") as f:
seed_data = json.loads(f.read())
for user_group in seed_data['user_groups']:
OWNER = CustomUser.objects.filter(
email=user_group['owner']).first()
USER_GROUP, CREATED = UserGroup.objects.get_or_create(
owner=OWNER,
agency_name=user_group['name'],
)
if CREATED:
print(f"Created UserGroup {USER_GROUP.agency_name}")
# Add managers
USERS = CustomUser.objects.filter(
email__in=user_group['managers'])
for USER in USERS:
if USER not in USER_GROUP.managers.all():
print(
f"Adding User {USER.full_name} as manager to UserGroup {USER_GROUP.agency_name}")
USER_GROUP.managers.add(USER)
# Add members
USERS = CustomUser.objects.filter(
email__in=user_group['members'])
for USER in USERS:
if USER not in USER_GROUP.members.all():
print(
f"Adding User {USER.full_name} as member to UserGroup {USER_GROUP.agency_name}")
USER_GROUP.clients.add(USER)
USER_GROUP.save()

View file

View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class EmailsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'webdriver'

View file

@ -0,0 +1,16 @@
from celery import shared_task
from webdriver.utils import setup_webdriver, selenium_action_template
# Sample Celery Selenium function
# TODO: Modify this as needed
@shared_task(autoretry_for=(Exception,), retry_kwargs={'max_retries': 6, 'countdown': 5})
def sample_selenium_task():
driver = setup_webdriver()
selenium_action_template(driver)
# Place any other actions here after Selenium is done executing
# Once completed, always close the session
driver.close()
driver.quit()

390
backend/webdriver/utils.py Normal file
View file

@ -0,0 +1,390 @@
"""
Settings file to hold constants and functions
"""
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
import os
from config.settings import get_secret
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver import FirefoxOptions
from selenium import webdriver
import undetected_chromedriver as uc
from config.settings import USE_PROXY, CAPTCHA_TESTING
from config.settings import get_secret
from twocaptcha import TwoCaptcha
from whois import whois
from whois.parser import PywhoisError
def take_snapshot(driver, filename='dump.png'):
# Set window size
required_width = driver.execute_script(
'return document.body.parentNode.scrollWidth')
required_height = driver.execute_script(
'return document.body.parentNode.scrollHeight')
driver.set_window_size(
required_width, required_height+(required_height*0.05))
# Take the snapshot
driver.find_element(By.TAG_NAME,
'body').screenshot('/dumps/'+filename) # avoids any scrollbars
print('Snapshot saved')
def dump_html(driver, filename='dump.html'):
# Save the page source to error.html
with open(('/dumps/'+filename), 'w', encoding='utf-8') as file:
file.write(driver.page_source)
def setup_webdriver(driver_type="chrome", use_proxy=True, use_saved_session=False):
# Manual proxy override via .env variable
if not USE_PROXY:
use_proxy = False
if use_proxy:
print('Running driver with proxy enabled')
else:
print('Running driver with proxy disabled')
if use_saved_session:
print('Running with saved session')
else:
print('Running without using saved session')
if driver_type == "chrome":
print('Using Chrome driver')
opts = uc.ChromeOptions()
if use_saved_session:
if os.path.exists("/tmp_chrome_profile"):
print('Existing Chrome ephemeral profile found')
else:
print('No existing Chrome ephemeral profile found')
os.system("mkdir /tmp_chrome_profile")
if os.path.exists('/chrome'):
print('Copying Chrome Profile to ephemeral directory')
# Flush any non-essential cache directories from the existing profile as they may balloon in size overtime
os.system(
'rm -rf "/chrome/Selenium Profile/Code Cache/*"')
# Create a copy of the Chrome Profile
os.system("cp -r /chrome/* /tmp_chrome_profile")
try:
# Remove some items related to file locks
os.remove('/tmp_chrome_profile/SingletonLock')
os.remove('/tmp_chrome_profile/SingletonSocket')
os.remove('/tmp_chrome_profile/SingletonLock')
except:
pass
else:
print('No existing Chrome Profile found. Creating one from scratch')
if use_saved_session:
# Specify the user data directory
opts.add_argument(f'--user-data-dir=/tmp_chrome_profile')
opts.add_argument('--profile-directory=Selenium Profile')
# Set proxy
if use_proxy:
opts.add_argument(
f'--proxy-server=socks5://{get_secret("PROXY_IP")}:{get_secret("PROXY_PORT_IP_AUTH")}')
opts.add_argument("--disable-extensions")
opts.add_argument('--disable-application-cache')
opts.add_argument("--disable-setuid-sandbox")
opts.add_argument('--disable-dev-shm-usage')
opts.add_argument("--disable-gpu")
opts.add_argument("--no-sandbox")
opts.add_argument("--headless=new")
driver = uc.Chrome(options=opts)
elif driver_type == "firefox":
print('Using firefox driver')
opts = FirefoxOptions()
if use_saved_session:
if not os.path.exists("/firefox"):
print('No profile found')
os.makedirs("/firefox")
else:
print('Existing profile found')
# Specify a profile if it exists
opts.profile = "/firefox"
# Set proxy
if use_proxy:
opts.set_preference('network.proxy.type', 1)
opts.set_preference('network.proxy.socks',
get_secret('PROXY_IP'))
opts.set_preference('network.proxy.socks_port',
int(get_secret('PROXY_PORT_IP_AUTH')))
opts.set_preference('network.proxy.socks_remote_dns', False)
opts.add_argument('--disable-dev-shm-usage')
opts.add_argument("--headless")
opts.add_argument("--disable-gpu")
driver = webdriver.Firefox(options=opts)
driver.maximize_window()
# Check if proxy is working
driver.get('https://api.ipify.org/')
body = WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.TAG_NAME, "body")))
ip_address = body.text
print(f'External IP: {ip_address}')
return driver
# Function to get the element once it has loaded in
def get_element(driver, by, key, hidden_element=False, timeout=8):
try:
if by == "xpath":
by = By.XPATH
elif by == "css":
by = By.CSS_SELECTOR
elif by == "id":
by = By.ID
elif by == "tagname":
by = By.TAG_NAME
elif by == "name":
by = By.NAME
elif by == "classname":
by == By.CLASS_NAME
wait = WebDriverWait(driver, timeout=timeout)
if not hidden_element:
element = wait.until(
EC.element_to_be_clickable((by, key)) and EC.visibility_of_element_located((by, key)))
else:
element = wait.until(EC.presence_of_element_located(
(by, key)))
return element
except Exception:
raise Exception(f"Unable to get element of {by} value: {key}")
def execute_selenium_elements(driver, timeout, elements):
try:
for index, element in enumerate(elements):
print('Waiting...')
# Element may have a keyword specified, check if that exists before running any actions
if "keyword" in element:
# Skip a step if the keyword does not exist
if element['keyword'] not in driver.page_source:
print(
f'Keyword {element["keyword"]} does not exist. Skipping step: {index+1} - {element["name"]}')
continue
elif element['keyword'] in driver.page_source and element['type'] == 'skip':
print(
f'Keyword {element["keyword"]} does exists. Stopping at step: {index+1} - {element["name"]}')
break
print(f'Step: {index+1} - {element["name"]}')
# Revert to default iframe action
if element["type"] == "revert_default_frame":
driver.switch_to.default_content()
continue
# CAPTCHA Callback
elif element["type"] == "recaptchav2_callback":
if callable(element["input"]):
values = element["input"]()
else:
values = element["input"]
if type(values) is list:
raise Exception(
'Invalid input value specified for "callback" type')
else:
# For single input values
driver.execute_script(
f'onRecaptcha("{values}");')
continue
try:
# Try to get default element
if "hidden" in element:
site_element = get_element(
driver, element["default"]["type"], element["default"]["key"], hidden_element=True, timeout=timeout)
else:
site_element = get_element(
driver, element["default"]["type"], element["default"]["key"], timeout=timeout)
except Exception as e:
print(f'Failed to find primary element')
# If that fails, try to get the failover one
print('Trying to find legacy element')
if "hidden" in element:
site_element = get_element(
driver, element["failover"]["type"], element["failover"]["key"], hidden_element=True, timeout=timeout)
else:
site_element = get_element(
driver, element["failover"]["type"], element["failover"]["key"], timeout=timeout)
# Clicking an element
if element["type"] == "click":
site_element.click()
# Switching to an element frame/iframe
elif element["type"] == "switch_to_iframe_click":
driver.switch_to.frame(site_element)
# Input type simulates user typing
elif element["type"] == "input":
if callable(element["input"]):
values = element["input"]()
else:
values = element["input"]
values = values.splitlines()
# For multiple input values
for index, value in enumerate(values):
site_element.send_keys(value)
# Only send a new line keypress if this is not the last value to enter in the list
if index != len(values) - 1:
site_element.send_keys(Keys.RETURN)
elif element["type"] == "input_enter":
site_element.send_keys(Keys.RETURN)
# Input_replace type places values directly. Useful for CAPTCHA
elif element["type"] == "input_replace":
if callable(element["input"]):
values = element["input"]()
else:
values = element["input"]
if type(values) is list:
raise Exception(
'Invalid input value specified for "input_replace" type')
else:
# For single input values
driver.execute_script(
f'arguments[0].value = "{values}";', site_element)
except Exception as e:
take_snapshot(driver)
dump_html(driver)
driver.close()
driver.quit()
raise Exception(e)
def solve_captcha(site_key, url, retry_attempts=3, version='v2', enterprise=False, use_proxy=True):
# Manual proxy override set via $ENV
if not USE_PROXY:
use_proxy = False
if CAPTCHA_TESTING:
print('Initializing CAPTCHA solver in dummy mode')
code = "12345"
print("CAPTCHA Successful")
return code
elif use_proxy:
print('Using CAPTCHA solver with proxy')
else:
print('Using CAPTCHA solver without proxy')
captcha_params = {
"url": url,
"sitekey": site_key,
"version": version,
"enterprise": 1 if enterprise else 0,
"proxy": {
'type': 'socks5',
'uri': get_secret('PROXY_USER_AUTH')
} if use_proxy else None
}
# Keep retrying until max attempts is reached
for _ in range(retry_attempts):
# Solver uses 2CAPTCHA by default
solver = TwoCaptcha(get_secret("CAPTCHA_API_KEY"))
try:
print('Waiting for CAPTCHA code...')
code = solver.recaptcha(**captcha_params)["code"]
print("CAPTCHA Successful")
return code
except Exception as e:
print(f'CAPTCHA Failed! {e}')
raise Exception(f"CAPTCHA API Failed!")
def whois_lookup(url):
try:
lookup_info = whois(url)
# TODO: Add your own processing here
except PywhoisError:
print(f"No WhoIs record found for {url}")
return lookup_info
def save_browser_session(driver):
# Copy over the profile once we finish logging in
if isinstance(driver, webdriver.Firefox):
# Copy process for Firefox
print('Updating saved Firefox profile')
# Get the current profile directory from about:support page
driver.get("about:support")
box = get_element(
driver, "id", "profile-dir-box", timeout=4)
temp_profile_path = os.path.join(os.getcwd(), box.text)
profile_path = '/firefox'
# Create the command
copy_command = "cp -r " + temp_profile_path + "/* " + profile_path
# Copy over the Firefox profile
if os.system(copy_command):
print("Firefox profile saved")
elif isinstance(driver, uc.Chrome):
# Copy the Chrome profile
print('Updating non-ephemeral Chrome profile')
# Flush Code Cache again to speed up copy
os.system(
'rm -rf "/tmp_chrome_profile/SimpleDMCA Profile/Code Cache/*"')
if os.system("cp -r /tmp_chrome_profile/* /chrome"):
print("Chrome profile saved")
# Sample function
# Call this within a Celery task
# TODO: Modify as needed to your needs
def selenium_action_template(driver):
info = {
"sample_field1": "sample_data",
"sample_field2": "sample_data",
"captcha_code": lambda: solve_captcha('SITE_KEY', 'SITE_URL')
}
elements = [
{
"name": "Enter data for sample field 1",
"type": "input",
"input": "{first_name}",
# If a site implements canary design releases, you can place the ID for the element in the new design
"default": {
# See get_element() for possible selector types
"type": "xpath",
"key": ''
},
# If a site implements canary design releases, you can place the ID for the element in the old design here
"failover": {
"type": "xpath",
"key": ''
}
},
]
# Dictionary to store values which will be entered via Selenium
# Helps prevent duplicates and stale values compared to just using the info variable directly
site_form_values = {}
# Fill in final fstring values in elements
for element in elements:
if 'input' in element and '{' in element['input']:
a = element['input'].strip('{}')
if a in info:
value = info[a]
# Check if the value is a callable (a lambda function) and call it if so
if callable(value):
# Check if the value has already been called
if a not in site_form_values:
# Call the value and store it in the dictionary
site_form_values[a] = value()
# Use the stored value
value = site_form_values[a]
# Replace the placeholder with the actual value
element['input'] = str(value)
# Execute the selenium actions
execute_selenium_elements(driver, 8, elements)

View file

@ -1,24 +1,115 @@
version: "3.9"
services:
# Django App
django_backend:
# Django Backend
# http://localhost:8000
django:
env_file: .env
build:
context: .
dockerfile: Dockerfile
image: test_backend:latest
image: drf_template:latest
ports:
- "8092:8000"
- "8000:8000"
environment:
- PYTHONBUFFERED=1
command:
[
"sh",
"-c",
"python backend/manage.py spectacular --color --file backend/schema.yml && python backend/manage.py collectstatic --noinput && python backend/manage.py makemigrations && python backend/manage.py migrate && python backend/manage.py runserver 127.0.0.1:8000",
]
- RUN_TYPE=web
volumes:
- .:/code # For hotreloading
- .:/code
depends_on:
- postgres
# Django Celery Worker
celery:
env_file: .env
environment:
- RUN_TYPE=worker
image: drf_template:latest
volumes:
- .:/code
- ./chrome:/chrome
- ./firefox:/firefox
- ./dumps:/dumps
depends_on:
- django
- postgres
- redis
## Runs multiple worker instances
scale: 4
# Django Celery Beat
celery_beat:
env_file: .env
environment:
- RUN_TYPE=beat
image: drf_template:latest
volumes:
- .:/code
depends_on:
- celery
- django
- postgres
- redis
# Django Celery Monitor
# http://localhost:5000
celery_monitor:
env_file: .env
environment:
- RUN_TYPE=monitor
image: drf_template:latest
ports:
- "5555:5555"
volumes:
- .:/code
depends_on:
- celery
- django
- redis
# SQL Database
postgres:
env_file: .env
image: postgres
environment:
- POSTGRES_DB=${DB_DATABASE}
- POSTGRES_USER=${DB_USERNAME}
- POSTGRES_PASSWORD=${DB_PASSWORD}
ports:
- "${DB_PORT}:5432"
volumes:
- db-data:/var/lib/postgresql/data
# Redis Server
redis:
image: redis:latest
ports:
- "${REDIS_PORT}:6379"
# Stripe CLI Webhook Listener
stripe-listener:
env_file: .env
image: stripe/stripe-cli:latest
environment:
- STRIPE_WEBHOOK_SECRET=${STRIPE_SECRET_WEBHOOK}
- STRIPE_API_KEY=${STRIPE_SECRET_KEY}
command: listen --forward-to django:8000/api/v1/stripe/webhook/
# Email Testing Server
# http://localhost:8025
inbucket:
image: inbucket/inbucket:latest
ports:
- "8025:8025"
- "1025:1025"
environment:
- INBUCKET_LOGLEVEL=error
- INBUCKET_MAILBOXNAMING=domain
- INBUCKET_SMTP_ADDR=0.0.0.0:1025
- INBUCKET_SMTP_MAXRECIPIENTS=1000
- INBUCKET_WEB_ADDR=0.0.0.0:8025
- INBUCKET_STORAGE_TYPE=memory
- INBUCKET_STORAGE_MAILBOXMSGCAP=2000
volumes:
test_backend:
db-data:

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

View file

@ -1,66 +1,109 @@
-i https://pypi.org/simple
annotated-types==0.6.0; python_version >= '3.8'
asgiref==3.7.2; python_version >= '3.7'
2captcha-python==1.2.5
amqp==5.2.0; python_version >= '3.6'
asgiref==3.8.1; python_version >= '3.8'
async-timeout==4.0.3; python_full_version < '3.11.3'
attrs==23.2.0; python_version >= '3.7'
certifi==2023.11.17; python_version >= '3.6'
cffi==1.16.0; python_version >= '3.8'
autobahn==23.6.2; python_version >= '3.9'
automat==22.10.0
autopep8==2.1.0; python_version >= '3.8'
billiard==4.2.0; python_version >= '3.7'
celery==5.4.0
certifi==2024.2.2; python_version >= '3.6'
cffi==1.16.0;
charset-normalizer==3.3.2; python_full_version >= '3.7.0'
click==8.1.7; python_version >= '3.7'
colorama==0.4.6; platform_system == 'Windows'
cryptography==41.0.7; python_version >= '3.7'
click-didyoumean==0.3.1; python_full_version >= '3.6.2'
click-plugins==1.1.1
click-repl==0.3.0; python_version >= '3.6'
colorama==0.4.6;
constantly==23.10.4; python_version >= '3.8'
cron-descriptor==1.4.3
cryptography==42.0.7; python_version >= '3.7'
defusedxml==0.8.0rc2; python_version >= '3.6'
django==5.0.1
django==5.0.6
django-celery-beat==2.6.0
django-celery-results==2.5.1
django-cors-headers==4.3.1
django-extensions==3.2.3
django-extra-fields==3.0.2
django-redis==5.4.0
django-resized==1.0.2
django-simple-history==3.4.0
django-silk==5.1.0
django-simple-history==3.5.0
django-storages==1.14.3
django-templated-mail==1.1.1
django-unfold==0.18.1
djangorestframework==3.14.0
django-timezone-field==6.1.0; python_version >= '3.8' and python_version < '4.0'
django-unfold==0.22.0
djangorestframework==3.15.1
djangorestframework-simplejwt==5.3.1; python_version >= '3.8'
djoser==2.2.2
dotty-dict==1.3.1; python_version >= '3.5' and python_version < '4.0'
drf-spectacular[sidecar]==0.27.0
drf-spectacular-sidecar==2024.1.1
gitdb==4.0.11; python_version >= '3.7'
gitpython==3.1.40; python_version >= '3.7'
idna==3.6; python_version >= '3.5'
importlib-resources==6.1.1; python_version >= '3.8'
drf-spectacular[sidecar]==0.27.2
drf-spectacular-sidecar==2024.5.1
flower==2.0.1
gprof2dot==2022.7.29; python_version >= '2.7'
gunicorn==22.0.0
h11==0.14.0; python_version >= '3.7'
humanize==4.9.0; python_version >= '3.8'
hyperlink==21.0.0
idna==3.7; python_version >= '3.5'
incremental==22.10.0
inflection==0.5.1; python_version >= '3.5'
jinja2==3.1.2; python_version >= '3.7'
jsonschema==4.20.0; python_version >= '3.8'
jsonschema==4.22.0; python_version >= '3.8'
jsonschema-specifications==2023.12.1; python_version >= '3.8'
markdown-it-py==3.0.0; python_version >= '3.8'
markupsafe==2.1.3; python_version >= '3.7'
mdurl==0.1.2; python_version >= '3.7'
kombu==5.3.7
msgpack==1.0.8; python_version >= '3.8'
oauthlib==3.2.2; python_version >= '3.6'
pillow==10.2.0
outcome==1.3.0.post0; python_version >= '3.7'
packaging==24.0; python_version >= '3.7'
pillow==10.3.0
prometheus-client==0.20.0; python_version >= '3.8'
prompt-toolkit==3.0.43; python_full_version >= '3.7.0'
psycopg2==2.9.9
pycparser==2.21
pydantic==2.5.3; python_version >= '3.7'
pydantic-core==2.14.6; python_version >= '3.7'
pygments==2.17.2; python_version >= '3.7'
pyasn1==0.6.0; python_version >= '3.8'
pyasn1-modules==0.4.0; python_version >= '3.8'
pycodestyle==2.11.1; python_version >= '3.8'
pycparser==2.22; python_version >= '3.8'
pygraphviz==1.13
pyjwt==2.8.0; python_version >= '3.7'
python-dotenv==1.0.0
python-gitlab==4.3.0; python_full_version >= '3.8.0'
python-semantic-release==8.7.0; python_version >= '3.7'
pyopenssl==24.1.0
pysocks==1.7.1
python-crontab==3.0.0
python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
python-dotenv==1.0.1
python-whois==0.9.4
python3-openid==3.2.0
pytz==2023.3.post1
pytz==2024.1
pyyaml==6.0.1; python_version >= '3.6'
referencing==0.32.1; python_version >= '3.8'
redis==5.0.4
referencing==0.35.1; python_version >= '3.8'
requests==2.31.0; python_version >= '3.7'
requests-oauthlib==1.3.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
requests-toolbelt==1.0.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
rich==13.7.0; python_full_version >= '3.7.0'
rpds-py==0.16.2; python_version >= '3.8'
shellingham==1.5.4; python_version >= '3.7'
smmap==5.0.1; python_version >= '3.7'
social-auth-app-django==5.4.0; python_version >= '3.8'
social-auth-core==4.5.1; python_version >= '3.8'
sqlparse==0.4.4; python_version >= '3.5'
tomlkit==0.12.3; python_version >= '3.7'
typing-extensions==4.9.0; python_version >= '3.8'
tzdata==2023.4; sys_platform == 'win32'
requests-oauthlib==2.0.0; python_version >= '3.4'
rpds-py==0.18.1; python_version >= '3.8'
selenium==4.20.0
service-identity==24.1.0
setuptools==69.5.1; python_version >= '3.8'
six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
sniffio==1.3.1; python_version >= '3.7'
social-auth-app-django==5.4.1; python_version >= '3.8'
social-auth-core==4.5.4; python_version >= '3.8'
sortedcontainers==2.4.0
sqlparse==0.5.0; python_version >= '3.8'
stripe==9.6.0
tornado==6.4; python_version >= '3.8'
trio==0.25.0; python_version >= '3.8'
trio-websocket==0.11.1; python_version >= '3.7'
twisted[tls]==24.3.0; python_full_version >= '3.8.0'
twisted-iocpsupport==1.0.4; platform_system == 'Windows'
txaio==23.1.1; python_version >= '3.7'
typing-extensions==4.11.0; python_version >= '3.8'
tzdata==2024.1; python_version >= '2'
undetected-chromedriver==3.5.5
uritemplate==4.1.1; python_version >= '3.6'
urllib3==2.1.0; python_version >= '3.8'
urllib3[socks]==2.2.1; python_version >= '3.8'
vine==5.1.0; python_version >= '3.6'
wcwidth==0.2.13
websockets==12.0; python_version >= '3.8'
whitenoise==6.6.0
wsproto==1.2.0; python_full_version >= '3.7.0'
zope-interface==6.3; python_version >= '3.7'

97
seed_data.json Normal file
View file

@ -0,0 +1,97 @@
{
"schedules": [
{
"type": "crontab",
"minute": "0",
"hour": "0",
"day_of_week": "*",
"day_of_month": "*",
"month_of_year": "*",
"timezone": "Asia/Manila"
},
{
"type": "crontab",
"minute": "0",
"hour": "1",
"day_of_week": "*",
"day_of_month": "*",
"month_of_year": "*",
"timezone": "Asia/Manila"
},
{
"type": "crontab",
"minute": "0",
"hour": "12",
"day_of_week": "*",
"day_of_month": "*",
"month_of_year": "*",
"timezone": "Asia/Manila"
},
{
"type": "crontab",
"minute": "0",
"hour": "13",
"day_of_week": "*",
"day_of_month": "*",
"month_of_year": "*",
"timezone": "Asia/Manila"
}
],
"scheduled_tasks": [
{
"name": "Delete notifications older than 3 days every 1 AM",
"task": "notifications.tasks.cleanup_notifications",
"schedule": {
"type": "crontab",
"minute": "0",
"hour": "1",
"day_of_week": "*",
"day_of_month": "*",
"month_of_year": "*",
"timezone": "Asia/Manila"
},
"enabled": true
}
],
"users": [
{
"username": "drf-template_admin",
"email": "admin@drf-template.com",
"password": "USE_ADMIN",
"is_superuser": true,
"first_name": "DRF-Template",
"last_name": "Admin"
},
{
"username": "drf-template_testuser1",
"email": "testuser1@drf-template.com",
"password": "USE_REGULAR",
"is_superuser": false,
"first_name": "DRF-Template",
"last_name": "Test User 1"
},
{
"username": "drf-template_testuser2",
"email": "testuser2@drf-template.com",
"password": "USE_REGULAR",
"is_superuser": false,
"first_name": "DRF-Template",
"last_name": "Test User 2"
},
{
"username": "drf-template_testuser3",
"email": "testuser3@drf-template.com",
"password": "USE_REGULAR",
"is_superuser": false,
"first_name": "DRF-Template",
"last_name": "Test User 3"
}
],
"user_groups": [
{
"name": "DRF-Template Test Group",
"managers": "drf-template_testuser2",
"members": ["drf-template_testuser3"]
}
]
}

36
start.sh Normal file
View file

@ -0,0 +1,36 @@
#!/bin/bash
set -e
echo "Running as: $RUN_TYPE"
if [ "$RUN_TYPE" = "web" ]; then
python backend/manage.py spectacular --color --file backend/schema.yml
python backend/manage.py migrate
if [ ! -d "backend/static" ]; then
echo "Generating static files"
python backend/manage.py collectstatic --noinput
fi
python backend/manage.py graph_models -o documentation/erd/app_models.png
cd backend
# python manage.py runserver 0.0.0.0:8000
python -m gunicorn --bind 0.0.0.0:8000 -w 4 config.wsgi:application
elif [ "$RUN_TYPE" = "worker" ]; then
cd backend && celery -A config worker -l INFO -E --concurrency 1
elif [ "$RUN_TYPE" = "beat" ]; then
sleep 15
cd backend && celery -A config beat -l INFO --scheduler django_celery_beat.schedulers:DatabaseScheduler
elif [ "$RUN_TYPE" = "monitor" ]; then
cd backend && celery -A config flower --port=5555
else
echo "No RUN_TYPE value set. Defaulting to web mode"
echo "No value specified, defaulting to web"
python backend/manage.py spectacular --color --file backend/schema.yml
python backend/manage.py migrate
if [ ! -d "backend/static" ]; then
echo "Generating static files"
python backend/manage.py collectstatic --noinput
fi
python backend/manage.py graph_models -o documentation/erd/app_models.png
cd backend
# python manage.py runserver 0.0.0.0:8000
python -m gunicorn --bind 0.0.0.0:8000 -w 4 config.wsgi:application
fi