Initial commit
This commit is contained in:
commit
5b2b77efd3
59
README.md
Normal file
59
README.md
Normal file
@ -0,0 +1,59 @@
|
||||
# Envelope
|
||||
Template Django app.
|
||||
|
||||
## Setting up the environment
|
||||
Create the virtual environment, enable it, and install the dependencies.
|
||||
```shell
|
||||
$ python3 -m venv env
|
||||
$ source env/bin/activate
|
||||
(env) $ pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Local settings
|
||||
You'll need to copy the `app/local_settings.example.py` file to `app/local_settings.py`. The project won't start otherwise.
|
||||
```
|
||||
$ cp app/local_settings.example.py app/local_settings.py
|
||||
```
|
||||
|
||||
## Running database migrations
|
||||
Now we need to run the database migrations in order to get a working database.
|
||||
```shell
|
||||
(env) $ python manage.py migrate
|
||||
```
|
||||
|
||||
## Creating a superuser
|
||||
In order to access Django admin, we need a superuser.
|
||||
```shell
|
||||
(env) $ python manage.py createsuperuser
|
||||
Username: t2
|
||||
Email address: t2@google.com
|
||||
Password:
|
||||
Password (again):
|
||||
Superuser created successfully.
|
||||
```
|
||||
|
||||
## Running the server
|
||||
```shell
|
||||
(env) $ python manage.py runserver 8001
|
||||
Starting development server at http://127.0.0.1:8001/
|
||||
Quit the server with CONTROL-C.
|
||||
```
|
||||
As you've guessed, you can access it at http://127.0.0.1:8001/
|
||||
|
||||
## Troubleshooting
|
||||
Sometimes Django is difficult.
|
||||
|
||||
> `django.db.utils.OperationalError: no such table: core_user`
|
||||
|
||||
This means you haven't yet run the migrations. See above.
|
||||
|
||||
### Updating the models
|
||||
If you make changes to the database models, you will need to create new migrations.
|
||||
Do this like so:
|
||||
```shell
|
||||
(env) $ python manage.py makemigrations
|
||||
```
|
||||
Afterwards, we can apply them as normal.
|
||||
```shell
|
||||
(env) $ python manage.py migrate
|
||||
```
|
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
16
app/asgi.py
Normal file
16
app/asgi.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for app project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
|
||||
|
||||
application = get_asgi_application()
|
35
app/local_settings.example.py
Normal file
35
app/local_settings.example.py
Normal file
@ -0,0 +1,35 @@
|
||||
# URLs
|
||||
DOMAIN = "example.com"
|
||||
URL = f"https://{DOMAIN}"
|
||||
|
||||
# Access control
|
||||
ALLOWED_HOSTS = ["127.0.0.1", DOMAIN]
|
||||
|
||||
# CSRF
|
||||
CSRF_TRUSTED_ORIGINS = [URL]
|
||||
|
||||
# Stripe
|
||||
STRIPE_TEST = True
|
||||
STRIPE_API_KEY_TEST = ""
|
||||
STRIPE_PUBLIC_API_KEY_TEST = ""
|
||||
|
||||
STRIPE_API_KEY_PROD = ""
|
||||
STRIPE_PUBLIC_API_KEY_PROD = ""
|
||||
|
||||
STRIPE_ENDPOINT_SECRET = ""
|
||||
STATIC_ROOT = ""
|
||||
SECRET_KEY = "a"
|
||||
|
||||
STRIPE_ADMIN_COUPON = ""
|
||||
|
||||
DEBUG = True
|
||||
PROFILER = False
|
||||
|
||||
if DEBUG:
|
||||
import socket # only if you haven't already imported this
|
||||
|
||||
hostname, _, ips = socket.gethostbyname_ex(socket.gethostname())
|
||||
INTERNAL_IPS = [ip[: ip.rfind(".")] + ".1" for ip in ips] + [
|
||||
"127.0.0.1",
|
||||
"10.0.2.2",
|
||||
]
|
180
app/settings.py
Normal file
180
app/settings.py
Normal file
@ -0,0 +1,180 @@
|
||||
"""
|
||||
Django settings for app project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 4.0.6.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.0/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/4.0/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
# MOVED TO local_settings.py
|
||||
|
||||
ALLOWED_HOSTS = []
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"core",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"debug_toolbar",
|
||||
"template_profiler_panel",
|
||||
"django_htmx",
|
||||
"crispy_forms",
|
||||
"crispy_bulma",
|
||||
# "django_tables2",
|
||||
# "django_tables2_bulma_template",
|
||||
]
|
||||
CRISPY_TEMPLATE_PACK = "bulma"
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = ("bulma",)
|
||||
DJANGO_TABLES2_TEMPLATE = "django-tables2/bulma.html"
|
||||
|
||||
MIDDLEWARE = [
|
||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"django_htmx.middleware.HtmxMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "app.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(BASE_DIR, "core/templates")],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "app.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": f"django.contrib.auth.password_validation.{name}"}
|
||||
for name in [
|
||||
"UserAttributeSimilarityValidator",
|
||||
"MinimumLengthValidator",
|
||||
"CommonPasswordValidator",
|
||||
"NumericPasswordValidator",
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/4.0/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "UTC"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/4.0/howto/static-files/
|
||||
|
||||
STATIC_URL = "static/"
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
AUTH_USER_MODEL = "core.User"
|
||||
|
||||
LOGIN_REDIRECT_URL = "home"
|
||||
LOGOUT_REDIRECT_URL = "home"
|
||||
LOGIN_URL = "/accounts/login/"
|
||||
|
||||
# ALLOWED_PAYMENT_METHODS = ["bacs_debit", "card"]
|
||||
ALLOWED_PAYMENT_METHODS = ["card"]
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_PARSER_CLASSES": [
|
||||
"rest_framework.parsers.JSONParser",
|
||||
]
|
||||
}
|
||||
|
||||
INTERNAL_IPS = [
|
||||
"127.0.0.1",
|
||||
"10.1.10.11",
|
||||
]
|
||||
|
||||
DEBUG_TOOLBAR_PANELS = [
|
||||
"template_profiler_panel.panels.template.TemplateProfilerPanel",
|
||||
"debug_toolbar.panels.history.HistoryPanel",
|
||||
"debug_toolbar.panels.versions.VersionsPanel",
|
||||
"debug_toolbar.panels.timer.TimerPanel",
|
||||
"debug_toolbar.panels.settings.SettingsPanel",
|
||||
"debug_toolbar.panels.headers.HeadersPanel",
|
||||
"debug_toolbar.panels.request.RequestPanel",
|
||||
"debug_toolbar.panels.sql.SQLPanel",
|
||||
"debug_toolbar.panels.staticfiles.StaticFilesPanel",
|
||||
"debug_toolbar.panels.templates.TemplatesPanel",
|
||||
"debug_toolbar.panels.cache.CachePanel",
|
||||
"debug_toolbar.panels.signals.SignalsPanel",
|
||||
"debug_toolbar.panels.logging.LoggingPanel",
|
||||
"debug_toolbar.panels.redirects.RedirectsPanel",
|
||||
"debug_toolbar.panels.profiling.ProfilingPanel",
|
||||
]
|
||||
|
||||
from app.local_settings import * # noqa
|
||||
|
||||
if PROFILER: # noqa - trust me its there
|
||||
import pyroscope
|
||||
|
||||
pyroscope.configure(
|
||||
application_name="neptune",
|
||||
server_address="http://pyroscope:4040",
|
||||
auth_token=os.getenv("PYROSCOPE_AUTH_TOKEN", ""),
|
||||
# tags = {
|
||||
# "region": f'{os.getenv("REGION")}',
|
||||
# }
|
||||
)
|
48
app/urls.py
Normal file
48
app/urls.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""app URL Configuration
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/4.0/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from core.views import base, demo
|
||||
from core.views.callbacks import Callback
|
||||
|
||||
urlpatterns = [
|
||||
path("__debug__/", include("debug_toolbar.urls")),
|
||||
path("", base.Home.as_view(), name="home"),
|
||||
path("callback", Callback.as_view(), name="callback"),
|
||||
path("billing/", base.Billing.as_view(), name="billing"),
|
||||
path("order/<str:plan_name>/", base.Order.as_view(), name="order"),
|
||||
path(
|
||||
"cancel_subscription/<str:plan_name>/",
|
||||
base.Cancel.as_view(),
|
||||
name="cancel_subscription",
|
||||
),
|
||||
path(
|
||||
"success/", TemplateView.as_view(template_name="success.html"), name="success"
|
||||
),
|
||||
path("cancel/", TemplateView.as_view(template_name="cancel.html"), name="cancel"),
|
||||
path("portal", base.Portal.as_view(), name="portal"),
|
||||
path("admin/", admin.site.urls),
|
||||
path("accounts/", include("django.contrib.auth.urls")),
|
||||
path("accounts/signup/", base.Signup.as_view(), name="signup"),
|
||||
path("demo/modal/", demo.DemoModal.as_view(), name="modal"),
|
||||
path("demo/widget/", demo.DemoWidget.as_view(), name="widget"),
|
||||
path("demo/window/", demo.DemoWindow.as_view(), name="window"),
|
||||
|
||||
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
16
app/wsgi.py
Normal file
16
app/wsgi.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for app project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
|
||||
|
||||
application = get_wsgi_application()
|
10
core/__init__.py
Normal file
10
core/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
import stripe
|
||||
from django.conf import settings
|
||||
from redis import StrictRedis
|
||||
|
||||
r = StrictRedis(unix_socket_path="/var/run/redis/redis.sock", db=0)
|
||||
|
||||
if settings.STRIPE_TEST:
|
||||
stripe.api_key = settings.STRIPE_API_KEY_TEST
|
||||
else:
|
||||
stripe.api_key = settings.STRIPE_API_KEY_PROD
|
33
core/admin.py
Normal file
33
core/admin.py
Normal file
@ -0,0 +1,33 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
|
||||
from .forms import CustomUserCreationForm
|
||||
from .models import Plan, Session, User
|
||||
|
||||
|
||||
# Register your models here.
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
list_filter = ["plans"]
|
||||
model = User
|
||||
add_form = CustomUserCreationForm
|
||||
fieldsets = (
|
||||
*UserAdmin.fieldsets,
|
||||
(
|
||||
"Stripe information",
|
||||
{"fields": ("stripe_id",)},
|
||||
),
|
||||
(
|
||||
"Payment information",
|
||||
{
|
||||
"fields": (
|
||||
"plans",
|
||||
"last_payment",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
admin.site.register(User, CustomUserAdmin)
|
||||
admin.site.register(Plan)
|
||||
admin.site.register(Session)
|
6
core/apps.py
Normal file
6
core/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "core"
|
34
core/forms.py
Normal file
34
core/forms.py
Normal file
@ -0,0 +1,34 @@
|
||||
from django import forms
|
||||
from django.contrib.auth.forms import UserCreationForm
|
||||
|
||||
from .models import User
|
||||
|
||||
# Create your forms here.
|
||||
|
||||
|
||||
class NewUserForm(UserCreationForm):
|
||||
email = forms.EmailField(required=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = (
|
||||
"username",
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"password1",
|
||||
"password2",
|
||||
)
|
||||
|
||||
def save(self, commit=True):
|
||||
user = super(NewUserForm, self).save(commit=False)
|
||||
user.email = self.cleaned_data["email"]
|
||||
if commit:
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
class CustomUserCreationForm(UserCreationForm):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = "__all__"
|
0
core/lib/__init__.py
Normal file
0
core/lib/__init__.py
Normal file
65
core/lib/customers.py
Normal file
65
core/lib/customers.py
Normal file
@ -0,0 +1,65 @@
|
||||
import logging
|
||||
|
||||
import stripe
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def expand_name(first_name, last_name):
|
||||
"""
|
||||
Convert two name variables into one.
|
||||
Last name without a first name is ignored.
|
||||
"""
|
||||
name = None
|
||||
if first_name:
|
||||
name = first_name
|
||||
# We only want to put the last name if we have a first name
|
||||
if last_name:
|
||||
name += f" {last_name}"
|
||||
return name
|
||||
|
||||
|
||||
def get_or_create(email, first_name, last_name):
|
||||
"""
|
||||
Get a customer ID from Stripe if one with the given email exists.
|
||||
Create a customer if one does not.
|
||||
Raise an exception if two or more customers matching the given email exist.
|
||||
"""
|
||||
# Let's see if we're just missing the ID
|
||||
matching_customers = stripe.Customer.list(email=email, limit=2)
|
||||
if len(matching_customers) == 2:
|
||||
# Something is horribly wrong
|
||||
logger.error(f"Two customers found for email {email}")
|
||||
raise Exception(f"Two customers found for email {email}")
|
||||
|
||||
elif len(matching_customers) == 1:
|
||||
# We found a customer. Let's copy the ID
|
||||
customer = matching_customers["data"][0]
|
||||
customer_id = customer["id"]
|
||||
return customer_id
|
||||
|
||||
else:
|
||||
# We didn't find anything. Create the customer
|
||||
|
||||
# Create a name, since we have 2 variables which could be null
|
||||
name = expand_name(first_name, last_name)
|
||||
cast = {"email": email}
|
||||
if name:
|
||||
cast["name"] = name
|
||||
customer = stripe.Customer.create(**cast)
|
||||
logger.info(f"Created new Stripe customer {customer.id} with email {email}")
|
||||
|
||||
return customer.id
|
||||
|
||||
|
||||
def update_customer_fields(stripe_id, email=None, first_name=None, last_name=None):
|
||||
"""
|
||||
Update the customer fields in Stripe.
|
||||
"""
|
||||
if email:
|
||||
stripe.Customer.modify(stripe_id, email=email)
|
||||
logger.info(f"Modified Stripe customer {stripe_id} to have email {email}")
|
||||
if first_name or last_name:
|
||||
name = expand_name(first_name, last_name)
|
||||
stripe.Customer.modify(stripe_id, name=name)
|
||||
logger.info(f"Modified Stripe customer {stripe_id} to have email {name}")
|
19
core/lib/products.py
Normal file
19
core/lib/products.py
Normal file
@ -0,0 +1,19 @@
|
||||
from core.models import Plan
|
||||
|
||||
|
||||
def assemble_plan_map(product_id_filter=None):
|
||||
"""
|
||||
Get all the plans from the database and create an object Stripe wants.
|
||||
"""
|
||||
line_items = []
|
||||
for plan in Plan.objects.all():
|
||||
if product_id_filter:
|
||||
if plan.product_id != product_id_filter:
|
||||
continue
|
||||
line_items.append(
|
||||
{
|
||||
"price": plan.product_id,
|
||||
"quantity": 1,
|
||||
}
|
||||
)
|
||||
return line_items
|
78
core/migrations/0001_initial.py
Normal file
78
core/migrations/0001_initial.py
Normal file
@ -0,0 +1,78 @@
|
||||
# Generated by Django 4.0.6 on 2022-07-10 19:54
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
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 = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
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')),
|
||||
('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')),
|
||||
('stripe_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('last_payment', models.DateTimeField(blank=True, null=True)),
|
||||
('email', models.EmailField(max_length=254, unique=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')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Plan',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, unique=True)),
|
||||
('description', models.CharField(blank=True, max_length=1024, null=True)),
|
||||
('cost', models.IntegerField()),
|
||||
('product_id', models.CharField(blank=True, max_length=255, null=True, unique=True)),
|
||||
('image', models.CharField(blank=True, max_length=1024, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Session',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('request', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('subscription_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='core.plan')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='plans',
|
||||
field=models.ManyToManyField(blank=True, to='core.plan'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='user_permissions',
|
||||
field=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'),
|
||||
),
|
||||
]
|
18
core/migrations/0002_session_session.py
Normal file
18
core/migrations/0002_session_session.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.0.6 on 2022-10-12 09:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='session',
|
||||
name='session',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
0
core/migrations/__init__.py
Normal file
0
core/migrations/__init__.py
Normal file
88
core/models.py
Normal file
88
core/models.py
Normal file
@ -0,0 +1,88 @@
|
||||
import logging
|
||||
|
||||
import stripe
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
|
||||
from core.lib.customers import get_or_create, update_customer_fields
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Plan(models.Model):
|
||||
name = models.CharField(max_length=255, unique=True)
|
||||
description = models.CharField(max_length=1024, null=True, blank=True)
|
||||
cost = models.IntegerField()
|
||||
product_id = models.CharField(max_length=255, unique=True, null=True, blank=True)
|
||||
image = models.CharField(max_length=1024, null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} (£{self.cost})"
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
# Stripe customer ID
|
||||
stripe_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
last_payment = models.DateTimeField(null=True, blank=True)
|
||||
plans = models.ManyToManyField(Plan, blank=True)
|
||||
email = models.EmailField(unique=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._original = self
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Override the save function to create a Stripe customer.
|
||||
"""
|
||||
if not self.stripe_id: # stripe ID not stored
|
||||
self.stripe_id = get_or_create(self.email, self.first_name, self.last_name)
|
||||
|
||||
to_update = {}
|
||||
if self.email != self._original.email:
|
||||
to_update["email"] = self.email
|
||||
if self.first_name != self._original.first_name:
|
||||
to_update["first_name"] = self.first_name
|
||||
if self.last_name != self._original.last_name:
|
||||
to_update["last_name"] = self.last_name
|
||||
|
||||
update_customer_fields(self.stripe_id, **to_update)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
if self.stripe_id:
|
||||
stripe.Customer.delete(self.stripe_id)
|
||||
logger.info(f"Deleted Stripe customer {self.stripe_id}")
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
def has_plan(self, plan):
|
||||
plan_list = [plan.name for plan in self.plans.all()]
|
||||
return plan in plan_list
|
||||
|
||||
|
||||
class Session(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
request = models.CharField(max_length=255, null=True, blank=True)
|
||||
session = models.CharField(max_length=255, null=True, blank=True)
|
||||
subscription_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
plan = models.ForeignKey(Plan, null=True, blank=True, on_delete=models.CASCADE)
|
||||
|
||||
|
||||
# class Perms(models.Model):
|
||||
# class Meta:
|
||||
# permissions = (
|
||||
# ("bypass_hashing", "Can bypass field hashing"), #
|
||||
# ("bypass_blacklist", "Can bypass the blacklist"), #
|
||||
# ("bypass_encryption", "Can bypass field encryption"), #
|
||||
# ("bypass_obfuscation", "Can bypass field obfuscation"), #
|
||||
# ("bypass_delay", "Can bypass data delay"), #
|
||||
# ("bypass_randomisation", "Can bypass data randomisation"), #
|
||||
# ("post_irc", "Can post to IRC"),
|
||||
# ("post_discord", "Can post to Discord"),
|
||||
# ("query_search", "Can search with query strings"), #
|
||||
# ("use_insights", "Can use the Insights page"),
|
||||
# ("index_int", "Can use the internal index"),
|
||||
# ("index_meta", "Can use the meta index"),
|
||||
# ("restricted_sources", "Can access restricted sources"),
|
||||
# )
|
2
core/static/css/bulma-tooltip.min.css
vendored
Normal file
2
core/static/css/bulma-tooltip.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
core/static/css/bulma.min.css
vendored
Normal file
1
core/static/css/bulma.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
core/static/css/gridstack.min.css
vendored
Normal file
1
core/static/css/gridstack.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
6
core/static/css/icons.css
Normal file
6
core/static/css/icons.css
Normal file
File diff suppressed because one or more lines are too long
3
core/static/js/gridstack-all.js
Normal file
3
core/static/js/gridstack-all.js
Normal file
File diff suppressed because one or more lines are too long
16
core/static/js/gridstack.min.js
vendored
Normal file
16
core/static/js/gridstack.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
core/static/js/htmx.min.js
vendored
Normal file
1
core/static/js/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
core/static/js/hyperscript.min.js
vendored
Normal file
1
core/static/js/hyperscript.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
core/static/js/magnet.min.js
vendored
Normal file
2
core/static/js/magnet.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
27
core/static/js/remove-me.js
Normal file
27
core/static/js/remove-me.js
Normal file
@ -0,0 +1,27 @@
|
||||
(function(){
|
||||
function maybeRemoveMe(elt) {
|
||||
var timing = elt.getAttribute("remove-me") || elt.getAttribute("data-remove-me");
|
||||
if (timing) {
|
||||
setTimeout(function () {
|
||||
elt.parentElement.removeChild(elt);
|
||||
}, htmx.parseInterval(timing));
|
||||
}
|
||||
}
|
||||
|
||||
htmx.defineExtension('remove-me', {
|
||||
onEvent: function (name, evt) {
|
||||
if (name === "htmx:afterProcessNode") {
|
||||
var elt = evt.detail.elt;
|
||||
if (elt.getAttribute) {
|
||||
maybeRemoveMe(elt);
|
||||
if (elt.querySelectorAll) {
|
||||
var children = elt.querySelectorAll("[remove-me], [data-remove-me]");
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
maybeRemoveMe(children[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
20
core/static/logo.svg
Normal file
20
core/static/logo.svg
Normal file
@ -0,0 +1,20 @@
|
||||
<svg width="122mm" height="45.7mm" version="1.1" viewBox="0 0 122 45.7" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<mask id="mask14115" maskUnits="userSpaceOnUse">
|
||||
<path d="m34.4 124 54.9 14.5-45.1 30.7z" fill="#fff" fill-opacity=".996"/>
|
||||
</mask>
|
||||
<mask id="mask14119" maskUnits="userSpaceOnUse">
|
||||
<path d="m34.4 124 54.9 14.5-45.1 30.7z" fill="#fff" fill-opacity=".996"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<g transform="translate(-34.1 -98.8)">
|
||||
<path d="m34.4 124c66.1-55.6 122-2.65 122-2.65s0 34.4-60.9 18.5l-60.9-15.9z" fill-opacity=".996" stroke="#000" stroke-width=".265px"/>
|
||||
<path d="m124 130c-44.6-37.4-82-1.78-82-1.78s0 23.2 41 12.5l41-10.7z" fill="#fff"/>
|
||||
<g mask="url(#mask14119)">
|
||||
<g id="g13912">
|
||||
<path d="m124 130c-44.6-37.4-82-1.78-82-1.78s0 23.2 41 12.5l41-10.7z" fill="#b9b9b9"/>
|
||||
</g>
|
||||
</g>
|
||||
<use width="100%" height="100%" mask="url(#mask14115)" xlink:href="#g13912"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 956 B |
15
core/static/manifest.webmanifest
Normal file
15
core/static/manifest.webmanifest
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"background_color": "white",
|
||||
"description": "Search for anything.",
|
||||
"display": "fullscreen",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/logo.png",
|
||||
"sizes": "800x800",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"name": "Pathogen Data Analytics",
|
||||
"short_name": "Pathogen",
|
||||
"start_url": "/"
|
||||
}
|
44
core/static/modal.js
Normal file
44
core/static/modal.js
Normal file
@ -0,0 +1,44 @@
|
||||
// var modal = document.querySelector('.modal'); // assuming you have only 1
|
||||
var modal = document.getElementById("modal");
|
||||
var html = document.querySelector('html');
|
||||
|
||||
var disableModal = function() {
|
||||
modal.classList.remove('is-active');
|
||||
html.classList.remove('is-clipped');
|
||||
var modal_refresh = document.getElementsByClassName("modal-refresh");
|
||||
for(var i = 0; i < modal_refresh.length; i++) {
|
||||
modal_refresh[i].remove();
|
||||
}
|
||||
}
|
||||
|
||||
var elements = document.querySelectorAll('.modal-background');
|
||||
for(var i = 0; i < elements.length; i++) {
|
||||
elements[i].addEventListener('click', function(e) {
|
||||
// elements[i].preventDefault();
|
||||
disableModal();
|
||||
});
|
||||
}
|
||||
|
||||
var elements = document.querySelectorAll('.modal-close');
|
||||
for(var i = 0; i < elements.length; i++) {
|
||||
elements[i].addEventListener('click', function(e) {
|
||||
// elements[i].preventDefault();
|
||||
disableModal();
|
||||
});
|
||||
}
|
||||
|
||||
function activateButtons() {
|
||||
var elements = document.querySelectorAll('.modal-close-button');
|
||||
for(var i = 0; i < elements.length; i++) {
|
||||
elements[i].addEventListener('click', function(e) {
|
||||
// elements[i].preventDefault();
|
||||
disableModal();
|
||||
});
|
||||
}
|
||||
}
|
||||
activateButtons();
|
||||
// modal.querySelector('.modal-close-button').addEventListener('click', function(e) {
|
||||
// e.preventDefault();
|
||||
// modal.classList.remove('is-active');
|
||||
// html.classList.remove('is-clipped');
|
||||
// });
|
289
core/templates/base.html
Normal file
289
core/templates/base.html
Normal file
@ -0,0 +1,289 @@
|
||||
{% load static %}
|
||||
{% load has_plan %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en-GB">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>XF - {{ request.path_info }}</title>
|
||||
<link rel="shortcut icon" href="{% static 'favicon.ico' %}">
|
||||
<link rel="manifest" href="{% static 'manifest.webmanifest' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/bulma.min.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/bulma-tooltip.min.css' %}">
|
||||
<link rel="stylesheet" href="https://site-assets.fontawesome.com/releases/v6.1.1/css/all.css">
|
||||
<link rel="stylesheet" href="{% static 'css/gridstack.min.css' %}">
|
||||
<script src="{% static 'js/htmx.min.js' %}" integrity="sha384-cZuAZ+ZbwkNRnrKi05G/fjBX+azI9DNOkNYysZ0I/X5ZFgsmMiBXgDZof30F5ofc" crossorigin="anonymous"></script>
|
||||
<script defer src="{% static 'js/hyperscript.min.js' %}" integrity="sha384-6GYN8BDHOJkkru6zcpGOUa//1mn+5iZ/MyT6mq34WFIpuOeLF52kSi721q0SsYF9" crossorigin="anonymous"></script>
|
||||
<script defer src="{% static 'js/remove-me.js' %}" integrity="sha384-6fHcFNoQ8QEI3ZDgw9Z/A6Brk64gF7AnFbLgdrumo8/kBbsKQ/wo7wPegj5WkzuG" crossorigin="anonymous"></script>
|
||||
<script src="{% static 'js/gridstack-all.js' %}"></script>
|
||||
<script defer src="{% static 'js/magnet.min.js' %}"></script>
|
||||
<script>
|
||||
document.addEventListener("restore-scroll", function(event) {
|
||||
var scrollpos = localStorage.getItem('scrollpos');
|
||||
if (scrollpos) {
|
||||
window.scrollTo(0, scrollpos)
|
||||
};
|
||||
});
|
||||
|
||||
document.addEventListener("htmx:beforeSwap", function(event) {
|
||||
localStorage.setItem('scrollpos', window.scrollY);
|
||||
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Get all "navbar-burger" elements
|
||||
const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
|
||||
|
||||
// Add a click event on each of them
|
||||
$navbarBurgers.forEach( el => {
|
||||
el.addEventListener('click', () => {
|
||||
|
||||
// Get the target from the "data-target" attribute
|
||||
const target = el.dataset.target;
|
||||
const $target = document.getElementById(target);
|
||||
|
||||
// Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
|
||||
el.classList.toggle('is-active');
|
||||
$target.classList.toggle('is-active');
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.icon { border-bottom: 0px !important;}
|
||||
.wrap {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.nowrap-parent {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nowrap-child {
|
||||
display: inline-block;
|
||||
}
|
||||
.htmx-indicator{
|
||||
opacity:0;
|
||||
transition: opacity 500ms ease-in;
|
||||
}
|
||||
.htmx-request .htmx-indicator{
|
||||
opacity:1
|
||||
}
|
||||
.htmx-request.htmx-indicator{
|
||||
opacity:1
|
||||
}
|
||||
|
||||
.tooltiptext {
|
||||
visibility: hidden;
|
||||
background-color: black;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
padding: 5px 0;
|
||||
border-radius: 6px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.rounded-tooltip:hover .tooltiptext {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.table {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
tr {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
cursor:pointer;
|
||||
background-color:rgba(221, 224, 255, 0.3) !important;
|
||||
}
|
||||
|
||||
a.panel-block {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
a.panel-block:hover {
|
||||
cursor:pointer;
|
||||
background-color:rgba(221, 224, 255, 0.3) !important;
|
||||
}
|
||||
|
||||
.panel, .box, .modal {
|
||||
background-color:rgba(250, 250, 250, 0.5) !important;
|
||||
}
|
||||
.modal, .modal.box{
|
||||
background-color:rgba(210, 210, 210, 0.9) !important;
|
||||
}
|
||||
.modal-background{
|
||||
background-color:rgba(255, 255, 255, 0.3) !important;
|
||||
}
|
||||
|
||||
.has-background-grey-lighter{
|
||||
background-color:rgba(219, 219, 219, 0.5) !important;
|
||||
}
|
||||
.navbar {
|
||||
background-color:rgba(0, 0, 0, 0.03) !important;
|
||||
}
|
||||
|
||||
.grid-stack-item-content {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-block {
|
||||
overflow-y:auto;
|
||||
overflow-x:auto;
|
||||
min-height: 90%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.floating-window {
|
||||
/* background-color:rgba(210, 210, 210, 0.6) !important; */
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: hidden !important;
|
||||
max-height: 300px;
|
||||
z-index: 9000;
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
left: 50px;
|
||||
}
|
||||
|
||||
.floating-window .panel {
|
||||
background-color:rgba(250, 250, 250, 0.8) !important;
|
||||
}
|
||||
|
||||
.float-right {
|
||||
float: right;
|
||||
padding-right: 5px;
|
||||
padding-left: 5px;
|
||||
}
|
||||
.grid-stack-item:hover .ui-resizable-handle {
|
||||
display: block !important;
|
||||
}
|
||||
.ui-resizable-handle {
|
||||
z-index: 39 !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar" role="navigation" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item" href="{% url 'home' %}">
|
||||
<img src="{% static 'logo.svg' %}" width="112" height="28" alt="logo">
|
||||
</a>
|
||||
|
||||
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="bar">
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="bar" class="navbar-menu">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="{% url 'home' %}">
|
||||
Home
|
||||
</a>
|
||||
{% if user.is_authenticated %}
|
||||
<a class="navbar-item" href="{% url 'billing' %}">
|
||||
Billing
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if user.is_superuser %}
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link">
|
||||
Admin
|
||||
</a>
|
||||
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="#">
|
||||
Admin1
|
||||
</a>
|
||||
<a class="navbar-item" href="#">
|
||||
Admin2
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<a class="navbar-item add-button">
|
||||
Install
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item">
|
||||
<div class="buttons">
|
||||
{% if not user.is_authenticated %}
|
||||
<a class="button is-info" href="{% url 'signup' %}">
|
||||
<strong>Sign up</strong>
|
||||
</a>
|
||||
<a class="button is-light" href="{% url 'login' %}">
|
||||
Log in
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<a class="button is-dark" href="{% url 'logout' %}">Logout</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<script>
|
||||
let deferredPrompt;
|
||||
const addBtn = document.querySelector('.add-button');
|
||||
addBtn.style.display = 'none';
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
// Prevent Chrome 67 and earlier from automatically showing the prompt
|
||||
e.preventDefault();
|
||||
// Stash the event so it can be triggered later.
|
||||
deferredPrompt = e;
|
||||
// Update UI to notify the user they can add to home screen
|
||||
addBtn.style.display = 'block';
|
||||
|
||||
addBtn.addEventListener('click', (e) => {
|
||||
// hide our user interface that shows our A2HS button
|
||||
addBtn.style.display = 'none';
|
||||
// Show the prompt
|
||||
deferredPrompt.prompt();
|
||||
// Wait for the user to respond to the prompt
|
||||
deferredPrompt.userChoice.then((choiceResult) => {
|
||||
if (choiceResult.outcome === 'accepted') {
|
||||
console.log('User accepted the A2HS prompt');
|
||||
} else {
|
||||
console.log('User dismissed the A2HS prompt');
|
||||
}
|
||||
deferredPrompt = null;
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% block outer_content %}
|
||||
{% endblock %}
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
46
core/templates/billing.html
Normal file
46
core/templates/billing.html
Normal file
@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<article class="panel is-info">
|
||||
<p class="panel-heading">
|
||||
User information
|
||||
</p>
|
||||
<a class="panel-block is-active">
|
||||
<span class="panel-icon">
|
||||
<i class="fas fa-id-card" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="tag is-info">{{ user.first_name }} {{ user.last_name }}</span>
|
||||
</a>
|
||||
<a class="panel-block">
|
||||
<span class="panel-icon">
|
||||
<i class="fas fa-binary" aria-hidden="true"></i>
|
||||
</span>
|
||||
{% for plan in user.plans.all %}
|
||||
<span class="tag is-info">{{ plan.name }}</span>
|
||||
{% endfor %}
|
||||
</a>
|
||||
<a class="panel-block">
|
||||
<span class="panel-icon">
|
||||
<i class="fas fa-credit-card" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="tag">{{ user.last_payment }}</span>
|
||||
</a>
|
||||
<a class="panel-block" href="{% url 'portal' %}">
|
||||
<span class="panel-icon">
|
||||
<i class="fa-brands fa-stripe-s" aria-hidden="true"></i>
|
||||
</span>
|
||||
Subscription management
|
||||
</a>
|
||||
</article>
|
||||
<div class="box">
|
||||
<h1 class="subtitle">
|
||||
This product is currently free. You may cancel any plans above.
|
||||
</h1>
|
||||
</div>
|
||||
<div class="box">
|
||||
<h1 class="subtitle">
|
||||
You cannot pay for access to the raw data. It is hashed to preserve privacy.
|
||||
</h1>
|
||||
</div>
|
||||
{# {% include "partials/product-list.html" %} #}
|
||||
{% endblock %}
|
||||
|
9
core/templates/cancel.html
Normal file
9
core/templates/cancel.html
Normal file
@ -0,0 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<section>
|
||||
<p>Forgot to add something to your cart? Shop around then come back to pay!</p>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
7
core/templates/denied.html
Normal file
7
core/templates/denied.html
Normal file
@ -0,0 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="title">Access denied</h1>
|
||||
<h2 class="subtitle">Sorry, you do not have the necessary permissions to view this page.</h2>
|
||||
|
||||
{% endblock %}
|
95
core/templates/index.html
Normal file
95
core/templates/index.html
Normal file
@ -0,0 +1,95 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load joinsep %}
|
||||
{% block outer_content %}
|
||||
|
||||
<div class="grid-stack" id="grid-stack-main">
|
||||
<div class="grid-stack-item" gs-w="7" gs-h="10" gs-y="0" gs-x="1">
|
||||
<div class="grid-stack-item-content">
|
||||
<nav class="panel">
|
||||
<p class="panel-heading" style="padding: .2em; line-height: .5em;">
|
||||
<i class="fa-solid fa-arrows-up-down-left-right has-text-grey-light"></i>
|
||||
Home
|
||||
</p>
|
||||
<article class="panel-block is-active">
|
||||
{% include 'window-content/main.html' %}
|
||||
</article>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var grid = GridStack.init({
|
||||
cellHeight: 20,
|
||||
cellWidth: 50,
|
||||
cellHeightUnit: 'px',
|
||||
auto: true,
|
||||
float: true,
|
||||
draggable: {handle: '.panel-heading', scroll: false, appendTo: 'body'},
|
||||
removable: false,
|
||||
animate: true,
|
||||
});
|
||||
// GridStack.init();
|
||||
|
||||
// a widget is ready to be loaded
|
||||
document.addEventListener('load-widget', function(event) {
|
||||
let container = htmx.find('#widget');
|
||||
// get the scripts, they won't be run on the new element so we need to eval them
|
||||
var scripts = htmx.findAll(container, "script");
|
||||
let widgetelement = container.firstElementChild.cloneNode(true);
|
||||
var new_id = widgetelement.id;
|
||||
|
||||
// check if there's an existing element like the one we want to swap
|
||||
let grid_element = htmx.find('#grid-stack-main');
|
||||
let existing_widget = htmx.find(grid_element, "#"+new_id);
|
||||
|
||||
// get the size and position attributes
|
||||
if (existing_widget) {
|
||||
let attrs = existing_widget.getAttributeNames();
|
||||
for (let i = 0, len = attrs.length; i < len; i++) {
|
||||
if (attrs[i].startsWith('gs-')) { // only target gridstack attributes
|
||||
widgetelement.setAttribute(attrs[i], existing_widget.getAttribute(attrs[i]));
|
||||
}
|
||||
}
|
||||
}
|
||||
// clear the queue element
|
||||
container.outerHTML = "";
|
||||
grid.addWidget(widgetelement);
|
||||
|
||||
// re-create the HTMX JS listeners, otherwise HTMX won't work inside the grid
|
||||
htmx.process(widgetelement);
|
||||
|
||||
// update the size of the widget according to its content
|
||||
var added_widget = htmx.find(grid_element, "#"+new_id);
|
||||
var itemContent = htmx.find(added_widget, ".control");
|
||||
var scrollheight = itemContent.scrollHeight+80;
|
||||
var verticalmargin = 0;
|
||||
var cellheight = grid.opts.cellHeight;
|
||||
var height = Math.ceil((scrollheight + verticalmargin) / (cellheight + verticalmargin));
|
||||
var opts = {
|
||||
h: height,
|
||||
}
|
||||
grid.update(
|
||||
added_widget,
|
||||
opts
|
||||
);
|
||||
|
||||
// run the JS scripts inside the added element again
|
||||
for (var i = 0; i < scripts.length; i++) {
|
||||
eval(scripts[i].innerHTML);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="modals-here">
|
||||
</div>
|
||||
<div id="items-here">
|
||||
</div>
|
||||
<div id="widgets-here" style="display: none;">
|
||||
</div>
|
||||
<script>
|
||||
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
5
core/templates/modals/modal.html
Normal file
5
core/templates/modals/modal.html
Normal file
@ -0,0 +1,5 @@
|
||||
{% extends 'wm/modal.html' %}
|
||||
|
||||
{% block modal_content %}
|
||||
{% include 'window-content/main.html' %}
|
||||
{% endblock %}
|
5
core/templates/partials/notify.html
Normal file
5
core/templates/partials/notify.html
Normal file
@ -0,0 +1,5 @@
|
||||
{% if message is not None %}
|
||||
<div class="notification is-{{ class }}" hx-ext="remove-me" remove-me="3s">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endif %}
|
48
core/templates/partials/product-list.html
Normal file
48
core/templates/partials/product-list.html
Normal file
@ -0,0 +1,48 @@
|
||||
{% load static %}
|
||||
|
||||
{% for plan in plans %}
|
||||
|
||||
|
||||
<div class="box">
|
||||
<article class="media">
|
||||
<div class="media-left">
|
||||
<figure class="image is-64x64">
|
||||
<img src="{% static plan.image %}" alt="Image">
|
||||
</figure>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<div class="content">
|
||||
<p>
|
||||
<strong>{{ plan.name }}</strong> <small>£{{ plan.cost }}</small>
|
||||
{% if plan in user_plans %}
|
||||
<i class="fas fa-check" aria-hidden="true"></i>
|
||||
{% endif %}
|
||||
<br>
|
||||
{{ plan.description }}
|
||||
</p>
|
||||
</div>
|
||||
<nav class="level is-mobile">
|
||||
<div class="level-left">
|
||||
{% if plan not in user_plans %}
|
||||
<a class="level-item" href="/order/{{ plan.name }}">
|
||||
<span class="icon is-small has-text-success">
|
||||
<i class="fas fa-plus" aria-hidden="true"></i>
|
||||
</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if plan in user_plans %}
|
||||
<a class="level-item" href="/cancel_subscription/{{ plan.name }}">
|
||||
<span class="icon is-small has-text-info">
|
||||
<i class="fas fa-cancel" aria-hidden="true"></i>
|
||||
</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
|
25
core/templates/registration/login.html
Normal file
25
core/templates/registration/login.html
Normal file
@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<section class="hero is-fullheight">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-5-tablet is-4-desktop is-3-widescreen">
|
||||
<form method="POST" class="box">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<div class="field">
|
||||
<button class="button is-success">
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
0
core/templates/registration/logout.html
Normal file
0
core/templates/registration/logout.html
Normal file
25
core/templates/registration/signup.html
Normal file
25
core/templates/registration/signup.html
Normal file
@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<section class="hero is-fullheight">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-5-tablet is-4-desktop is-3-widescreen">
|
||||
<form method="POST" class="box">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<div class="field">
|
||||
<button class="button is-success">
|
||||
Sign up
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
9
core/templates/subscriptioncancel.html
Normal file
9
core/templates/subscriptioncancel.html
Normal file
@ -0,0 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<section>
|
||||
<p>Subscription {{ plan }} cancelled!</p>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
11
core/templates/success.html
Normal file
11
core/templates/success.html
Normal file
@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="title">XF</h1>
|
||||
<div class="container">
|
||||
<h2 class="subtitle">Thank you for your order!</h2>
|
||||
<div class="col">
|
||||
<h2 class="subtitle">The customer portal will be available <a href="{% url 'billing' %} ">in your profile</a> shortly.</h2>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
20
core/templates/widgets/widget.html
Normal file
20
core/templates/widgets/widget.html
Normal file
@ -0,0 +1,20 @@
|
||||
{% extends 'wm/widget.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block widget_options %}
|
||||
gs-w="10" gs-h="1" gs-y="10" gs-x="1"
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
Widget
|
||||
{% endblock %}
|
||||
|
||||
{% block close_button %}
|
||||
<i
|
||||
class="fa-solid fa-xmark has-text-grey-light float-right"
|
||||
onclick='grid.removeWidget("widget-{{ unique }}"); //grid.compact();'></i>
|
||||
{% endblock %}
|
||||
|
||||
{% block panel_content %}
|
||||
{% include 'window-content/main.html' %}
|
||||
{% endblock %}
|
44
core/templates/window-content/main.html
Normal file
44
core/templates/window-content/main.html
Normal file
@ -0,0 +1,44 @@
|
||||
<p class="title">This is a demo panel</p>
|
||||
|
||||
<div class="buttons">
|
||||
<button
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{% url 'modal' %}"
|
||||
hx-trigger="click"
|
||||
hx-target="#modals-here"
|
||||
class="button is-info">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-list"></i>
|
||||
</span>
|
||||
<span>Open modal</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{% url 'widget' %}"
|
||||
hx-trigger="click"
|
||||
hx-target="#widgets-here"
|
||||
class="button is-info">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-list"></i>
|
||||
</span>
|
||||
<span>Open widget</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{% url 'window' %}"
|
||||
hx-trigger="click"
|
||||
hx-target="#items-here"
|
||||
hx-swap="afterend"
|
||||
class="button is-info">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-list"></i>
|
||||
</span>
|
||||
<span>Open window</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
9
core/templates/windows/window.html
Normal file
9
core/templates/windows/window.html
Normal file
@ -0,0 +1,9 @@
|
||||
{% extends 'wm/magnet.html' %}
|
||||
|
||||
{% block heading %}
|
||||
Window
|
||||
{% endblock %}
|
||||
|
||||
{% block panel_content %}
|
||||
{% include 'window-content/main.html' %}
|
||||
{% endblock %}
|
8
core/templates/wm/magnet.html
Normal file
8
core/templates/wm/magnet.html
Normal file
@ -0,0 +1,8 @@
|
||||
<magnet-block attract-distance="10" align-to="outer|center" class="floating-window">
|
||||
{% extends 'wm/panel.html' %}
|
||||
{% block heading %}
|
||||
{% endblock %}
|
||||
|
||||
{% block panel_content %}
|
||||
{% endblock %}
|
||||
</magnet-block>
|
19
core/templates/wm/modal.html
Normal file
19
core/templates/wm/modal.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% load static %}
|
||||
|
||||
<script src="{% static 'modal.js' %}"></script>
|
||||
{% block scripts %}
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{% endblock %}
|
||||
|
||||
<div id="modal" class="modal is-active is-clipped">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-content">
|
||||
<div class="box">
|
||||
{% block modal_content %}
|
||||
{% endblock %}
|
||||
<button class="modal-close is-large" aria-label="close"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
19
core/templates/wm/panel.html
Normal file
19
core/templates/wm/panel.html
Normal file
@ -0,0 +1,19 @@
|
||||
|
||||
<nav class="panel">
|
||||
<p class="panel-heading" style="padding: .2em; line-height: .5em;">
|
||||
<i class="fa-solid fa-arrows-up-down-left-right has-text-grey-light"></i>
|
||||
{% block close_button %}
|
||||
<i
|
||||
class="fa-solid fa-xmark has-text-grey-light float-right"
|
||||
data-script="on click remove the closest <nav/>"></i>
|
||||
{% endblock %}
|
||||
{% block heading %}
|
||||
{% endblock %}
|
||||
</p>
|
||||
<article class="panel-block is-active">
|
||||
<div class="control">
|
||||
{% block panel_content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</article>
|
||||
</nav>
|
37
core/templates/wm/widget.html
Normal file
37
core/templates/wm/widget.html
Normal file
@ -0,0 +1,37 @@
|
||||
<div id="widget">
|
||||
<div id="widget-{{ unique }}" class="grid-stack-item" {% block widget_options %}{% endblock %}>
|
||||
<div class="grid-stack-item-content">
|
||||
|
||||
<nav class="panel">
|
||||
<p class="panel-heading" style="padding: .2em; line-height: .5em;">
|
||||
<i class="fa-solid fa-arrows-up-down-left-right has-text-grey-light"></i>
|
||||
{% block close_button %}
|
||||
<i
|
||||
class="fa-solid fa-xmark has-text-grey-light float-right"
|
||||
onclick='grid.removeWidget("widget-{{ unique }}");'></i>
|
||||
{% endblock %}
|
||||
<i
|
||||
class="fa-solid fa-arrows-minimize has-text-grey-light float-right"
|
||||
onclick='grid.compact();'></i>
|
||||
{% block heading %}
|
||||
{% endblock %}
|
||||
</p>
|
||||
<article class="panel-block is-active">
|
||||
<div class="control">
|
||||
{% block panel_content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</article>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{% block custom_script %}
|
||||
{% endblock %}
|
||||
var widget_event = new Event('load-widget');
|
||||
document.dispatchEvent(widget_event);
|
||||
</script>
|
||||
{% block custom_end %}
|
||||
{% endblock %}
|
0
core/templatetags/__init__.py
Normal file
0
core/templatetags/__init__.py
Normal file
11
core/templatetags/has_plan.py
Normal file
11
core/templatetags/has_plan.py
Normal file
@ -0,0 +1,11 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def has_plan(user, plan_name):
|
||||
if not hasattr(user, "plans"):
|
||||
return False
|
||||
plan_list = [plan.name for plan in user.plans.all()]
|
||||
return plan_name in plan_list
|
8
core/templatetags/index.py
Normal file
8
core/templatetags/index.py
Normal file
@ -0,0 +1,8 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def index(h, key):
|
||||
return h[key]
|
8
core/templatetags/joinsep.py
Normal file
8
core/templatetags/joinsep.py
Normal file
@ -0,0 +1,8 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def joinsep(lst, sep):
|
||||
return sep.join(lst)
|
8
core/templatetags/nsep.py
Normal file
8
core/templatetags/nsep.py
Normal file
@ -0,0 +1,8 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def nsep(lst):
|
||||
return "\n".join(lst)
|
10
core/templatetags/urlsafe.py
Normal file
10
core/templatetags/urlsafe.py
Normal file
@ -0,0 +1,10 @@
|
||||
import urllib.parse
|
||||
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def urlsafe(h):
|
||||
return urllib.parse.quote(h, safe="")
|
3
core/tests.py
Normal file
3
core/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
# from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
0
core/util/__init__.py
Normal file
0
core/util/__init__.py
Normal file
69
core/util/logs.py
Normal file
69
core/util/logs.py
Normal file
@ -0,0 +1,69 @@
|
||||
# Other library imports
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("util")
|
||||
|
||||
debug = True
|
||||
|
||||
# Color definitions
|
||||
BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8)
|
||||
COLORS = {
|
||||
"WARNING": YELLOW,
|
||||
"INFO": WHITE,
|
||||
"DEBUG": BLUE,
|
||||
"CRITICAL": YELLOW,
|
||||
"ERROR": RED,
|
||||
}
|
||||
RESET_SEQ = "\033[0m"
|
||||
COLOR_SEQ = "\033[1;%dm"
|
||||
BOLD_SEQ = "\033[1m"
|
||||
|
||||
|
||||
def formatter_message(message, use_color=True):
|
||||
if use_color:
|
||||
message = message.replace("$RESET", RESET_SEQ).replace("$BOLD", BOLD_SEQ)
|
||||
else:
|
||||
message = message.replace("$RESET", "").replace("$BOLD", "")
|
||||
return message
|
||||
|
||||
|
||||
class ColoredFormatter(logging.Formatter):
|
||||
def __init__(self, msg, use_color=True):
|
||||
logging.Formatter.__init__(self, msg)
|
||||
self.use_color = use_color
|
||||
|
||||
def format(self, record):
|
||||
levelname = record.levelname
|
||||
if self.use_color and levelname in COLORS:
|
||||
levelname_color = (
|
||||
COLOR_SEQ % (30 + COLORS[levelname]) + levelname + RESET_SEQ
|
||||
)
|
||||
record.levelname = levelname_color
|
||||
return logging.Formatter.format(self, record)
|
||||
|
||||
|
||||
def get_logger(name):
|
||||
|
||||
# Define the logging format
|
||||
FORMAT = "%(asctime)s %(levelname)18s $BOLD%(name)13s$RESET - %(message)s"
|
||||
COLOR_FORMAT = formatter_message(FORMAT, True)
|
||||
color_formatter = ColoredFormatter(COLOR_FORMAT)
|
||||
# formatter = logging.Formatter(
|
||||
|
||||
# Why is this so complicated?
|
||||
ch = logging.StreamHandler()
|
||||
ch.setLevel(logging.INFO)
|
||||
# ch.setFormatter(formatter)
|
||||
ch.setFormatter(color_formatter)
|
||||
|
||||
# Define the logger on the base class
|
||||
log = logging.getLogger(name)
|
||||
log.setLevel(logging.INFO)
|
||||
if debug:
|
||||
log.setLevel(logging.DEBUG)
|
||||
ch.setLevel(logging.DEBUG)
|
||||
|
||||
# Add the handler and stop it being silly and printing everything twice
|
||||
log.addHandler(ch)
|
||||
log.propagate = False
|
||||
return log
|
0
core/views.py
Normal file
0
core/views.py
Normal file
0
core/views/__init__.py
Normal file
0
core/views/__init__.py
Normal file
90
core/views/base.py
Normal file
90
core/views/base.py
Normal file
@ -0,0 +1,90 @@
|
||||
import logging
|
||||
|
||||
import stripe
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.views import View
|
||||
from django.views.generic.edit import CreateView
|
||||
|
||||
from core.forms import NewUserForm
|
||||
from core.lib.products import assemble_plan_map
|
||||
from core.models import Plan, Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Create your views here
|
||||
|
||||
|
||||
class Home(View):
|
||||
template_name = "index.html"
|
||||
|
||||
def get(self, request):
|
||||
return render(request, self.template_name)
|
||||
|
||||
|
||||
class Billing(LoginRequiredMixin, View):
|
||||
template_name = "billing.html"
|
||||
|
||||
def get(self, request):
|
||||
context = {"plans": Plan.objects.all(), "user_plans": request.user.plans.all()}
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
|
||||
class Order(LoginRequiredMixin, View):
|
||||
def get(self, request, plan_name):
|
||||
plan = Plan.objects.get(name=plan_name)
|
||||
try:
|
||||
cast = {
|
||||
"payment_method_types": settings.ALLOWED_PAYMENT_METHODS,
|
||||
"mode": "subscription",
|
||||
"customer": request.user.stripe_id,
|
||||
"line_items": assemble_plan_map(product_id_filter=plan.product_id),
|
||||
"success_url": request.build_absolute_uri(reverse("success")),
|
||||
"cancel_url": request.build_absolute_uri(reverse("cancel")),
|
||||
}
|
||||
if request.user.is_superuser:
|
||||
cast["discounts"] = [{"coupon": settings.STRIPE_ADMIN_COUPON}]
|
||||
session = stripe.checkout.Session.create(**cast)
|
||||
Session.objects.create(user=request.user, session=session.id)
|
||||
return redirect(session.url)
|
||||
# return JsonResponse({'id': session.id})
|
||||
except Exception as e:
|
||||
# Raise a server error
|
||||
return JsonResponse({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
class Cancel(LoginRequiredMixin, View):
|
||||
def get(self, request, plan_name):
|
||||
plan = Plan.objects.get(name=plan_name)
|
||||
try:
|
||||
subscriptions = stripe.Subscription.list(
|
||||
customer=request.user.stripe_id, price=plan.product_id
|
||||
)
|
||||
for subscription in subscriptions["data"]:
|
||||
items = subscription["items"]["data"]
|
||||
for item in items:
|
||||
stripe.Subscription.delete(item["subscription"])
|
||||
return render(request, "subscriptioncancel.html", {"plan": plan})
|
||||
# return JsonResponse({'id': session.id})
|
||||
except Exception as e:
|
||||
# Raise a server error
|
||||
logging.error(f"Error cancelling subscription for user: {e}")
|
||||
return JsonResponse({"error": "True"}, status=500)
|
||||
|
||||
|
||||
class Signup(CreateView):
|
||||
form_class = NewUserForm
|
||||
success_url = reverse_lazy("login")
|
||||
template_name = "registration/signup.html"
|
||||
|
||||
|
||||
class Portal(LoginRequiredMixin, View):
|
||||
def get(self, request):
|
||||
session = stripe.billing_portal.Session.create(
|
||||
customer=request.user.stripe_id,
|
||||
return_url=request.build_absolute_uri(reverse("billing")),
|
||||
)
|
||||
return redirect(session.url)
|
103
core/views/callbacks.py
Normal file
103
core/views/callbacks.py
Normal file
@ -0,0 +1,103 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import stripe
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from rest_framework.parsers import JSONParser
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from core.models import Plan, Session, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Callback(APIView):
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
@csrf_exempt
|
||||
def post(self, request):
|
||||
payload = request.body
|
||||
sig_header = request.META["HTTP_STRIPE_SIGNATURE"]
|
||||
try:
|
||||
stripe.Webhook.construct_event(
|
||||
payload, sig_header, settings.STRIPE_ENDPOINT_SECRET
|
||||
)
|
||||
except ValueError:
|
||||
# Invalid payload
|
||||
logger.error("Invalid payload")
|
||||
return HttpResponse(status=400)
|
||||
except stripe.error.SignatureVerificationError:
|
||||
# Invalid signature
|
||||
logger.error("Invalid signature")
|
||||
return HttpResponse(status=400)
|
||||
|
||||
if request.data is None:
|
||||
return JsonResponse({"success": False}, status=500)
|
||||
if "type" in request.data.keys():
|
||||
rtype = request.data["type"]
|
||||
if rtype == "checkout.session.completed":
|
||||
session = request.data["data"]["object"]["id"]
|
||||
subscription_id = request.data["data"]["object"]["subscription"]
|
||||
session_map = Session.objects.get(session=session)
|
||||
if not session_map:
|
||||
return JsonResponse({"success": False}, status=500)
|
||||
user = session_map.user
|
||||
session_map.subscription_id = subscription_id
|
||||
session_map.save()
|
||||
|
||||
if rtype == "customer.subscription.updated":
|
||||
stripe_id = request.data["data"]["object"]["customer"]
|
||||
if not stripe_id:
|
||||
logging.error("No stripe id")
|
||||
return JsonResponse({"success": False}, status=500)
|
||||
user = User.objects.get(stripe_id=stripe_id)
|
||||
# ssubscription_active
|
||||
subscription_id = request.data["data"]["object"]["id"]
|
||||
sessions = Session.objects.filter(user=user)
|
||||
session = None
|
||||
for session_iter in sessions:
|
||||
if session_iter.subscription_id == subscription_id:
|
||||
session = session_iter
|
||||
if not session:
|
||||
logging.error(
|
||||
f"No session found for subscription id {subscription_id}"
|
||||
)
|
||||
return JsonResponse({"success": False}, status=500)
|
||||
# query Session objects
|
||||
# iterate and check against product_id
|
||||
session.request = request.data["request"]["id"]
|
||||
product_id = request.data["data"]["object"]["plan"]["id"]
|
||||
plan = Plan.objects.get(product_id=product_id)
|
||||
if not plan:
|
||||
logging.error(f"Plan not found: {product_id}")
|
||||
return JsonResponse({"success": False}, status=500)
|
||||
session.plan = plan
|
||||
session.save()
|
||||
|
||||
elif rtype == "payment_intent.succeeded":
|
||||
customer = request.data["data"]["object"]["customer"]
|
||||
user = User.objects.get(stripe_id=customer)
|
||||
if not user:
|
||||
logging.error(f"No user found for customer: {customer}")
|
||||
return JsonResponse({"success": False}, status=500)
|
||||
session = Session.objects.get(request=request.data["request"]["id"])
|
||||
|
||||
user.plans.add(session.plan)
|
||||
user.last_payment = datetime.utcnow()
|
||||
user.save()
|
||||
|
||||
elif rtype == "customer.subscription.deleted":
|
||||
customer = request.data["data"]["object"]["customer"]
|
||||
user = User.objects.get(stripe_id=customer)
|
||||
if not user:
|
||||
logging.error(f"No user found for customer {customer}")
|
||||
return JsonResponse({"success": False}, status=500)
|
||||
product_id = request.data["data"]["object"]["plan"]["id"]
|
||||
plan = Plan.objects.get(product_id=product_id)
|
||||
user.plans.remove(plan)
|
||||
user.save()
|
||||
else:
|
||||
return JsonResponse({"success": False}, status=500)
|
||||
return JsonResponse({"success": True})
|
22
core/views/demo.py
Normal file
22
core/views/demo.py
Normal file
@ -0,0 +1,22 @@
|
||||
from django.shortcuts import render
|
||||
from django.views import View
|
||||
import uuid
|
||||
|
||||
class DemoModal(View):
|
||||
template_name = "modals/modal.html"
|
||||
|
||||
def get(self, request):
|
||||
return render(request, self.template_name)
|
||||
|
||||
class DemoWidget(View):
|
||||
template_name = "widgets/widget.html"
|
||||
|
||||
def get(self, request):
|
||||
unique = str(uuid.uuid4())[:8]
|
||||
return render(request, self.template_name, {"unique": unique})
|
||||
|
||||
class DemoWindow(View):
|
||||
template_name = "windows/window.html"
|
||||
|
||||
def get(self, request):
|
||||
return render(request, self.template_name)
|
6
core/views/manage/permissions.py
Normal file
6
core/views/manage/permissions.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
|
||||
|
||||
class SuperUserRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
|
||||
def test_func(self):
|
||||
return self.request.user.is_superuser
|
67
docker-compose.yml
Normal file
67
docker-compose.yml
Normal file
@ -0,0 +1,67 @@
|
||||
version: "2"
|
||||
|
||||
services:
|
||||
app:
|
||||
image: xf/envelope:latest
|
||||
build: ./docker
|
||||
volumes:
|
||||
- ${PORTAINER_GIT_DIR}:/code
|
||||
- ${APP_LOCAL_SETTINGS}:/code/app/local_settings.py
|
||||
- ${APP_DATABASE_FILE}:/code/db.sqlite3
|
||||
ports:
|
||||
- "${APP_PORT}:8000"
|
||||
env_file:
|
||||
- .env
|
||||
volumes_from:
|
||||
- tmp
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
migration:
|
||||
condition: service_started
|
||||
|
||||
migration:
|
||||
image: xf/envelope:latest
|
||||
command: sh -c '. /venv/bin/activate && python manage.py migrate --noinput'
|
||||
volumes:
|
||||
- ${PORTAINER_GIT_DIR}:/code
|
||||
- ${APP_LOCAL_SETTINGS}:/code/app/local_settings.py
|
||||
- ${APP_DATABASE_FILE}:/code/db.sqlite3
|
||||
volumes_from:
|
||||
- tmp
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
# pyroscope:
|
||||
# image: pyroscope/pyroscope
|
||||
# environment:
|
||||
# - PYROSCOPE_LOG_LEVEL=debug
|
||||
# ports:
|
||||
# - '4040:4040'
|
||||
# command:
|
||||
# - 'server'
|
||||
|
||||
tmp:
|
||||
image: busybox
|
||||
command: chmod -R 777 /var/run/redis
|
||||
volumes:
|
||||
- /var/run/redis
|
||||
|
||||
redis:
|
||||
image: redis
|
||||
command: redis-server /etc/redis.conf
|
||||
volumes:
|
||||
- ${PORTAINER_GIT_DIR}/docker/redis.conf:/etc/redis.conf
|
||||
volumes_from:
|
||||
- tmp
|
||||
healthcheck:
|
||||
test: "redis-cli -s /var/run/redis/redis.sock ping"
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 15
|
||||
|
||||
networks:
|
||||
default:
|
||||
external:
|
||||
name: xf
|
18
docker/Dockerfile
Normal file
18
docker/Dockerfile
Normal file
@ -0,0 +1,18 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM python:3
|
||||
|
||||
RUN useradd -d /code xf
|
||||
RUN mkdir /code
|
||||
RUN chown xf:xf /code
|
||||
|
||||
RUN mkdir /venv
|
||||
RUN chown xf:xf /venv
|
||||
|
||||
USER xf
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
WORKDIR /code
|
||||
COPY requirements.dev.txt /code/
|
||||
RUN python -m venv /venv
|
||||
RUN . /venv/bin/activate && pip install -r requirements.dev.txt
|
||||
CMD . /venv/bin/activate && exec python manage.py runserver 0.0.0.0:8000
|
60
docker/docker-compose.prod.yml
Normal file
60
docker/docker-compose.prod.yml
Normal file
@ -0,0 +1,60 @@
|
||||
version: "2"
|
||||
|
||||
services:
|
||||
app:
|
||||
image: xf/envelope:latest
|
||||
build: ./docker/prod
|
||||
volumes:
|
||||
- ${PORTAINER_GIT_DIR}:/code
|
||||
- ${PORTAINER_GIT_DIR}/docker/prod/uwsgi.ini:/conf/uwsgi.ini
|
||||
- ${APP_LOCAL_SETTINGS}:/code/app/local_settings.py
|
||||
- ${APP_DATABASE_FILE}:/code/db.sqlite3
|
||||
ports:
|
||||
- "${APP_PORT}:8000" # uwsgi socket
|
||||
env_file:
|
||||
- ../stack.env
|
||||
volumes_from:
|
||||
- tmp
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
migration:
|
||||
condition: service_started
|
||||
|
||||
migration:
|
||||
image: xf/envelope:latest
|
||||
build: ./docker/prod
|
||||
command: sh -c '. /venv/bin/activate && python manage.py migrate --noinput'
|
||||
volumes:
|
||||
- ${PORTAINER_GIT_DIR}:/code
|
||||
- ${APP_LOCAL_SETTINGS}:/code/app/local_settings.py
|
||||
- ${APP_DATABASE_FILE}:/code/db.sqlite3
|
||||
volumes_from:
|
||||
- tmp
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
tmp:
|
||||
image: busybox
|
||||
command: chmod -R 777 /var/run/redis
|
||||
volumes:
|
||||
- /var/run/redis
|
||||
|
||||
redis:
|
||||
image: redis
|
||||
command: redis-server /etc/redis.conf
|
||||
volumes:
|
||||
- ${PORTAINER_GIT_DIR}/docker/redis.conf:/etc/redis.conf
|
||||
volumes_from:
|
||||
- tmp
|
||||
healthcheck:
|
||||
test: "redis-cli -s /var/run/redis/redis.sock ping"
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 15
|
||||
|
||||
networks:
|
||||
default:
|
||||
external:
|
||||
name: xf
|
21
docker/prod/Dockerfile
Normal file
21
docker/prod/Dockerfile
Normal file
@ -0,0 +1,21 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM python:3
|
||||
|
||||
RUN useradd -d /code xf
|
||||
RUN mkdir /code
|
||||
RUN chown xf:xf /code
|
||||
|
||||
RUN mkdir /conf
|
||||
RUN chown xf:xf /conf
|
||||
|
||||
RUN mkdir /venv
|
||||
RUN chown xf:xf /venv
|
||||
|
||||
USER xf
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
WORKDIR /code
|
||||
COPY requirements.prod.txt /code/
|
||||
RUN python -m venv /venv
|
||||
RUN . /venv/bin/activate && pip install -r requirements.prod.txt
|
||||
CMD . /venv/bin/activate && uwsgi --ini /conf/uwsgi.ini
|
13
docker/prod/requirements.prod.txt
Normal file
13
docker/prod/requirements.prod.txt
Normal file
@ -0,0 +1,13 @@
|
||||
wheel
|
||||
django
|
||||
pre-commit
|
||||
django-crispy-forms
|
||||
crispy-bulma
|
||||
stripe
|
||||
django-rest-framework
|
||||
uwsgi
|
||||
django-htmx
|
||||
cryptography
|
||||
django-debug-toolbar
|
||||
django-debug-toolbar-template-profiler
|
||||
orjson
|
13
docker/prod/uwsgi.ini
Normal file
13
docker/prod/uwsgi.ini
Normal file
@ -0,0 +1,13 @@
|
||||
[uwsgi]
|
||||
chdir=/code
|
||||
module=app.wsgi:application
|
||||
env=DJANGO_SETTINGS_MODULE=app.settings
|
||||
master=1
|
||||
pidfile=/tmp/project-master.pid
|
||||
socket=0.0.0.0:8000
|
||||
processes=5
|
||||
harakiri=20
|
||||
max-requests=5000
|
||||
vacuum=1
|
||||
home=/venv
|
||||
|
2
docker/redis.conf
Normal file
2
docker/redis.conf
Normal file
@ -0,0 +1,2 @@
|
||||
unixsocket /var/run/redis/redis.sock
|
||||
unixsocketperm 777
|
12
docker/requirements.dev.txt
Normal file
12
docker/requirements.dev.txt
Normal file
@ -0,0 +1,12 @@
|
||||
wheel
|
||||
django
|
||||
pre-commit
|
||||
django-crispy-forms
|
||||
crispy-bulma
|
||||
stripe
|
||||
django-rest-framework
|
||||
django-htmx
|
||||
cryptography
|
||||
django-debug-toolbar
|
||||
django-debug-toolbar-template-profiler
|
||||
orjson
|
22
manage.py
Executable file
22
manage.py
Executable file
@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
12
requirements.txt
Normal file
12
requirements.txt
Normal file
@ -0,0 +1,12 @@
|
||||
wheel
|
||||
django
|
||||
pre-commit
|
||||
django-crispy-forms
|
||||
crispy-bulma
|
||||
stripe
|
||||
django-rest-framework
|
||||
django-htmx
|
||||
cryptography
|
||||
django-debug-toolbar
|
||||
django-debug-toolbar-template-profiler
|
||||
orjson
|
Loading…
Reference in New Issue
Block a user