Start from envelope Django template
This commit is contained in:
commit
f1dd61e228
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@ -0,0 +1,28 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM python:3
|
||||
ARG OPERATION
|
||||
|
||||
RUN useradd -d /code xf
|
||||
RUN mkdir -p /code
|
||||
RUN chown -R xf:xf /code
|
||||
|
||||
RUN mkdir -p /conf/static
|
||||
RUN chown -R xf:xf /conf
|
||||
|
||||
RUN mkdir /venv
|
||||
RUN chown xf:xf /venv
|
||||
|
||||
USER xf
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
WORKDIR /code
|
||||
COPY requirements.txt /code/
|
||||
RUN python -m venv /venv
|
||||
RUN . /venv/bin/activate && pip install -r requirements.txt
|
||||
|
||||
# CMD . /venv/bin/activate && uwsgi --ini /conf/uwsgi.ini
|
||||
|
||||
CMD if [ "$OPERATION" = "uwsgi" ] ; then . /venv/bin/activate && uwsgi --ini /conf/uwsgi.ini ; else . /venv/bin/activate && exec python manage.py runserver 0.0.0.0:8000; fi
|
||||
|
||||
# CMD . /venv/bin/activate && uvicorn --reload --reload-include *.html --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
|
26
Makefile
Normal file
26
Makefile
Normal file
@ -0,0 +1,26 @@
|
||||
run:
|
||||
docker-compose --env-file=stack.env up -d
|
||||
|
||||
build:
|
||||
docker-compose --env-file=stack.env build
|
||||
|
||||
stop:
|
||||
docker-compose --env-file=stack.env down
|
||||
|
||||
log:
|
||||
docker-compose --env-file=stack.env logs -f
|
||||
|
||||
test:
|
||||
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py test $(MODULES) -v 2"
|
||||
|
||||
migrate:
|
||||
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py migrate"
|
||||
|
||||
makemigrations:
|
||||
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py makemigrations"
|
||||
|
||||
auth:
|
||||
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py createsuperuser"
|
||||
|
||||
token:
|
||||
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py addstatictoken m"
|
64
README.md
Normal file
64
README.md
Normal file
@ -0,0 +1,64 @@
|
||||
# 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 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
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()
|
48
app/local_settings.py
Normal file
48
app/local_settings.py
Normal file
@ -0,0 +1,48 @@
|
||||
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
|
||||
BILLING_ENABLED = getenv("BILLING_ENABLED", "false").lower() in trues
|
||||
STRIPE_TEST = getenv("STRIPE_TEST", "true") 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", "")
|
||||
|
||||
REGISTRATION_OPEN = getenv("REGISTRATION_OPEN", "false").lower() in trues
|
||||
|
||||
LAGO_API_KEY = getenv("LAGO_API_KEY", "")
|
||||
LAGO_ORG_ID = getenv("LAGO_ORG_ID", "")
|
||||
LAGO_URL = getenv("LAGO_URL", "")
|
||||
|
||||
DEBUG = getenv("DEBUG", "false") in trues
|
||||
PROFILER = getenv("PROFILER", "false") 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",
|
||||
]
|
||||
|
||||
SETTINGS_EXPORT = ["BILLING_ENABLED"]
|
226
app/settings.py
Normal file
226
app/settings.py
Normal file
@ -0,0 +1,226 @@
|
||||
"""
|
||||
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",
|
||||
# 'core.apps.LibraryAdminConfig', # our custom OTP'ed 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",
|
||||
"django_otp",
|
||||
"django_otp.plugins.otp_totp",
|
||||
# "django_otp.plugins.otp_email",
|
||||
# 'django_otp.plugins.otp_hotp',
|
||||
"django_otp.plugins.otp_static",
|
||||
"two_factor",
|
||||
# "two_factor.plugins.phonenumber",
|
||||
# "two_factor.plugins.email",
|
||||
# "two_factor.plugins.yubikey",
|
||||
# "otp_yubikey",
|
||||
"mixins",
|
||||
"cachalot",
|
||||
]
|
||||
|
||||
# Performance optimisations
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": "unix:///var/run/socks/redis.sock",
|
||||
"OPTIONS": {
|
||||
"db": "10",
|
||||
# "parser_class": "django_redis.cache.RedisCache",
|
||||
"pool_class": "redis.BlockingConnectionPool",
|
||||
},
|
||||
}
|
||||
}
|
||||
# CACHE_MIDDLEWARE_ALIAS = 'default'
|
||||
# CACHE_MIDDLEWARE_SECONDS = '600'
|
||||
# CACHE_MIDDLEWARE_KEY_PREFIX = ''
|
||||
|
||||
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.cache.UpdateCacheMiddleware',
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
# 'django.middleware.cache.FetchFromCacheMiddleware',
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django_otp.middleware.OTPMiddleware",
|
||||
"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",
|
||||
"core.util.django_settings_export.settings_export",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "app.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": "/conf/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"
|
||||
# 2FA
|
||||
LOGIN_URL = "two_factor:login"
|
||||
# LOGIN_REDIRECT_URL = 'two_factor:profile'
|
||||
|
||||
# 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",
|
||||
"cachalot.panels.CachalotPanel",
|
||||
]
|
||||
|
||||
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")}',
|
||||
# }
|
||||
)
|
||||
|
||||
|
||||
def show_toolbar(request):
|
||||
return DEBUG # noqa: from local imports
|
||||
|
||||
|
||||
DEBUG_TOOLBAR_CONFIG = {
|
||||
"SHOW_TOOLBAR_CALLBACK": show_toolbar,
|
||||
}
|
43
app/urls.py
Normal file
43
app/urls.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""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.contrib.auth.views import LogoutView
|
||||
from django.urls import include, path
|
||||
from two_factor.urls import urlpatterns as tf_urls
|
||||
|
||||
from core.views import base, demo, notifications
|
||||
|
||||
urlpatterns = [
|
||||
path("__debug__/", include("debug_toolbar.urls")),
|
||||
path("", base.Home.as_view(), name="home"),
|
||||
path("admin/", admin.site.urls),
|
||||
# 2FA login urls
|
||||
path("", include(tf_urls)),
|
||||
path("accounts/signup/", base.Signup.as_view(), name="signup"),
|
||||
path("accounts/logout/", LogoutView.as_view(), name="logout"),
|
||||
# Demos
|
||||
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"),
|
||||
# Notifications
|
||||
path(
|
||||
"notifications/<str:type>/update/",
|
||||
notifications.NotificationsUpdate.as_view(),
|
||||
name="notifications_update",
|
||||
),
|
||||
] + 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()
|
14
core/__init__.py
Normal file
14
core/__init__.py
Normal 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
|
36
core/admin.py
Normal file
36
core/admin.py
Normal file
@ -0,0 +1,36 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
|
||||
from .forms import CustomUserCreationForm
|
||||
from .models import NotificationSettings, User
|
||||
|
||||
|
||||
# Register your models here.
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
# list_filter = ["plans"]
|
||||
model = User
|
||||
add_form = CustomUserCreationForm
|
||||
fieldsets = (
|
||||
*UserAdmin.fieldsets,
|
||||
(
|
||||
"Billing information",
|
||||
{"fields": ("billing_provider_id", "customer_id", "stripe_id")},
|
||||
),
|
||||
# (
|
||||
# "Payment information",
|
||||
# {
|
||||
# "fields": (
|
||||
# # "plans",
|
||||
# "last_payment",
|
||||
# )
|
||||
# },
|
||||
# ),
|
||||
)
|
||||
|
||||
|
||||
class NotificationSettingsAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "ntfy_topic", "ntfy_url")
|
||||
|
||||
|
||||
admin.site.register(User, CustomUserAdmin)
|
||||
admin.site.register(NotificationSettings, NotificationSettingsAdmin)
|
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"
|
49
core/forms.py
Normal file
49
core/forms.py
Normal file
@ -0,0 +1,49 @@
|
||||
from django import forms
|
||||
from django.contrib.auth.forms import UserCreationForm
|
||||
from django.forms import ModelForm
|
||||
from mixins.restrictions import RestrictedFormMixin
|
||||
|
||||
from .models import NotificationSettings, 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 NotificationSettingsForm(RestrictedFormMixin, ModelForm):
|
||||
class Meta:
|
||||
model = NotificationSettings
|
||||
fields = (
|
||||
"ntfy_topic",
|
||||
"ntfy_url",
|
||||
)
|
||||
help_texts = {
|
||||
"ntfy_topic": "The topic to send notifications to.",
|
||||
"ntfy_url": "Custom NTFY server. Leave blank to use the default server.",
|
||||
}
|
||||
|
||||
|
||||
class CustomUserCreationForm(UserCreationForm):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = "__all__"
|
0
core/lib/__init__.py
Normal file
0
core/lib/__init__.py
Normal file
38
core/lib/notify.py
Normal file
38
core/lib/notify.py
Normal file
@ -0,0 +1,38 @@
|
||||
import requests
|
||||
|
||||
from core.util import logs
|
||||
|
||||
NTFY_URL = "https://ntfy.sh"
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
|
||||
# Actual function to send a message to a topic
|
||||
def raw_sendmsg(msg, title=None, priority=None, tags=None, url=None, topic=None):
|
||||
if url is None:
|
||||
url = NTFY_URL
|
||||
headers = {"Title": "Envelope"}
|
||||
if title:
|
||||
headers["Title"] = title
|
||||
if priority:
|
||||
headers["Priority"] = priority
|
||||
if tags:
|
||||
headers["Tags"] = tags
|
||||
requests.post(
|
||||
f"{url}/{topic}",
|
||||
data=msg,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
# Sendmsg helper to send a message to a user's notification settings
|
||||
def sendmsg(user, *args, **kwargs):
|
||||
notification_settings = user.get_notification_settings()
|
||||
|
||||
if notification_settings.ntfy_topic is None:
|
||||
# No topic set, so don't send
|
||||
return
|
||||
else:
|
||||
topic = notification_settings.ntfy_topic
|
||||
|
||||
raw_sendmsg(*args, **kwargs, url=notification_settings.ntfy_url, topic=topic)
|
60
core/migrations/0001_initial.py
Normal file
60
core/migrations/0001_initial.py
Normal file
@ -0,0 +1,60 @@
|
||||
# Generated by Django 4.2.6 on 2023-10-17 19:26
|
||||
|
||||
import uuid
|
||||
|
||||
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)),
|
||||
('customer_id', models.UUIDField(blank=True, default=uuid.uuid4, null=True)),
|
||||
('billing_provider_id', models.CharField(blank=True, max_length=255, 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')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NotificationSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ntfy_topic', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('ntfy_url', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
0
core/migrations/__init__.py
Normal file
0
core/migrations/__init__.py
Normal file
50
core/models.py
Normal file
50
core/models.py
Normal file
@ -0,0 +1,50 @@
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
# Stripe customer ID
|
||||
stripe_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
customer_id = models.UUIDField(default=uuid.uuid4, null=True, blank=True)
|
||||
billing_provider_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
email = models.EmailField(unique=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._original = self
|
||||
|
||||
def get_notification_settings(self):
|
||||
return NotificationSettings.objects.get_or_create(user=self)[0]
|
||||
|
||||
|
||||
class NotificationSettings(models.Model):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||
ntfy_topic = models.CharField(max_length=255, null=True, blank=True)
|
||||
ntfy_url = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Notification settings for {self.user}"
|
||||
|
||||
|
||||
# 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
22
core/static/django-htmx.js
Normal file
22
core/static/django-htmx.js
Normal 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 Django’s 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 Django’s onload function since browser won’t
|
||||
window.onload();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
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');
|
||||
// });
|
300
core/templates/base.html
Normal file
300
core/templates/base.html
Normal file
@ -0,0 +1,300 @@
|
||||
{% 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_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 %}
|
||||
{% if user.is_authenticated %}
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link">
|
||||
Account
|
||||
</a>
|
||||
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="{% url 'two_factor:profile' %}">
|
||||
Security
|
||||
</a>
|
||||
<a class="navbar-item" href="{% url 'notifications_update' type='page' %}">
|
||||
Notifications
|
||||
</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" href="{% url 'two_factor: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>
|
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 'mixins/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 %}
|
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-5-desktop is-4-widescreen">
|
||||
<form method="POST" class="box">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<div class="field">
|
||||
<button class="button">
|
||||
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
19
core/templates/registration/registration_closed.html
Normal file
19
core/templates/registration/registration_closed.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% 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">
|
||||
<div class="box">
|
||||
<p class="has-text-danger">Registration closed.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
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 %}
|
1
core/templates/two_factor/_base.html
Normal file
1
core/templates/two_factor/_base.html
Normal file
@ -0,0 +1 @@
|
||||
{% extends 'base.html' %}
|
16
core/templates/two_factor/_base_focus.html
Normal file
16
core/templates/two_factor/_base_focus.html
Normal file
@ -0,0 +1,16 @@
|
||||
{% extends "two_factor/_base.html" %}
|
||||
|
||||
{% block content_wrapper %}
|
||||
<section class="hero is-fullheight">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<div class="columns is-centered">
|
||||
<div class="column box is-5-tablet is-5-desktop is-4-widescreen">
|
||||
{% block content %}{% endblock content %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
16
core/templates/two_factor/_wizard_actions.html
Normal file
16
core/templates/two_factor/_wizard_actions.html
Normal file
@ -0,0 +1,16 @@
|
||||
{% load i18n %}
|
||||
|
||||
<div class="buttons">
|
||||
{% if cancel_url %}
|
||||
<a href="{{ cancel_url }}"
|
||||
class="button">{% trans "Cancel" %}</a>
|
||||
{% endif %}
|
||||
{% if wizard.steps.prev %}
|
||||
<button name="wizard_goto_step" type="submit"
|
||||
value="{{ wizard.steps.prev }}"
|
||||
class="button">{% trans "Back" %}</button>
|
||||
{% else %}
|
||||
<button disabled name="" type="button" class="button">{% trans "Back" %}</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="button">{% trans "Next" %}</button>
|
||||
</div>
|
6
core/templates/two_factor/_wizard_forms.html
Normal file
6
core/templates/two_factor/_wizard_forms.html
Normal file
@ -0,0 +1,6 @@
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
<table class="is-3">
|
||||
{{ wizard.management_form|crispy }}
|
||||
{{ wizard.form|crispy }}
|
||||
</table>
|
28
core/templates/two_factor/core/backup_tokens.html
Normal file
28
core/templates/two_factor/core/backup_tokens.html
Normal file
@ -0,0 +1,28 @@
|
||||
{% extends "two_factor/_base_focus.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="title">{% block title %}{% trans "Backup Tokens" %}{% endblock %}</h1>
|
||||
<p class="subtitle">{% blocktrans trimmed %}Backup tokens can be used when your primary and backup
|
||||
phone numbers aren't available. The backup tokens below can be used
|
||||
for login verification. If you've used up all your backup tokens, you
|
||||
can generate a new set of backup tokens. Only the backup tokens shown
|
||||
below will be valid.{% endblocktrans %}</p>
|
||||
|
||||
{% if device.token_set.count %}
|
||||
<ul>
|
||||
{% for token in device.token_set.all %}
|
||||
<li>{{ token.token }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p class="subtitle">{% blocktrans %}Print these tokens and keep them somewhere safe.{% endblocktrans %}</p>
|
||||
{% else %}
|
||||
<p class="subtitle">{% trans "You don't have any backup codes yet." %}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">{% csrf_token %}{{ form }}
|
||||
<a href="{% url 'two_factor:profile'%}"
|
||||
class="float-right button">{% trans "Back to Account Security" %}</a>
|
||||
<button class="button" type="submit">{% trans "Generate Tokens" %}</button>
|
||||
</form>
|
||||
{% endblock %}
|
52
core/templates/two_factor/core/login.html
Normal file
52
core/templates/two_factor/core/login.html
Normal file
@ -0,0 +1,52 @@
|
||||
{% extends "two_factor/_base_focus.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="title">{% block title %}{% trans "Login" %}{% endblock %}</h1>
|
||||
|
||||
{% if wizard.steps.current == 'auth' %}
|
||||
<p class="subtitle">{% blocktrans %}Enter your credentials.{% endblocktrans %}</p>
|
||||
{% elif wizard.steps.current == 'token' %}
|
||||
{% if device.method == 'call' %}
|
||||
<p class="subtitle">{% blocktrans trimmed %}We are calling your phone right now, please enter the
|
||||
digits you hear.{% endblocktrans %}</p>
|
||||
{% elif device.method == 'sms' %}
|
||||
<p class="subtitle">{% blocktrans trimmed %}We sent you a text message, please enter the tokens we
|
||||
sent.{% endblocktrans %}</p>
|
||||
{% else %}
|
||||
<p class="subtitle">{% blocktrans trimmed %}Please enter the tokens generated by your token
|
||||
generator.{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
{% elif wizard.steps.current == 'backup' %}
|
||||
<p class="subtitle">{% blocktrans trimmed %}Use this form for entering backup tokens for logging in.
|
||||
These tokens have been generated for you to print and keep safe. Please
|
||||
enter one of these backup tokens to login to your account.{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
|
||||
<form action="" method="post">{% csrf_token %}
|
||||
{% include "two_factor/_wizard_forms.html" %}
|
||||
|
||||
{# hidden submit button to enable [enter] key #}
|
||||
<input type="submit" value="" style="display:none" />
|
||||
|
||||
{% if other_devices %}
|
||||
<p class="subtitle">{% trans "Or, alternatively, use one of your backup phones:" %}</p>
|
||||
<p class="subtitle">
|
||||
{% for other in other_devices %}
|
||||
<button name="challenge_device" value="{{ other.persistent_id }}"
|
||||
class="button" type="submit">
|
||||
{{ other.generate_challenge_button_title }}
|
||||
</button>
|
||||
{% endfor %}</p>
|
||||
{% endif %}
|
||||
{% if backup_tokens %}
|
||||
<p class="subtitle">{% trans "As a last resort, you can use a backup token:" %}</p>
|
||||
<p class="subtitle">
|
||||
<button name="wizard_goto_step" type="submit" value="backup"
|
||||
class="button">{% trans "Use Backup Token" %}</button>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% include "two_factor/_wizard_actions.html" %}
|
||||
</form>
|
||||
{% endblock %}
|
22
core/templates/two_factor/core/otp_required.html
Normal file
22
core/templates/two_factor/core/otp_required.html
Normal file
@ -0,0 +1,22 @@
|
||||
{% extends "two_factor/_base_focus.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="title">{% block title %}{% trans "Permission Denied" %}{% endblock %}</h1>
|
||||
|
||||
<p class="subtitle">{% blocktrans trimmed %}The page you requested, enforces users to verify using
|
||||
two-factor authentication for security reasons. You need to enable these
|
||||
security features in order to access this page.{% endblocktrans %}</p>
|
||||
|
||||
<p class="subtitle">{% blocktrans trimmed %}Two-factor authentication is not enabled for your
|
||||
account. Enable two-factor authentication for enhanced account
|
||||
security.{% endblocktrans %}</p>
|
||||
<div class="buttons">
|
||||
|
||||
<a href="javascript:history.go(-1)"
|
||||
class="float-right button">{% trans "Go back" %}</a>
|
||||
<a href="{% url 'two_factor:setup' %}" class="button">
|
||||
{% trans "Enable Two-Factor Authentication" %}</a>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
24
core/templates/two_factor/core/phone_register.html
Normal file
24
core/templates/two_factor/core/phone_register.html
Normal file
@ -0,0 +1,24 @@
|
||||
{% extends "two_factor/_base_focus.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="title">{% block title %}{% trans "Add Backup Phone" %}{% endblock %}</h1>
|
||||
|
||||
{% if wizard.steps.current == 'setup' %}
|
||||
<p class="subtitle">{% blocktrans trimmed %}You'll be adding a backup phone number to your
|
||||
account. This number will be used if your primary method of
|
||||
registration is not available.{% endblocktrans %}</p>
|
||||
{% elif wizard.steps.current == 'validation' %}
|
||||
<p class="subtitle">{% blocktrans trimmed %}We've sent a token to your phone number. Please
|
||||
enter the token you've received.{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
|
||||
<form action="" method="post">{% csrf_token %}
|
||||
{% include "two_factor/_wizard_forms.html" %}
|
||||
|
||||
{# hidden submit button to enable [enter] key #}
|
||||
<input type="submit" value="" style="display:none" />
|
||||
|
||||
{% include "two_factor/_wizard_actions.html" %}
|
||||
</form>
|
||||
{% endblock %}
|
56
core/templates/two_factor/core/setup.html
Normal file
56
core/templates/two_factor/core/setup.html
Normal file
@ -0,0 +1,56 @@
|
||||
{% extends "two_factor/_base_focus.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="title">{% block title %}{% trans "Enable Two-Factor Authentication" %}{% endblock %}</h1>
|
||||
{% if wizard.steps.current == 'welcome' %}
|
||||
<p class="subtitle">{% blocktrans trimmed %}You are about to take your account security to the
|
||||
next level. Follow the steps in this wizard to enable two-factor
|
||||
authentication.{% endblocktrans %}</p>
|
||||
{% elif wizard.steps.current == 'method' %}
|
||||
<p class="subtitle">{% blocktrans trimmed %}Please select which authentication method you would
|
||||
like to use.{% endblocktrans %}</p>
|
||||
{% elif wizard.steps.current == 'generator' %}
|
||||
<p class="subtitle">{% blocktrans trimmed %}To start using a token generator, please use your
|
||||
smartphone to scan the QR code below. For example, use Google
|
||||
Authenticator. Then, enter the token generated by the app.
|
||||
{% endblocktrans %}</p>
|
||||
<p class="subtitle"><img src="{{ QR_URL }}" alt="QR Code" class="bg-white"/></p>
|
||||
{% elif wizard.steps.current == 'sms' %}
|
||||
<p class="subtitle">{% blocktrans trimmed %}Please enter the phone number you wish to receive the
|
||||
text messages on. This number will be validated in the next step.
|
||||
{% endblocktrans %}</p>
|
||||
{% elif wizard.steps.current == 'call' %}
|
||||
<p class="subtitle">{% blocktrans trimmed %}Please enter the phone number you wish to be called on.
|
||||
This number will be validated in the next step. {% endblocktrans %}</p>
|
||||
{% elif wizard.steps.current == 'validation' %}
|
||||
{% if challenge_succeeded %}
|
||||
{% if device.method == 'call' %}
|
||||
<p class="subtitle">{% blocktrans trimmed %}We are calling your phone right now, please enter the
|
||||
digits you hear.{% endblocktrans %}</p>
|
||||
{% elif device.method == 'sms' %}
|
||||
<p class="subtitle">{% blocktrans trimmed %}We sent you a text message, please enter the tokens we
|
||||
sent.{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="alert alert-warning" role="alert">{% blocktrans trimmed %}We've
|
||||
encountered an issue with the selected authentication method. Please
|
||||
go back and verify that you entered your information correctly, try
|
||||
again, or use a different authentication method instead. If the issue
|
||||
persists, contact the site administrator.{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
{% elif wizard.steps.current == 'yubikey' %}
|
||||
<p class="subtitle">{% blocktrans trimmed %}To identify and verify your YubiKey, please insert a
|
||||
token in the field below. Your YubiKey will be linked to your
|
||||
account.{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
|
||||
<form action="" method="post">{% csrf_token %}
|
||||
{% include "two_factor/_wizard_forms.html" %}
|
||||
|
||||
{# hidden submit button to enable [enter] key #}
|
||||
<input type="submit" value="" style="display:none" />
|
||||
|
||||
{% include "two_factor/_wizard_actions.html" %}
|
||||
</form>
|
||||
{% endblock %}
|
24
core/templates/two_factor/core/setup_complete.html
Normal file
24
core/templates/two_factor/core/setup_complete.html
Normal file
@ -0,0 +1,24 @@
|
||||
{% extends "two_factor/_base_focus.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="title">{% block title %}{% trans "Enable Two-Factor Authentication" %}{% endblock %}</h1>
|
||||
|
||||
<p class="subtitle">{% blocktrans trimmed %}Congratulations, you've successfully enabled two-factor
|
||||
authentication.{% endblocktrans %}</p>
|
||||
|
||||
{% if not phone_methods %}
|
||||
<p class="subtitle"><a href="{% url 'two_factor:profile' %}"
|
||||
class="button">{% trans "Back to Account Security" %}</a></p>
|
||||
{% else %}
|
||||
<p class="subtitle">{% blocktrans trimmed %}However, it might happen that you don't have access to
|
||||
your primary token device. To enable account recovery, add a phone
|
||||
number.{% endblocktrans %}</p>
|
||||
|
||||
<a href="{% url 'two_factor:profile' %}"
|
||||
class="float-right button">{% trans "Back to Account Security" %}</a>
|
||||
<p class="subtitle"><a href="{% url 'two_factor:phone_create' %}"
|
||||
class="button">{% trans "Add Phone Number" %}</a></p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
14
core/templates/two_factor/profile/disable.html
Normal file
14
core/templates/two_factor/profile/disable.html
Normal file
@ -0,0 +1,14 @@
|
||||
{% extends "two_factor/_base_focus.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="title">{% block title %}{% trans "Disable Two-factor Authentication" %}{% endblock %}</h1>
|
||||
<p class="subtitle">{% blocktrans trimmed %}You are about to disable two-factor authentication. This
|
||||
weakens your account security, are you sure?{% endblocktrans %}</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<table>{{ form }}</table>
|
||||
<button class="button"
|
||||
type="submit">{% trans "Disable" %}</button>
|
||||
</form>
|
||||
{% endblock %}
|
63
core/templates/two_factor/profile/profile.html
Normal file
63
core/templates/two_factor/profile/profile.html
Normal file
@ -0,0 +1,63 @@
|
||||
{% extends "two_factor/_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="title">{% block title %}{% trans "Account Security" %}{% endblock %}</h1>
|
||||
|
||||
{% if default_device %}
|
||||
{% if default_device_type == 'TOTPDevice' %}
|
||||
<p class="subtitle">{% trans "Tokens will be generated by your token generator." %}</p>
|
||||
{% elif default_device_type == 'PhoneDevice' %}
|
||||
<p class="subtitle">{% blocktrans with primary=default_device.generate_challenge_button_title %}Primary method: {{ primary }}{% endblocktrans %}</p>
|
||||
{% elif default_device_type == 'RemoteYubikeyDevice' %}
|
||||
<p class="subtitle">{% blocktrans %}Tokens will be generated by your YubiKey.{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if available_phone_methods %}
|
||||
<h2 class="title is-4">{% trans "Backup Phone Numbers" %}</h2>
|
||||
<p class="subtitle">{% blocktrans trimmed %}If your primary method is not available, we are able to
|
||||
send backup tokens to the phone numbers listed below.{% endblocktrans %}</p>
|
||||
<ul>
|
||||
{% for phone in backup_phones %}
|
||||
<li>
|
||||
{{ phone.generate_challenge_button_title }}
|
||||
<form method="post" action="{% url 'two_factor:phone_delete' phone.id %}"
|
||||
onsubmit="return confirm({% trans 'Are you sure?' %})">
|
||||
{% csrf_token %}
|
||||
<button class="button is-warning"
|
||||
type="submit">{% trans "Unregister" %}</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p class="subtitle"><a href="{% url 'two_factor:phone_create' %}"
|
||||
class="button">{% trans "Add Phone Number" %}</a></p>
|
||||
{% endif %}
|
||||
|
||||
<h2 class="title is-4">{% trans "Backup Tokens" %}</h2>
|
||||
<p class="subtitle">
|
||||
{% blocktrans trimmed %}If you don't have any device with you, you can access
|
||||
your account using backup tokens.{% endblocktrans %}
|
||||
{% blocktrans trimmed count counter=backup_tokens %}
|
||||
You have only one backup token remaining.
|
||||
{% plural %}
|
||||
You have {{ counter }} backup tokens remaining.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p class="subtitle"><a href="{% url 'two_factor:backup_tokens' %}"
|
||||
class="button">{% trans "Show Codes" %}</a></p>
|
||||
|
||||
<h3 class="title is-5">{% trans "Disable Two-Factor Authentication" %}</h3>
|
||||
<p class="subtitle">{% blocktrans trimmed %}However we strongly discourage you to do so, you can
|
||||
also disable two-factor authentication for your account.{% endblocktrans %}</p>
|
||||
<p class="subtitle"><a class="button" href="{% url 'two_factor:disable' %}">
|
||||
{% trans "Disable Two-Factor Authentication" %}</a></p>
|
||||
{% else %}
|
||||
<p class="subtitle">{% blocktrans trimmed %}Two-factor authentication is not enabled for your
|
||||
account. Enable two-factor authentication for enhanced account
|
||||
security.{% endblocktrans %}</p>
|
||||
<p class="subtitle"><a href="{% url 'two_factor:setup' %}" class="button">
|
||||
{% trans "Enable Two-Factor Authentication" %}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
7
core/templates/two_factor/twilio/press_a_key.xml
Normal file
7
core/templates/two_factor/twilio/press_a_key.xml
Normal file
@ -0,0 +1,7 @@
|
||||
{% load i18n %}<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<Response>
|
||||
<Gather timeout="15" numDigits="1" finishOnKey="">
|
||||
<Say language="{{ locale }}">{% blocktrans %}Hi, this is {{ site_name }} calling. Press any key to continue.{% endblocktrans %}</Say>
|
||||
</Gather>
|
||||
<Say language="{{ locale }}">{% trans "You didn’t press any keys. Good bye." %}</Say>
|
||||
</Response>
|
5
core/templates/two_factor/twilio/sms_message.html
Normal file
5
core/templates/two_factor/twilio/sms_message.html
Normal file
@ -0,0 +1,5 @@
|
||||
{% load i18n %}
|
||||
{% blocktrans trimmed %}
|
||||
Your OTP token is {{ token }}
|
||||
{% endblocktrans %}
|
||||
|
12
core/templates/two_factor/twilio/token.xml
Normal file
12
core/templates/two_factor/twilio/token.xml
Normal file
@ -0,0 +1,12 @@
|
||||
{% load i18n %}<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<Response>
|
||||
<Say language="{{ locale }}">{% trans "Your token is:" %}</Say>
|
||||
<Pause>
|
||||
{% for digit in token %} <Say language="{{ locale }}">{{ digit }}</Say>
|
||||
<Pause>
|
||||
{% endfor %} <Say language="{{ locale }}">{% trans "Repeat:" %}</Say>
|
||||
<Pause>
|
||||
{% for digit in token %} <Say language="{{ locale }}">{{ digit }}</Say>
|
||||
<Pause>
|
||||
{% endfor %} <Say language="{{ locale }}">{% trans "Good bye." %}</Say>
|
||||
</Response>
|
20
core/templates/widgets/widget.html
Normal file
20
core/templates/widgets/widget.html
Normal file
@ -0,0 +1,20 @@
|
||||
{% extends 'mixins/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 'mixins/wm/window.html' %}
|
||||
|
||||
{% block heading %}
|
||||
Window
|
||||
{% endblock %}
|
||||
|
||||
{% block panel_content %}
|
||||
{% include 'window-content/main.html' %}
|
||||
{% 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/tests/__init__.py
Normal file
0
core/tests/__init__.py
Normal file
0
core/util/__init__.py
Normal file
0
core/util/__init__.py
Normal file
69
core/util/django_settings_export.py
Normal file
69
core/util/django_settings_export.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""
|
||||
Export Django settings to templates
|
||||
|
||||
https://github.com/jakubroztocil/django-settings-export
|
||||
|
||||
"""
|
||||
from django.conf import settings as django_settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
__version__ = "1.2.1"
|
||||
|
||||
|
||||
VARIABLE_NAME = getattr(django_settings, "SETTINGS_EXPORT_VARIABLE_NAME", "settings")
|
||||
|
||||
|
||||
class SettingsExportError(ImproperlyConfigured):
|
||||
"""Base error indicating misconfiguration."""
|
||||
|
||||
|
||||
class UndefinedSettingError(SettingsExportError):
|
||||
"""An undefined setting name included in SETTINGS_EXPORT."""
|
||||
|
||||
|
||||
class UnexportedSettingError(SettingsExportError):
|
||||
"""An unexported setting has been accessed from a template."""
|
||||
|
||||
|
||||
def settings_export(request):
|
||||
"""
|
||||
The template context processor that adds settings defined in
|
||||
`SETTINGS_EXPORT` to the context. If SETTINGS_EXPORT_VARIABLE_NAME is not
|
||||
set, the context variable will be `settings`.
|
||||
|
||||
"""
|
||||
variable_name = getattr(
|
||||
django_settings, "SETTINGS_EXPORT_VARIABLE_NAME", "settings"
|
||||
)
|
||||
return {variable_name: _get_exported_settings()}
|
||||
|
||||
|
||||
class ExportedSettings(dict):
|
||||
def __getitem__(self, item):
|
||||
"""Fail loudly if accessing a setting that is not exported."""
|
||||
try:
|
||||
return super(ExportedSettings, self).__getitem__(item)
|
||||
except KeyError:
|
||||
if hasattr(self, item):
|
||||
# Let the KeyError propagate so that Django templates
|
||||
# can access the existing attribute (e.g. `items()`).
|
||||
raise
|
||||
raise UnexportedSettingError(
|
||||
"The `{key}` setting key is not accessible"
|
||||
' from templates: add "{key}" to'
|
||||
" `settings.SETTINGS_EXPORT` to change that.".format(key=item)
|
||||
)
|
||||
|
||||
|
||||
def _get_exported_settings():
|
||||
exported_settings = ExportedSettings()
|
||||
for key in getattr(django_settings, "SETTINGS_EXPORT", []):
|
||||
try:
|
||||
value = getattr(django_settings, key)
|
||||
except AttributeError:
|
||||
raise UndefinedSettingError(
|
||||
'"settings.%s" is included in settings.SETTINGS_EXPORT '
|
||||
"but it does not exist. " % key
|
||||
)
|
||||
exported_settings[key] = value
|
||||
return exported_settings
|
68
core/util/logs.py
Normal file
68
core/util/logs.py
Normal file
@ -0,0 +1,68 @@
|
||||
# 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
341
core/views/__init__.py
Normal file
341
core/views/__init__.py
Normal file
@ -0,0 +1,341 @@
|
||||
import uuid
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import QuerySet
|
||||
from django.http import Http404, HttpResponseBadRequest
|
||||
from django.urls import reverse
|
||||
from django.views.generic.detail import DetailView
|
||||
from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
||||
from django.views.generic.list import ListView
|
||||
from rest_framework.parsers import FormParser
|
||||
|
||||
from core.util import logs
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
|
||||
class RestrictedViewMixin:
|
||||
"""
|
||||
This mixin overrides two helpers in order to pass the user object to the filters.
|
||||
get_queryset alters the objects returned for list views.
|
||||
get_form_kwargs passes the request object to the form class. Remaining permissions
|
||||
checks are in forms.py
|
||||
"""
|
||||
|
||||
allow_empty = True
|
||||
queryset = None
|
||||
model = None
|
||||
paginate_by = None
|
||||
paginate_orphans = 0
|
||||
context_object_name = None
|
||||
paginator_class = Paginator
|
||||
page_kwarg = "page"
|
||||
ordering = None
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
This function is overriden to filter the objects by the requesting user.
|
||||
"""
|
||||
if self.queryset is not None:
|
||||
queryset = self.queryset
|
||||
if isinstance(queryset, QuerySet):
|
||||
# queryset = queryset.all()
|
||||
queryset = queryset.filter(user=self.request.user)
|
||||
elif self.model is not None:
|
||||
queryset = self.model._default_manager.filter(user=self.request.user)
|
||||
else:
|
||||
raise ImproperlyConfigured(
|
||||
"%(cls)s is missing a QuerySet. Define "
|
||||
"%(cls)s.model, %(cls)s.queryset, or override "
|
||||
"%(cls)s.get_queryset()." % {"cls": self.__class__.__name__}
|
||||
)
|
||||
if hasattr(self, "get_ordering"):
|
||||
ordering = self.get_ordering()
|
||||
if ordering:
|
||||
if isinstance(ordering, str):
|
||||
ordering = (ordering,)
|
||||
queryset = queryset.order_by(*ordering)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_form_kwargs(self):
|
||||
"""Passes the request object to the form class.
|
||||
This is necessary to only display members that belong to a given user"""
|
||||
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["request"] = self.request
|
||||
return kwargs
|
||||
|
||||
|
||||
class ObjectNameMixin(object):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.title_singular = self.model._meta.verbose_name.title() # Hook
|
||||
self.context_object_name_singular = self.title_singular.lower() # hook
|
||||
self.title = self.model._meta.verbose_name_plural.title() # Hooks
|
||||
self.context_object_name = self.title.lower() # hooks
|
||||
|
||||
self.context_object_name = self.context_object_name.replace(" ", "")
|
||||
self.context_object_name_singular = self.context_object_name_singular.replace(
|
||||
" ", ""
|
||||
)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class ObjectList(RestrictedViewMixin, ObjectNameMixin, ListView):
|
||||
allowed_types = ["modal", "widget", "window", "page"]
|
||||
window_content = "window-content/objects.html"
|
||||
list_template = None
|
||||
|
||||
page_title = None
|
||||
page_subtitle = None
|
||||
|
||||
list_url_name = None
|
||||
# WARNING: TAKEN FROM locals()
|
||||
list_url_args = ["type"]
|
||||
|
||||
submit_url_name = None
|
||||
|
||||
delete_all_url_name = None
|
||||
|
||||
# copied from BaseListView
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.request = request
|
||||
self.object_list = self.get_queryset()
|
||||
allow_empty = self.get_allow_empty()
|
||||
|
||||
type = kwargs.get("type", None)
|
||||
if not type:
|
||||
return HttpResponseBadRequest("No type specified")
|
||||
if type not in self.allowed_types:
|
||||
return HttpResponseBadRequest("Invalid type specified")
|
||||
self.template_name = f"wm/{type}.html"
|
||||
unique = str(uuid.uuid4())[:8]
|
||||
|
||||
list_url_args = {}
|
||||
for arg in self.list_url_args:
|
||||
list_url_args[arg] = locals()[arg]
|
||||
|
||||
orig_type = type
|
||||
if type == "page":
|
||||
type = "modal"
|
||||
|
||||
if not allow_empty:
|
||||
# When pagination is enabled and object_list is a queryset,
|
||||
# it's better to do a cheap query than to load the unpaginated
|
||||
# queryset in memory.
|
||||
if self.get_paginate_by(self.object_list) is not None and hasattr(
|
||||
self.object_list, "exists"
|
||||
):
|
||||
is_empty = not self.object_list.exists()
|
||||
else:
|
||||
is_empty = not self.object_list
|
||||
if is_empty:
|
||||
raise Http404("Empty list")
|
||||
submit_url = reverse(self.submit_url_name, kwargs={"type": type})
|
||||
|
||||
list_url = reverse(self.list_url_name, kwargs=list_url_args)
|
||||
context = self.get_context_data()
|
||||
context["title"] = self.title + f" ({type})"
|
||||
context["title_singular"] = self.title_singular
|
||||
context["unique"] = unique
|
||||
context["window_content"] = self.window_content
|
||||
context["list_template"] = self.list_template
|
||||
context["page_title"] = self.page_title
|
||||
context["page_subtitle"] = self.page_subtitle
|
||||
context["type"] = type
|
||||
context["submit_url"] = submit_url
|
||||
context["list_url"] = list_url
|
||||
context["context_object_name"] = self.context_object_name
|
||||
context["context_object_name_singular"] = self.context_object_name_singular
|
||||
if self.delete_all_url_name:
|
||||
context["delete_all_url"] = reverse(self.delete_all_url_name)
|
||||
|
||||
# Return partials for HTMX
|
||||
if self.request.htmx:
|
||||
if orig_type == "page":
|
||||
self.template_name = self.list_template
|
||||
else:
|
||||
context["window_content"] = self.list_template
|
||||
return self.render_to_response(context)
|
||||
|
||||
|
||||
class ObjectCreate(RestrictedViewMixin, ObjectNameMixin, CreateView):
|
||||
allowed_types = ["modal", "widget", "window", "page"]
|
||||
window_content = "window-content/object-form.html"
|
||||
parser_classes = [FormParser]
|
||||
|
||||
model = None
|
||||
submit_url_name = None
|
||||
|
||||
list_url_name = None
|
||||
# WARNING: TAKEN FROM locals()
|
||||
list_url_args = ["type"]
|
||||
|
||||
request = None
|
||||
|
||||
def post_save(self, obj):
|
||||
pass
|
||||
|
||||
def form_valid(self, form):
|
||||
obj = form.save(commit=False)
|
||||
if self.request is None:
|
||||
raise Exception("Request is None")
|
||||
obj.user = self.request.user
|
||||
obj.save()
|
||||
form.save_m2m()
|
||||
self.post_save(obj)
|
||||
context = {"message": "Object created", "class": "success"}
|
||||
response = self.render_to_response(context)
|
||||
response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
|
||||
return response
|
||||
|
||||
def form_invalid(self, form):
|
||||
"""If the form is invalid, render the invalid form."""
|
||||
return self.get(self.request, **self.kwargs, form=form)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.request = request
|
||||
self.kwargs = kwargs
|
||||
type = kwargs.get("type", None)
|
||||
if not type:
|
||||
return HttpResponseBadRequest("No type specified")
|
||||
if type not in self.allowed_types:
|
||||
return HttpResponseBadRequest("Invalid type specified")
|
||||
self.template_name = f"wm/{type}.html"
|
||||
unique = str(uuid.uuid4())[:8]
|
||||
|
||||
list_url_args = {}
|
||||
for arg in self.list_url_args:
|
||||
list_url_args[arg] = locals()[arg]
|
||||
|
||||
if type == "page":
|
||||
type = "modal"
|
||||
|
||||
self.object = None
|
||||
submit_url = reverse(self.submit_url_name, kwargs={"type": type})
|
||||
|
||||
list_url = reverse(self.list_url_name, kwargs=list_url_args)
|
||||
context = self.get_context_data()
|
||||
form = kwargs.get("form", None)
|
||||
if form:
|
||||
context["form"] = form
|
||||
context["unique"] = unique
|
||||
context["window_content"] = self.window_content
|
||||
context["context_object_name"] = self.context_object_name
|
||||
context["context_object_name_singular"] = self.context_object_name_singular
|
||||
context["submit_url"] = submit_url
|
||||
context["list_url"] = list_url
|
||||
context["type"] = type
|
||||
response = self.render_to_response(context)
|
||||
# response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
|
||||
return response
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.request = request
|
||||
self.template_name = "partials/notify.html"
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
|
||||
class ObjectRead(RestrictedViewMixin, ObjectNameMixin, DetailView):
|
||||
allowed_types = ["modal", "widget", "window", "page"]
|
||||
window_content = "window-content/object.html"
|
||||
|
||||
model = None
|
||||
|
||||
|
||||
class ObjectUpdate(RestrictedViewMixin, ObjectNameMixin, UpdateView):
|
||||
allowed_types = ["modal", "widget", "window", "page"]
|
||||
window_content = "window-content/object-form.html"
|
||||
parser_classes = [FormParser]
|
||||
|
||||
model = None
|
||||
submit_url_name = None
|
||||
|
||||
request = None
|
||||
|
||||
def post_save(self, obj):
|
||||
pass
|
||||
|
||||
def form_valid(self, form):
|
||||
obj = form.save(commit=False)
|
||||
if self.request is None:
|
||||
raise Exception("Request is None")
|
||||
obj.save()
|
||||
form.save_m2m()
|
||||
self.post_save(obj)
|
||||
context = {"message": "Object updated", "class": "success"}
|
||||
response = self.render_to_response(context)
|
||||
response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
|
||||
return response
|
||||
|
||||
def form_invalid(self, form):
|
||||
"""If the form is invalid, render the invalid form."""
|
||||
return self.get(self.request, **self.kwargs, form=form)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.request = request
|
||||
type = kwargs.get("type", None)
|
||||
pk = kwargs.get("pk", None)
|
||||
if not type:
|
||||
return HttpResponseBadRequest("No type specified")
|
||||
if not pk:
|
||||
return HttpResponseBadRequest("No pk specified")
|
||||
if type not in self.allowed_types:
|
||||
return HttpResponseBadRequest("Invalid type specified")
|
||||
self.template_name = f"wm/{type}.html"
|
||||
unique = str(uuid.uuid4())[:8]
|
||||
if type == "page":
|
||||
type = "modal"
|
||||
|
||||
self.object = self.get_object()
|
||||
submit_url = reverse(self.submit_url_name, kwargs={"type": type, "pk": pk})
|
||||
context = self.get_context_data()
|
||||
form = kwargs.get("form", None)
|
||||
if form:
|
||||
context["form"] = form
|
||||
context["unique"] = unique
|
||||
context["window_content"] = self.window_content
|
||||
context["context_object_name"] = self.context_object_name
|
||||
context["context_object_name_singular"] = self.context_object_name_singular
|
||||
context["submit_url"] = submit_url
|
||||
context["type"] = type
|
||||
response = self.render_to_response(context)
|
||||
# response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
|
||||
return response
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.request = request
|
||||
self.template_name = "partials/notify.html"
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
|
||||
class ObjectDelete(RestrictedViewMixin, ObjectNameMixin, DeleteView):
|
||||
model = None
|
||||
template_name = "partials/notify.html"
|
||||
|
||||
# Overriden to prevent success URL from being used
|
||||
def delete(self, request, *args, **kwargs):
|
||||
"""
|
||||
Call the delete() method on the fetched object and then redirect to the
|
||||
success URL.
|
||||
"""
|
||||
self.object = self.get_object()
|
||||
# success_url = self.get_success_url()
|
||||
self.object.delete()
|
||||
context = {"message": "Object deleted", "class": "success"}
|
||||
response = self.render_to_response(context)
|
||||
response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
|
||||
return response
|
||||
|
||||
# This will be used in newer Django versions, until then we get a warning
|
||||
def form_valid(self, form):
|
||||
"""
|
||||
Call the delete() method on the fetched object.
|
||||
"""
|
||||
self.object = self.get_object()
|
||||
self.object.delete()
|
||||
context = {"message": "Object deleted", "class": "success"}
|
||||
response = self.render_to_response(context)
|
||||
response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
|
||||
return response
|
42
core/views/base.py
Normal file
42
core/views/base.py
Normal file
@ -0,0 +1,42 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse_lazy
|
||||
from django.views import View
|
||||
from django.views.generic.edit import CreateView
|
||||
|
||||
from core.forms import NewUserForm
|
||||
from core.lib.notify import raw_sendmsg
|
||||
|
||||
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 Signup(CreateView):
|
||||
form_class = NewUserForm
|
||||
success_url = reverse_lazy("two_factor:login")
|
||||
template_name = "registration/signup.html"
|
||||
|
||||
def form_valid(self, form):
|
||||
"""If the form is valid, save the associated model."""
|
||||
self.object = form.save()
|
||||
raw_sendmsg(
|
||||
f"New user signup: {self.object.username} - {self.object.email}",
|
||||
title="New user",
|
||||
topic=settings.NOTIFY_TOPIC,
|
||||
)
|
||||
return super().form_valid(form)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if not settings.REGISTRATION_OPEN:
|
||||
return render(request, "registration/registration_closed.html")
|
||||
return super().get(request, *args, **kwargs)
|
26
core/views/demo.py
Normal file
26
core/views/demo.py
Normal 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"
|
||||
|
||||
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
|
30
core/views/notifications.py
Normal file
30
core/views/notifications.py
Normal file
@ -0,0 +1,30 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from mixins.views import ObjectUpdate
|
||||
|
||||
from core.forms import NotificationSettingsForm
|
||||
from core.models import NotificationSettings
|
||||
|
||||
|
||||
# Notifications - we create a new notification settings object if there isn't one
|
||||
# Hence, there is only an update view, not a create view.
|
||||
class NotificationsUpdate(LoginRequiredMixin, ObjectUpdate):
|
||||
model = NotificationSettings
|
||||
form_class = NotificationSettingsForm
|
||||
|
||||
page_title = "Update your notification settings"
|
||||
page_subtitle = (
|
||||
"At least the topic must be set if you want to receive notifications."
|
||||
)
|
||||
|
||||
submit_url_name = "notifications_update"
|
||||
submit_url_args = ["type"]
|
||||
|
||||
pk_required = False
|
||||
|
||||
hide_cancel = True
|
||||
|
||||
def get_object(self, **kwargs):
|
||||
notification_settings, _ = NotificationSettings.objects.get_or_create(
|
||||
user=self.request.user
|
||||
)
|
||||
return notification_settings
|
125
docker-compose.yml
Normal file
125
docker-compose.yml
Normal file
@ -0,0 +1,125 @@
|
||||
version: "2.2"
|
||||
|
||||
services:
|
||||
app:
|
||||
image: xf/envelope:prod
|
||||
container_name: envelope
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
OPERATION: ${OPERATION}
|
||||
volumes:
|
||||
- ${REPO_DIR}:/code
|
||||
- ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini
|
||||
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
|
||||
- app_static:${STATIC_ROOT}
|
||||
#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
|
||||
networks:
|
||||
- default
|
||||
- xf
|
||||
|
||||
migration:
|
||||
image: xf/envelope:prod
|
||||
container_name: migration_envelope
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
OPERATION: ${OPERATION}
|
||||
command: sh -c '. /venv/bin/activate && python manage.py migrate --noinput'
|
||||
volumes:
|
||||
- ${REPO_DIR}:/code
|
||||
- ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini
|
||||
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
|
||||
- app_static:${STATIC_ROOT}
|
||||
volumes_from:
|
||||
- tmp
|
||||
env_file:
|
||||
- stack.env
|
||||
|
||||
collectstatic:
|
||||
image: xf/envelope:prod
|
||||
container_name: collectstatic_envelope
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
OPERATION: ${OPERATION}
|
||||
command: sh -c '. /venv/bin/activate && python manage.py collectstatic --noinput'
|
||||
volumes:
|
||||
- ${REPO_DIR}:/code
|
||||
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
|
||||
- app_static:${STATIC_ROOT}
|
||||
volumes_from:
|
||||
- tmp
|
||||
env_file:
|
||||
- stack.env
|
||||
|
||||
nginx:
|
||||
image: nginx:latest
|
||||
container_name: nginx_envelope
|
||||
ports:
|
||||
- ${APP_PORT}:9999
|
||||
ulimits:
|
||||
nproc: 65535
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
volumes:
|
||||
- ${REPO_DIR}:/code
|
||||
- ${REPO_DIR}/docker/nginx/conf.d/${OPERATION}.conf:/etc/nginx/conf.d/default.conf
|
||||
- app_static:${STATIC_ROOT}
|
||||
volumes_from:
|
||||
- tmp
|
||||
networks:
|
||||
- default
|
||||
- xf
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_started
|
||||
|
||||
tmp:
|
||||
image: busybox
|
||||
container_name: tmp_envelope
|
||||
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:
|
||||
- ${REPO_DIR}/docker/redis.conf:/etc/redis.conf
|
||||
- redis_data:/data
|
||||
volumes_from:
|
||||
- tmp
|
||||
healthcheck:
|
||||
test: "redis-cli -s /var/run/socks/redis.sock ping"
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 15
|
||||
|
||||
networks:
|
||||
default:
|
||||
driver: bridge
|
||||
xf:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
app_static: {}
|
||||
redis_data: {}
|
23
docker/nginx/conf.d/dev.conf
Normal file
23
docker/nginx/conf.d/dev.conf
Normal file
@ -0,0 +1,23 @@
|
||||
upstream django {
|
||||
#server app:8000;
|
||||
#server unix:///var/run/socks/app.sock;
|
||||
server app:8000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 9999;
|
||||
|
||||
location = /favicon.ico { access_log off; log_not_found off; }
|
||||
|
||||
location /static/ {
|
||||
root /conf;
|
||||
}
|
||||
|
||||
location / {
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
24
docker/nginx/conf.d/uwsgi.conf
Normal file
24
docker/nginx/conf.d/uwsgi.conf
Normal file
@ -0,0 +1,24 @@
|
||||
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 /conf;
|
||||
}
|
||||
|
||||
location / {
|
||||
include /etc/nginx/uwsgi_params; # the uwsgi_params file you installed
|
||||
uwsgi_pass django;
|
||||
uwsgi_param Host $host;
|
||||
uwsgi_param X-Real-IP $remote_addr;
|
||||
uwsgi_param X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
uwsgi_param X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
}
|
||||
|
||||
}
|
2
docker/redis.conf
Normal file
2
docker/redis.conf
Normal file
@ -0,0 +1,2 @@
|
||||
unixsocket /var/run/socks/redis.sock
|
||||
unixsocketperm 777
|
12
docker/uwsgi.ini
Normal file
12
docker/uwsgi.ini
Normal file
@ -0,0 +1,12 @@
|
||||
[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
|
||||
harakiri=20
|
||||
max-requests=100000
|
||||
vacuum=1
|
||||
home=/venv
|
||||
processes=12
|
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()
|
31
requirements.txt
Normal file
31
requirements.txt
Normal file
@ -0,0 +1,31 @@
|
||||
wheel
|
||||
uwsgi
|
||||
django
|
||||
pre-commit
|
||||
django-crispy-forms==1.14.0
|
||||
crispy-bulma
|
||||
# stripe
|
||||
django-rest-framework
|
||||
uvloop
|
||||
uvicorn[standard]
|
||||
gunicorn
|
||||
django-htmx
|
||||
cryptography
|
||||
django-debug-toolbar
|
||||
django-debug-toolbar-template-profiler
|
||||
orjson
|
||||
watchfiles
|
||||
django-otp
|
||||
django-two-factor-auth
|
||||
django-otp-yubikey
|
||||
phonenumbers
|
||||
qrcode
|
||||
pydantic
|
||||
# glom
|
||||
git+https://git.zm.is/XF/django-crud-mixins
|
||||
# pyroscope-io
|
||||
# For caching
|
||||
redis
|
||||
hiredis
|
||||
django-cachalot
|
||||
django_redis
|
16
stack.env.example
Normal file
16
stack.env.example
Normal file
@ -0,0 +1,16 @@
|
||||
APP_PORT=5006
|
||||
REPO_DIR=.
|
||||
APP_LOCAL_SETTINGS=./app/local_settings.py
|
||||
APP_DATABASE_FILE=./db.sqlite3
|
||||
DOMAIN=dev.local
|
||||
URL=http://127.0.0.1:5006
|
||||
ALLOWED_HOSTS=127.0.0.1,dev.local,zm.is,localhost,xf
|
||||
NOTIFY_TOPIC=some-ntfy-topic
|
||||
CSRF_TRUSTED_ORIGINS=http://127.0.0.1:5006,http://localhost:5006,http://qi:5006
|
||||
DEBUG=y
|
||||
SECRET_KEY=asdkld00s0s0ds
|
||||
STATIC_ROOT=/conf/static
|
||||
REGISTRATION_OPEN=1
|
||||
OPERATION=dev
|
||||
PROFILER=0
|
||||
BILLING_ENABLED=0
|
Loading…
Reference in New Issue
Block a user