mirror of
https://github.com/lemeow125/DRF_Template.git
synced 2024-11-16 19:59:24 +08:00
Overhauled entire project config, added notifications, email templates, optimized stripe subscriptions, redis caching, and webdriver utilities
This commit is contained in:
parent
7cbe8fd720
commit
99dfcef67b
84 changed files with 4300 additions and 867 deletions
4
.dockerignore
Normal file
4
.dockerignore
Normal file
|
@ -0,0 +1,4 @@
|
|||
firefox/
|
||||
chrome/
|
||||
dumps/
|
||||
media/
|
60
.env.sample
Normal file
60
.env.sample
Normal 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
13
.gitignore
vendored
|
@ -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
1
.prettierignore
Normal file
|
@ -0,0 +1 @@
|
|||
*.html
|
45
Dockerfile
45
Dockerfile
|
@ -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
|
||||
|
||||
# Generate DRF Spectacular Documentation
|
||||
RUN python backend/manage.py spectacular --color --file backend/schema.yml
|
||||
# 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
|
||||
|
||||
# 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
17
Pipfile
|
@ -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
1468
Pipfile.lock
generated
File diff suppressed because it is too large
Load diff
18
README.md
Normal file
18
README.md
Normal 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`
|
|
@ -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 = ""
|
|
@ -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',)}),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,46 +1,50 @@
|
|||
# Generated by Django 5.0.1 on 2024-01-06 04:34
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.utils.timezone
|
||||
import django_resized.forms
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CustomUser',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('avatar', django_resized.forms.ResizedImageField(crop=None, force_format='WEBP', keep_meta=True, null=True, quality=100, scale=None, size=[1920, 1080], upload_to='avatars/')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
# Generated by Django 5.0.6 on 2024-05-10 06:37
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import django_resized.forms
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('user_groups', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CustomUser',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('avatar', django_resized.forms.ResizedImageField(crop=None, force_format='WEBP', keep_meta=True, null=True, quality=100, scale=None, size=[1920, 1080], upload_to='avatars/')),
|
||||
('onboarding', models.BooleanField(default=True)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_group', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='user_groups.usergroup')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
103
backend/accounts/signals.py
Normal 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
23
backend/accounts/tasks.py
Normal 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
|
|
@ -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')),
|
||||
]
|
||||
|
|
44
backend/accounts/validators.py
Normal file
44
backend/accounts/validators.py
Normal 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).")
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
0
backend/billing/__init__.py
Normal file
0
backend/billing/__init__.py
Normal file
6
backend/billing/apps.py
Normal file
6
backend/billing/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BillingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "billing"
|
0
backend/billing/migrations/__init__.py
Normal file
0
backend/billing/migrations/__init__.py
Normal file
7
backend/billing/urls.py
Normal file
7
backend/billing/urls.py
Normal 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
61
backend/billing/views.py
Normal 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)
|
|
@ -0,0 +1,3 @@
|
|||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
17
backend/config/celery.py
Normal file
17
backend/config/celery.py
Normal 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()
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'))]
|
||||
|
|
0
backend/emails/__init__.py
Normal file
0
backend/emails/__init__.py
Normal file
6
backend/emails/apps.py
Normal file
6
backend/emails/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class EmailsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'emails'
|
49
backend/emails/templates.py
Normal file
49
backend/emails/templates.py
Normal 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
|
28
backend/emails/templates/activation.html
Normal file
28
backend/emails/templates/activation.html
Normal 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 %}
|
29
backend/emails/templates/password_reset.html
Normal file
29
backend/emails/templates/password_reset.html
Normal 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 %}
|
20
backend/emails/templates/subscription_availed.html
Normal file
20
backend/emails/templates/subscription_availed.html
Normal 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 %}
|
24
backend/emails/templates/subscription_cancelled.html
Normal file
24
backend/emails/templates/subscription_cancelled.html
Normal 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 %}
|
20
backend/emails/templates/subscription_refunded.html
Normal file
20
backend/emails/templates/subscription_refunded.html
Normal 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 %}
|
0
backend/notifications/__init__.py
Normal file
0
backend/notifications/__init__.py
Normal file
10
backend/notifications/admin.py
Normal file
10
backend/notifications/admin.py
Normal 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']
|
9
backend/notifications/apps.py
Normal file
9
backend/notifications/apps.py
Normal 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
|
27
backend/notifications/migrations/0001_initial.py
Normal file
27
backend/notifications/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
0
backend/notifications/migrations/__init__.py
Normal file
0
backend/notifications/migrations/__init__.py
Normal file
12
backend/notifications/models.py
Normal file
12
backend/notifications/models.py
Normal 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
|
12
backend/notifications/serializers.py
Normal file
12
backend/notifications/serializers.py
Normal 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')
|
13
backend/notifications/signals.py
Normal file
13
backend/notifications/signals.py
Normal 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}')
|
13
backend/notifications/tasks.py
Normal file
13
backend/notifications/tasks.py
Normal 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()
|
10
backend/notifications/urls.py
Normal file
10
backend/notifications/urls.py
Normal 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)),
|
||||
]
|
35
backend/notifications/views.py
Normal file
35
backend/notifications/views.py
Normal 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)
|
0
backend/payments/__init__.py
Normal file
0
backend/payments/__init__.py
Normal file
6
backend/payments/apps.py
Normal file
6
backend/payments/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PaymentsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'payments'
|
0
backend/payments/migrations/__init__.py
Normal file
0
backend/payments/migrations/__init__.py
Normal file
6
backend/payments/serializers.py
Normal file
6
backend/payments/serializers.py
Normal 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
8
backend/payments/urls.py
Normal 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
398
backend/payments/views.py
Normal 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)
|
|
@ -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:
|
||||
|
|
0
backend/subscriptions/__init__.py
Normal file
0
backend/subscriptions/__init__.py
Normal file
29
backend/subscriptions/admin.py
Normal file
29
backend/subscriptions/admin.py
Normal 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"]
|
9
backend/subscriptions/apps.py
Normal file
9
backend/subscriptions/apps.py
Normal 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
|
56
backend/subscriptions/migrations/0001_initial.py
Normal file
56
backend/subscriptions/migrations/0001_initial.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
0
backend/subscriptions/migrations/__init__.py
Normal file
0
backend/subscriptions/migrations/__init__.py
Normal file
56
backend/subscriptions/models.py
Normal file
56
backend/subscriptions/models.py
Normal 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}'
|
44
backend/subscriptions/serializers.py
Normal file
44
backend/subscriptions/serializers.py
Normal 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
|
91
backend/subscriptions/signals.py
Normal file
91
backend/subscriptions/signals.py
Normal 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')
|
42
backend/subscriptions/tasks.py
Normal file
42
backend/subscriptions/tasks.py
Normal 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
|
14
backend/subscriptions/urls.py
Normal file
14
backend/subscriptions/urls.py
Normal 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)),
|
||||
]
|
56
backend/subscriptions/views.py
Normal file
56
backend/subscriptions/views.py
Normal 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
|
0
backend/user_groups/__init__.py
Normal file
0
backend/user_groups/__init__.py
Normal file
15
backend/user_groups/admin.py
Normal file
15
backend/user_groups/admin.py
Normal 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']
|
9
backend/user_groups/apps.py
Normal file
9
backend/user_groups/apps.py
Normal 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
|
23
backend/user_groups/migrations/0001_initial.py
Normal file
23
backend/user_groups/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
0
backend/user_groups/migrations/__init__.py
Normal file
0
backend/user_groups/migrations/__init__.py
Normal file
24
backend/user_groups/models.py
Normal file
24
backend/user_groups/models.py
Normal 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
|
12
backend/user_groups/serializers.py
Normal file
12
backend/user_groups/serializers.py
Normal 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']
|
107
backend/user_groups/signals.py
Normal file
107
backend/user_groups/signals.py
Normal 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()
|
0
backend/webdriver/__init__.py
Normal file
0
backend/webdriver/__init__.py
Normal file
6
backend/webdriver/apps.py
Normal file
6
backend/webdriver/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class EmailsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'webdriver'
|
16
backend/webdriver/tasks.py
Normal file
16
backend/webdriver/tasks.py
Normal 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
390
backend/webdriver/utils.py
Normal 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)
|
|
@ -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:
|
||||
|
|
BIN
documentation/erd/app_models.png
Normal file
BIN
documentation/erd/app_models.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 148 KiB |
175
requirements.txt
175
requirements.txt
|
@ -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'
|
||||
attrs==23.2.0; python_version >= '3.7'
|
||||
certifi==2023.11.17; python_version >= '3.6'
|
||||
cffi==1.16.0; python_version >= '3.8'
|
||||
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'
|
||||
defusedxml==0.8.0rc2; python_version >= '3.6'
|
||||
django==5.0.1
|
||||
django-cors-headers==4.3.1
|
||||
django-extra-fields==3.0.2
|
||||
django-resized==1.0.2
|
||||
django-simple-history==3.4.0
|
||||
django-templated-mail==1.1.1
|
||||
django-unfold==0.18.1
|
||||
djangorestframework==3.14.0
|
||||
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'
|
||||
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-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'
|
||||
oauthlib==3.2.2; python_version >= '3.6'
|
||||
pillow==10.2.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'
|
||||
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'
|
||||
python3-openid==3.2.0
|
||||
pytz==2023.3.post1
|
||||
pyyaml==6.0.1; python_version >= '3.6'
|
||||
referencing==0.32.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'
|
||||
uritemplate==4.1.1; python_version >= '3.6'
|
||||
urllib3==2.1.0; python_version >= '3.8'
|
||||
whitenoise==6.6.0
|
||||
-i https://pypi.org/simple
|
||||
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'
|
||||
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'
|
||||
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.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-silk==5.1.0
|
||||
django-simple-history==3.5.0
|
||||
django-storages==1.14.3
|
||||
django-templated-mail==1.1.1
|
||||
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
|
||||
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'
|
||||
jsonschema==4.22.0; python_version >= '3.8'
|
||||
jsonschema-specifications==2023.12.1; python_version >= '3.8'
|
||||
kombu==5.3.7
|
||||
msgpack==1.0.8; python_version >= '3.8'
|
||||
oauthlib==3.2.2; python_version >= '3.6'
|
||||
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
|
||||
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'
|
||||
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==2024.1
|
||||
pyyaml==6.0.1; python_version >= '3.6'
|
||||
redis==5.0.4
|
||||
referencing==0.35.1; python_version >= '3.8'
|
||||
requests==2.31.0; python_version >= '3.7'
|
||||
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[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
97
seed_data.json
Normal 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
36
start.sh
Normal 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
|
Loading…
Reference in a new issue