Upload template project

This commit is contained in:
Mark Veidemanis 2022-10-13 15:26:43 +01:00
commit e631811090
Signed by: m
GPG Key ID: 5ACFCEED46C0904F
74 changed files with 2151 additions and 0 deletions

11
Makefile Normal file
View File

@ -0,0 +1,11 @@
run:
docker-compose -f docker/docker-compose.prod.yml --env-file=stack.env up -d
build:
docker-compose -f docker/docker-compose.prod.yml --env-file=stack.env build
stop:
docker-compose -f docker/docker-compose.prod.yml --env-file=stack.env down
log:
docker-compose -f docker/docker-compose.prod.yml --env-file=stack.env logs -f

64
README.md Normal file
View File

@ -0,0 +1,64 @@
# Fisk
Cryptocurrency signal aggregator and trade executor.
## 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 docker/prod/requirements.prod.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
```
## stack.env
The stack.env file referenced is a Portainer special. This is where Portainer would put a file containing all the environment variables set up in its UI.
To run it manually, you will need to copy `stack.env.example` to `stack.env` in the project root.
## Running database migrations
Now we need to run the database migrations in order to get a working database.
```shell
(env) $ python manage.py migrate
```
Note that these are automatically run by a step in the compose file in production.
You won't need to do that manually.
## 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 Docker Compose file is located in `docker/docker-compose.prod.yml`.
There is a shortcut to run it: `make run`.
## Stopping
To stop the containers, run `make stop`.
## Setup
This setup may be different from what you've seen before.
### Uvicorn
There is a Uvicorn worker in the `app` container listening on `/var/run/socks/app.sock`. This is the bit that runs the actual code.
### Nginx
Nginx runs in the `nginx` container and proxies requests to Uvicorn thanks to a mounted and shared directory. No TCP required.
### Pre-start steps
There's a few commands running before start to ensure Django works correctly.
#### Migration
The `migration` container step runs the migrations so you don't need to remember to do it.
#### Collectstatic
The `collectstatic` container step collects all static files from plugins and puts them in the `core/static` folder. This folder is served straight from Nginx without going through Uvicorn.

0
app/__init__.py Normal file
View File

16
app/asgi.py Normal file
View 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()

40
app/local_settings.py Normal file
View File

@ -0,0 +1,40 @@
from os import getenv
trues = ("t", "true", "yes", "y", "1")
# URLs
DOMAIN = getenv("DOMAIN", "example.com")
URL = getenv("URL", f"https://{DOMAIN}")
# Access control
ALLOWED_HOSTS = getenv("ALLOWED_HOSTS", f"127.0.0.1,{DOMAIN}").split(",")
# CSRF
CSRF_TRUSTED_ORIGINS = getenv("CSRF_TRUSTED_ORIGINS", URL).split(",")
# Stripe
STRIPE_ENABLED = getenv("STRIPE_ENABLED", "false").lower() in trues
STRIPE_TEST = getenv("STRIPE_TEST", "true").lower() in trues
STRIPE_API_KEY_TEST = getenv("STRIPE_API_KEY_TEST", "")
STRIPE_PUBLIC_API_KEY_TEST = getenv("STRIPE_PUBLIC_API_KEY_TEST", "")
STRIPE_API_KEY_PROD = getenv("STRIPE_API_KEY_PROD", "")
STRIPE_PUBLIC_API_KEY_PROD = getenv("STRIPE_PUBLIC_API_KEY_PROD", "")
STRIPE_ENDPOINT_SECRET = getenv("STRIPE_ENDPOINT_SECRET", "")
STATIC_ROOT = getenv("STATIC_ROOT", "")
SECRET_KEY = getenv("SECRET_KEY", "")
STRIPE_ADMIN_COUPON = getenv("STRIPE_ADMIN_COUPON", "")
DEBUG = getenv("DEBUG", "false").lower() in trues
PROFILER = getenv("PROFILER", "false").lower() in trues
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
View 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")}',
# }
)

47
app/urls.py Normal file
View File

@ -0,0 +1,47 @@
"""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
View 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()

14
core/__init__.py Normal file
View File

@ -0,0 +1,14 @@
import os
import stripe
from django.conf import settings
os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
# 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
View 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
View 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
View 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
View File

65
core/lib/customers.py Normal file
View 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}")

21
core/lib/products.py Normal file
View File

@ -0,0 +1,21 @@
from asgiref.sync import sync_to_async
from core.models import Plan
async 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 await sync_to_async(list)(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

View 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'),
),
]

View 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),
),
]

View File

90
core/models.py Normal file
View File

@ -0,0 +1,90 @@
import logging
import stripe
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.conf import settings
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 settings.STRIPE_ENABLED:
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 settings.STRIPE_ENABLED:
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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,22 @@
{
const data = document.currentScript.dataset;
const isDebug = data.debug === "True";
if (isDebug) {
document.addEventListener("htmx:beforeOnLoad", function (event) {
const xhr = event.detail.xhr;
if (xhr.status == 500 || xhr.status == 404) {
// Tell htmx to stop processing this response
event.stopPropagation();
document.children[0].innerHTML = xhr.response;
// Run Djangos inline script
// (1, eval) wtf - see https://stackoverflow.com/questions/9107240/1-evalthis-vs-evalthis-in-javascript
(1, eval)(document.scripts[0].innerText);
// Need to directly call Djangos onload function since browser wont
window.onload();
}
});
}
}

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

View 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
View 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

View 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
View 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
View 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>

View File

@ -0,0 +1,36 @@
{% 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>
{% include "partials/product-list.html" %}
{% endblock %}

View 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 %}

View 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
View 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 %}

View File

@ -0,0 +1,5 @@
{% extends 'wm/modal.html' %}
{% block modal_content %}
{% include 'window-content/main.html' %}
{% endblock %}

View 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 %}

View 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 %}

View 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 %}

View File

View 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 %}

View File

@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block content %}
<section>
<p>Subscription {{ plan }} cancelled!</p>
</section>
{% endblock %}

View 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 %}

View 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 %}

View 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>

View File

@ -0,0 +1,9 @@
{% extends 'wm/magnet.html' %}
{% block heading %}
Window
{% endblock %}
{% block panel_content %}
{% include 'window-content/main.html' %}
{% endblock %}

View 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>

View 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>

View 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>

View 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 %}

View File

View 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

View File

@ -0,0 +1,8 @@
from django import template
register = template.Library()
@register.filter
def index(h, key):
return h[key]

View File

@ -0,0 +1,8 @@
from django import template
register = template.Library()
@register.filter
def joinsep(lst, sep):
return sep.join(lst)

View File

@ -0,0 +1,8 @@
from django import template
register = template.Library()
@register.filter
def nsep(lst):
return "\n".join(lst)

View 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
View File

@ -0,0 +1,3 @@
# from django.test import TestCase
# Create your tests here.

0
core/util/__init__.py Normal file
View File

69
core/util/logs.py Normal file
View 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
View File

0
core/views/__init__.py Normal file
View File

95
core/views/base.py Normal file
View File

@ -0,0 +1,95 @@
import logging
import stripe
from asgiref.sync import sync_to_async
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"
async def get(self, request):
return render(request, self.template_name)
class Billing(LoginRequiredMixin, View):
template_name = "billing.html"
async def get(self, request):
plans = await sync_to_async(list)(Plan.objects.all())
user_plans = await sync_to_async(list)(request.user.plans.all())
context = {"plans": plans, "user_plans": user_plans}
return render(request, self.template_name, context)
class Order(LoginRequiredMixin, View):
async 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": await 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)
await Session.objects.acreate(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):
async 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):
async 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)

104
core/views/callbacks.py Normal file
View File

@ -0,0 +1,104 @@
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]
# TODO: make async
@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})

26
core/views/demo.py Normal file
View File

@ -0,0 +1,26 @@
import uuid
from django.shortcuts import render
from django.views import View
class DemoModal(View):
template_name = "modals/modal.html"
async def get(self, request):
return render(request, self.template_name)
class DemoWidget(View):
template_name = "widgets/widget.html"
async 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"
async def get(self, request):
return render(request, self.template_name)

View 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

View File

@ -0,0 +1,104 @@
version: "2.2"
services:
app:
image: xf/fisk:prod
build: ${PORTAINER_GIT_DIR}/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:
# - "8000:8000" # uwsgi socket
env_file:
- ../stack.env
volumes_from:
- tmp
depends_on:
# redis:
# condition: service_healthy
migration:
condition: service_started
collectstatic:
condition: service_started
migration:
image: xf/fisk:prod
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
env_file:
- ../stack.env
collectstatic:
image: xf/fisk:prod
build: ./docker/prod
command: sh -c '. /venv/bin/activate && python manage.py collectstatic --noinput'
volumes:
- ${PORTAINER_GIT_DIR}:/code
- ${APP_LOCAL_SETTINGS}:/code/app/local_settings.py
- ${APP_DATABASE_FILE}:/code/db.sqlite3
env_file:
- ../stack.env
nginx:
image: nginx:latest
ports:
- ${APP_PORT}:9999
ulimits:
nproc: 65535
nofile:
soft: 65535
hard: 65535
volumes:
- ${PORTAINER_GIT_DIR}:/code
- ${PORTAINER_GIT_DIR}/docker/nginx/conf.d:/etc/nginx/conf.d
volumes_from:
- tmp
depends_on:
app:
condition: service_started
# volumes_from:
# - tmp
# depends_on:
# redis:
# condition: service_healthy
tmp:
image: busybox
command: chmod -R 777 /var/run/socks
volumes:
- /var/run/socks
# redis:
# image: redis
# command: redis-server /etc/redis.conf
# ulimits:
# nproc: 65535
# nofile:
# soft: 65535
# hard: 65535
# volumes:
# - ${PORTAINER_GIT_DIR}/docker/redis.conf:/etc/redis.conf
# - redis_data:/data
# 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
# volumes:
# redis_data: {}

View File

@ -0,0 +1,23 @@
upstream django {
#server app:8000;
server unix:///var/run/socks/app.sock;
}
server {
listen 9999;
location = /favicon.ico { access_log off; log_not_found off; }
location /static/ {
root /code/core/;
}
location / {
include /etc/nginx/uwsgi_params; # the uwsgi_params file you installed
proxy_pass http://django;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
}
}

23
docker/prod/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
# 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
CMD . /venv/bin/activate && uvicorn --reload --workers 2 --uds /var/run/socks/app.sock app.asgi:application
# CMD . /venv/bin/activate && gunicorn -b 0.0.0.0:8000 --reload app.asgi:application -k uvicorn.workers.UvicornWorker

View File

@ -0,0 +1,15 @@
wheel
django
pre-commit
django-crispy-forms
crispy-bulma
stripe
django-rest-framework
uvloop
uvicorn[standard]
gunicorn
django-htmx
cryptography
django-debug-toolbar
django-debug-toolbar-template-profiler
orjson

2
docker/redis.conf Normal file
View File

@ -0,0 +1,2 @@
unixsocket /var/run/redis/redis.sock
unixsocketperm 777

22
manage.py Executable file
View 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()