Compare commits
15 Commits
32de41ed89
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
0f85cdf059
|
|||
|
b8e094f4f7
|
|||
|
c9dd0c1282
|
|||
|
83a3761d17
|
|||
|
906f34f3a2
|
|||
|
22823ec0aa
|
|||
|
b055dc9f77
|
|||
|
84be2a7278
|
|||
|
c54b3e5412
|
|||
|
5cba45e0a7
|
|||
|
1de84e17c9
|
|||
|
d805a39466
|
|||
|
8a6ca7c2c9
|
|||
|
a73f852e39
|
|||
|
e121b135a2
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -58,7 +58,6 @@ cover/
|
|||||||
|
|
||||||
# Django stuff:
|
# Django stuff:
|
||||||
*.log
|
*.log
|
||||||
local_settings.py
|
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
db.sqlite3-journal
|
db.sqlite3-journal
|
||||||
|
|
||||||
@@ -158,3 +157,5 @@ cython_debug/
|
|||||||
.vscode/
|
.vscode/
|
||||||
core/static/admin
|
core/static/admin
|
||||||
core/static/debug_toolbar
|
core/static/debug_toolbar
|
||||||
|
docker/data/
|
||||||
|
static/
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
repos:
|
|
||||||
- repo: https://github.com/psf/black
|
|
||||||
rev: 22.6.0
|
|
||||||
hooks:
|
|
||||||
- id: black
|
|
||||||
exclude: ^core/migrations
|
|
||||||
- repo: https://github.com/PyCQA/isort
|
|
||||||
rev: 5.10.1
|
|
||||||
hooks:
|
|
||||||
- id: isort
|
|
||||||
args: ["--profile", "black"]
|
|
||||||
- repo: https://github.com/PyCQA/flake8
|
|
||||||
rev: 4.0.1
|
|
||||||
hooks:
|
|
||||||
- id: flake8
|
|
||||||
args: [--max-line-length=88]
|
|
||||||
exclude: ^core/migrations
|
|
||||||
- repo: https://github.com/rtts/djhtml
|
|
||||||
rev: 'v1.5.2'
|
|
||||||
hooks:
|
|
||||||
- id: djhtml
|
|
||||||
args: [-t 2]
|
|
||||||
- id: djcss
|
|
||||||
exclude : ^core/static/css # slow
|
|
||||||
- id: djjs
|
|
||||||
exclude: ^core/static/js # slow
|
|
||||||
- repo: https://github.com/sirwart/ripsecrets.git
|
|
||||||
rev: v0.1.5
|
|
||||||
hooks:
|
|
||||||
- id: ripsecrets
|
|
||||||
# - repo: https://github.com/thibaudcolas/curlylint
|
|
||||||
# rev: v0.13.1
|
|
||||||
# hooks:
|
|
||||||
# - id: curlylint
|
|
||||||
# files: \.(html|sls)$
|
|
||||||
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
|
||||||
23
Makefile
23
Makefile
@@ -1,11 +1,26 @@
|
|||||||
run:
|
run:
|
||||||
docker-compose -f docker/docker-compose.prod.yml --env-file=stack.env up -d
|
docker-compose --env-file=stack.env up -d
|
||||||
|
|
||||||
build:
|
build:
|
||||||
docker-compose -f docker/docker-compose.prod.yml --env-file=stack.env build
|
docker-compose --env-file=stack.env build
|
||||||
|
|
||||||
stop:
|
stop:
|
||||||
docker-compose -f docker/docker-compose.prod.yml --env-file=stack.env down
|
docker-compose --env-file=stack.env down
|
||||||
|
|
||||||
log:
|
log:
|
||||||
docker-compose -f docker/docker-compose.prod.yml --env-file=stack.env logs -f
|
docker-compose --env-file=stack.env logs -f --names
|
||||||
|
|
||||||
|
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"
|
||||||
|
|||||||
202
README.md
202
README.md
@@ -1,64 +1,158 @@
|
|||||||
# Envelope
|
# 🚀 Django Starter Template
|
||||||
Template Django app.
|
|
||||||
|
|
||||||
## Setting up the environment
|
A **lightweight, production-ready Django template** for rapid project development.
|
||||||
Create the virtual environment, enable it, and install the dependencies.
|
|
||||||
```shell
|
## 📌 Features
|
||||||
$ python3 -m venv env
|
- **Django 4+ with Modular Structure:** A clean, scalable, and extensible architecture for fast development.
|
||||||
$ source env/bin/activate
|
- **Pre-configured Podman & Docker Support:** Seamlessly deploy using containerized environments.
|
||||||
(env) $ pip install -r docker/prod/requirements.prod.txt
|
- **ManticoreSearch & Redis Integration (Optional):** Enhanced search and caching capabilities for high-performance applications.
|
||||||
|
- **Multi-Agent Execution:** Supports **processing, scheduling, and background task handling** via Django management commands.
|
||||||
|
- **Automated Migrations & Static File Handling:** Database migrations and `collectstatic` are run automatically before deployment.
|
||||||
|
- **Built-in Django Admin & Authentication:** User authentication, registration, and two-factor authentication (2FA) ready.
|
||||||
|
- **HTMX & Hyperscript Powered UI:** Provides a dynamic and responsive frontend with lightweight interactivity.
|
||||||
|
- **Bulma-Based UI with Rich Components:** Pre-integrated **Bulma CSS framework**, tooltips, sliders, calendars, and grid-based layouts.
|
||||||
|
- **Chart.js & Data Visualization:** Easily generate and display interactive charts and graphs.
|
||||||
|
- **Modular Notifications System:** Supports email, in-app, and third-party notifications with extensible `notify.py`.
|
||||||
|
- **Integrated Background Task Management:** Built-in support for scheduling and executing tasks asynchronously.
|
||||||
|
- **Security Enhancements:** Includes Two-Factor Authentication (2FA) via Django’s `two_factor` package.
|
||||||
|
- **Easy Integration with [django-crud-mixins](https://git.zm.is/XF/django-crud-mixins):** Rapidly create CRUD operations with minimal code.
|
||||||
|
- **Built-in Support for Django Signals:** Process real-time events and system hooks efficiently.
|
||||||
|
- **Custom Django Template Tags:** Extend template functionality with built-in utilities.
|
||||||
|
- **Custom Django Views & Permissions Management:** Pre-configured base views, notifications, and access control utilities.
|
||||||
|
- **Manifest & PWA Support:** Adds Progressive Web App (PWA) capabilities with a manifest file and service worker support.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quickstart Guide
|
||||||
|
|
||||||
|
### 🔧 Setting Up the Environment
|
||||||
|
|
||||||
|
Ensure you have **Podman** or **Docker** installed.
|
||||||
|
|
||||||
|
1. **Clone the repository**
|
||||||
|
```shell
|
||||||
|
❯ git clone https://git.zm.is/XF/envelope
|
||||||
|
❯ cd envelope
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Set up the environment variables
|
||||||
|
```shell
|
||||||
|
❯ cp stack.env.example stack.env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Edit `stack.env`
|
||||||
|
|
||||||
|
4. Build and start the containers
|
||||||
|
```shell
|
||||||
|
❯ make build
|
||||||
|
❯ make run
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Run database migrations
|
||||||
|
```shell
|
||||||
|
❯ make migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Create a superuser for Django Admin (optional but recommended)
|
||||||
|
```shell
|
||||||
|
❯ make auth
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Monitor logs
|
||||||
|
```shell
|
||||||
|
❯ make log
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ Deployment & Architecture
|
||||||
|
### 🏗️ Services Overview
|
||||||
|
| Service | Description |
|
||||||
|
|----------------|-------------|
|
||||||
|
| **app** | Main application container running **Uvicorn** for API handling. |
|
||||||
|
| **db** | ManticoreSearch-based database backend. |
|
||||||
|
| **redis** | Message queue for task distribution. |
|
||||||
|
| **signal-cli** | Handles Signal communications. |
|
||||||
|
| **processing** | Processes streams. |
|
||||||
|
| **scheduling** | Handles timed tasks. |
|
||||||
|
| **migration** | Runs database migrations automatically on startup. |
|
||||||
|
| **collectstatic** | Collects static files for Django before launch. |
|
||||||
|
|
||||||
|
## 🔥 Running Commands in a Container
|
||||||
|
|
||||||
|
You can execute management commands inside the app container using:
|
||||||
|
```shell
|
||||||
|
❯ docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py <command>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛑 Stopping the Project
|
||||||
|
To stop all running services:
|
||||||
|
```shell
|
||||||
|
❯ make stop
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🕵️♂️ Operational Modes
|
||||||
|
The app runs in different operation modes set via OPERATION:
|
||||||
|
| Mode | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| **uwsgi** | Runs behind Nginx for production. |
|
||||||
|
| **dev** | Direct execution via Django's built-in server (development mode). |
|
||||||
|
|
||||||
|
|
||||||
|
The default Podman entrypoint dynamically selects the correct process based on OPERATION.
|
||||||
|
Be sure to uncomment nginx if using dev, as the shipped setup expects an **external** `nginx` instance to point to the app's `uwsgi` sock:
|
||||||
|
```
|
||||||
|
location / {
|
||||||
|
include include/xf-only.conf;
|
||||||
|
include /etc/nginx/uwsgi_params;
|
||||||
|
uwsgi_pass unix:///code/run/uwsgi.sock;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
location /static {
|
||||||
|
alias /code/xf/envelope/static/;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Local settings
|
## 🔄 Persistent Data & Storage
|
||||||
You'll need to copy the `app/local_settings.example.py` file to `app/local_settings.py`. The project won't start otherwise.
|
| Mount Path (Host) | Purpose |
|
||||||
```
|
|---------------------------|-------------|
|
||||||
$ cp app/local_settings.example.py app/local_settings.py
|
| **docker/uwsgi.ini** | Configuration for **uWSGI** execution. |
|
||||||
```
|
| **db.sqlite3** | SQLite database storage. |
|
||||||
|
| **/code/vrun/** | Sockets shared between services. |
|
||||||
|
| **signal-cli-config/** | Stores **Signal CLI** configuration and keys. |
|
||||||
|
|
||||||
## stack.env
|
## 🔧 Additional Configuration
|
||||||
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.
|
### Django Environment Variables
|
||||||
To run it manually, you will need to copy `stack.env.example` to `stack.env` in the project root.
|
|
||||||
|
|
||||||
## Running database migrations
|
The following are required for proper operation:
|
||||||
Now we need to run the database migrations in order to get a working database.
|
```shell
|
||||||
```shell
|
APP_PORT=5006
|
||||||
(env) $ python manage.py migrate
|
REPO_DIR=.
|
||||||
```
|
APP_LOCAL_SETTINGS=./app/local_settings.py
|
||||||
Note that these are automatically run by a step in the compose file in production.
|
APP_DATABASE_FILE=./db.sqlite3
|
||||||
You won't need to do that manually.
|
DOMAIN=example.com
|
||||||
|
URL=https://example.com
|
||||||
|
ALLOWED_HOSTS=example.com
|
||||||
|
NOTIFY_TOPIC=example-topic
|
||||||
|
CSRF_TRUSTED_ORIGINS=https://example.com
|
||||||
|
DEBUG=y
|
||||||
|
SECRET_KEY=
|
||||||
|
STATIC_ROOT=/code/static
|
||||||
|
REGISTRATION_OPEN=0
|
||||||
|
OPERATION=uwsgi
|
||||||
|
BILLING_ENABLED=0
|
||||||
|
```
|
||||||
|
|
||||||
## Creating a superuser
|
These can be set inside `stack.env`.
|
||||||
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
|
## ⚠️ Legal Disclaimer
|
||||||
The Docker Compose file is located in `docker/docker-compose.prod.yml`.
|
**This software is provided as-is, without warranty.**
|
||||||
There is a shortcut to run it: `make run`.
|
|
||||||
|
|
||||||
## Stopping
|
By using this Django template, you agree that:
|
||||||
To stop the containers, run `make stop`.
|
* You are responsible for your own configurations and security setup.
|
||||||
|
* The authors disclaim all liability for damages resulting from its use.
|
||||||
|
|
||||||
## Setup
|
This template is for educational and development purposes only. **Ensure compliance with all relevant legal and security standards before deployment.**
|
||||||
This setup may be different from what you've seen before.
|
|
||||||
|
|
||||||
### Uvicorn
|
This serves as a **ready-to-use Django starter kit** with a structured setup for **quick deployment**. Let me know if you want additional tweaks! 🚀
|
||||||
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.
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
# URLs
|
|
||||||
DOMAIN = "example.com"
|
|
||||||
URL = f"https://{DOMAIN}"
|
|
||||||
|
|
||||||
# Access control
|
|
||||||
ALLOWED_HOSTS = ["127.0.0.1", DOMAIN]
|
|
||||||
|
|
||||||
# CSRF
|
|
||||||
CSRF_TRUSTED_ORIGINS = [URL]
|
|
||||||
|
|
||||||
# Stripe
|
|
||||||
STRIPE_TEST = True
|
|
||||||
STRIPE_API_KEY_TEST = ""
|
|
||||||
STRIPE_PUBLIC_API_KEY_TEST = ""
|
|
||||||
|
|
||||||
STRIPE_API_KEY_PROD = ""
|
|
||||||
STRIPE_PUBLIC_API_KEY_PROD = ""
|
|
||||||
|
|
||||||
STRIPE_ENDPOINT_SECRET = ""
|
|
||||||
STATIC_ROOT = ""
|
|
||||||
SECRET_KEY = "a"
|
|
||||||
|
|
||||||
STRIPE_ADMIN_COUPON = ""
|
|
||||||
|
|
||||||
DEBUG = True
|
|
||||||
PROFILER = False
|
|
||||||
|
|
||||||
if DEBUG:
|
|
||||||
import socket # only if you haven't already imported this
|
|
||||||
|
|
||||||
hostname, _, ips = socket.gethostbyname_ex(socket.gethostname())
|
|
||||||
INTERNAL_IPS = [ip[: ip.rfind(".")] + ".1" for ip in ips] + [
|
|
||||||
"127.0.0.1",
|
|
||||||
"10.0.2.2",
|
|
||||||
]
|
|
||||||
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"]
|
||||||
@@ -30,6 +30,7 @@ ALLOWED_HOSTS = []
|
|||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"core",
|
"core",
|
||||||
"django.contrib.admin",
|
"django.contrib.admin",
|
||||||
|
# 'core.apps.LibraryAdminConfig', # our custom OTP'ed admin
|
||||||
"django.contrib.auth",
|
"django.contrib.auth",
|
||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
@@ -42,7 +43,39 @@ INSTALLED_APPS = [
|
|||||||
"crispy_bulma",
|
"crispy_bulma",
|
||||||
# "django_tables2",
|
# "django_tables2",
|
||||||
# "django_tables2_bulma_template",
|
# "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",
|
||||||
|
# "LOCATION": f"redis://{REDIS_HOST}:{REDIS_PORT}",
|
||||||
|
"LOCATION": "unix:///var/run/envelope-redis.sock",
|
||||||
|
"OPTIONS": {
|
||||||
|
"db": 10,
|
||||||
|
# "parser_class": "django_redis.cache.RedisCache",
|
||||||
|
# "PASSWORD": REDIS_PASSWORD,
|
||||||
|
"pool_class": "redis.BlockingConnectionPool",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# CACHE_MIDDLEWARE_ALIAS = 'default'
|
||||||
|
# CACHE_MIDDLEWARE_SECONDS = '600'
|
||||||
|
# CACHE_MIDDLEWARE_KEY_PREFIX = ''
|
||||||
|
|
||||||
CRISPY_TEMPLATE_PACK = "bulma"
|
CRISPY_TEMPLATE_PACK = "bulma"
|
||||||
CRISPY_ALLOWED_TEMPLATE_PACKS = ("bulma",)
|
CRISPY_ALLOWED_TEMPLATE_PACKS = ("bulma",)
|
||||||
DJANGO_TABLES2_TEMPLATE = "django-tables2/bulma.html"
|
DJANGO_TABLES2_TEMPLATE = "django-tables2/bulma.html"
|
||||||
@@ -51,9 +84,12 @@ MIDDLEWARE = [
|
|||||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
# 'django.middleware.cache.UpdateCacheMiddleware',
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
# 'django.middleware.cache.FetchFromCacheMiddleware',
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django_otp.middleware.OTPMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
"django_htmx.middleware.HtmxMiddleware",
|
"django_htmx.middleware.HtmxMiddleware",
|
||||||
@@ -87,7 +123,7 @@ WSGI_APPLICATION = "app.wsgi.application"
|
|||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
"NAME": BASE_DIR / "db.sqlite3",
|
"NAME": "/conf/db.sqlite3",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +168,9 @@ AUTH_USER_MODEL = "core.User"
|
|||||||
|
|
||||||
LOGIN_REDIRECT_URL = "home"
|
LOGIN_REDIRECT_URL = "home"
|
||||||
LOGOUT_REDIRECT_URL = "home"
|
LOGOUT_REDIRECT_URL = "home"
|
||||||
LOGIN_URL = "/accounts/login/"
|
# 2FA
|
||||||
|
LOGIN_URL = "two_factor:login"
|
||||||
|
# LOGIN_REDIRECT_URL = 'two_factor:profile'
|
||||||
|
|
||||||
# ALLOWED_PAYMENT_METHODS = ["bacs_debit", "card"]
|
# ALLOWED_PAYMENT_METHODS = ["bacs_debit", "card"]
|
||||||
ALLOWED_PAYMENT_METHODS = ["card"]
|
ALLOWED_PAYMENT_METHODS = ["card"]
|
||||||
@@ -164,6 +202,7 @@ DEBUG_TOOLBAR_PANELS = [
|
|||||||
"debug_toolbar.panels.logging.LoggingPanel",
|
"debug_toolbar.panels.logging.LoggingPanel",
|
||||||
"debug_toolbar.panels.redirects.RedirectsPanel",
|
"debug_toolbar.panels.redirects.RedirectsPanel",
|
||||||
"debug_toolbar.panels.profiling.ProfilingPanel",
|
"debug_toolbar.panels.profiling.ProfilingPanel",
|
||||||
|
"cachalot.panels.CachalotPanel",
|
||||||
]
|
]
|
||||||
|
|
||||||
from app.local_settings import * # noqa
|
from app.local_settings import * # noqa
|
||||||
@@ -179,3 +218,12 @@ if PROFILER: # noqa - trust me its there
|
|||||||
# "region": f'{os.getenv("REGION")}',
|
# "region": f'{os.getenv("REGION")}',
|
||||||
# }
|
# }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def show_toolbar(request):
|
||||||
|
return DEBUG # noqa: from local imports
|
||||||
|
|
||||||
|
|
||||||
|
DEBUG_TOOLBAR_CONFIG = {
|
||||||
|
"SHOW_TOOLBAR_CALLBACK": show_toolbar,
|
||||||
|
}
|
||||||
|
|||||||
31
app/urls.py
31
app/urls.py
@@ -16,32 +16,25 @@ Including another URLconf
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.views import LogoutView
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
from two_factor.urls import urlpatterns as tf_urls
|
||||||
|
|
||||||
from core.views import base, demo
|
from core.views import base, notifications
|
||||||
from core.views.callbacks import Callback
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("__debug__/", include("debug_toolbar.urls")),
|
path("__debug__/", include("debug_toolbar.urls")),
|
||||||
path("", base.Home.as_view(), name="home"),
|
path("", base.Home.as_view(), name="home"),
|
||||||
path("callback", Callback.as_view(), name="callback"),
|
|
||||||
path("billing/", base.Billing.as_view(), name="billing"),
|
|
||||||
path("order/<str:plan_name>/", base.Order.as_view(), name="order"),
|
|
||||||
path(
|
|
||||||
"cancel_subscription/<str:plan_name>/",
|
|
||||||
base.Cancel.as_view(),
|
|
||||||
name="cancel_subscription",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"success/", TemplateView.as_view(template_name="success.html"), name="success"
|
|
||||||
),
|
|
||||||
path("cancel/", TemplateView.as_view(template_name="cancel.html"), name="cancel"),
|
|
||||||
path("portal", base.Portal.as_view(), name="portal"),
|
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("accounts/", include("django.contrib.auth.urls")),
|
# 2FA login urls
|
||||||
|
path("", include(tf_urls)),
|
||||||
path("accounts/signup/", base.Signup.as_view(), name="signup"),
|
path("accounts/signup/", base.Signup.as_view(), name="signup"),
|
||||||
path("demo/modal/", demo.DemoModal.as_view(), name="modal"),
|
path("accounts/logout/", LogoutView.as_view(), name="logout"),
|
||||||
path("demo/widget/", demo.DemoWidget.as_view(), name="widget"),
|
# Notifications
|
||||||
path("demo/window/", demo.DemoWindow.as_view(), name="window"),
|
path(
|
||||||
|
"notifications/<str:type>/update/",
|
||||||
|
notifications.NotificationsUpdate.as_view(),
|
||||||
|
name="notifications_update",
|
||||||
|
),
|
||||||
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
import stripe
|
# import stripe
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
|
os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
|
||||||
@@ -8,7 +8,7 @@ os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
|
|||||||
|
|
||||||
# r = StrictRedis(unix_socket_path="/var/run/redis/redis.sock", db=0)
|
# r = StrictRedis(unix_socket_path="/var/run/redis/redis.sock", db=0)
|
||||||
|
|
||||||
if settings.STRIPE_TEST:
|
# if settings.STRIPE_TEST:
|
||||||
stripe.api_key = settings.STRIPE_API_KEY_TEST
|
# stripe.api_key = settings.STRIPE_API_KEY_TEST
|
||||||
else:
|
# else:
|
||||||
stripe.api_key = settings.STRIPE_API_KEY_PROD
|
# stripe.api_key = settings.STRIPE_API_KEY_PROD
|
||||||
|
|||||||
@@ -2,32 +2,35 @@ from django.contrib import admin
|
|||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
|
||||||
from .forms import CustomUserCreationForm
|
from .forms import CustomUserCreationForm
|
||||||
from .models import Plan, Session, User
|
from .models import NotificationSettings, User
|
||||||
|
|
||||||
|
|
||||||
# Register your models here.
|
# Register your models here.
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(UserAdmin):
|
||||||
list_filter = ["plans"]
|
# list_filter = ["plans"]
|
||||||
model = User
|
model = User
|
||||||
add_form = CustomUserCreationForm
|
add_form = CustomUserCreationForm
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
*UserAdmin.fieldsets,
|
*UserAdmin.fieldsets,
|
||||||
(
|
(
|
||||||
"Stripe information",
|
"Billing information",
|
||||||
{"fields": ("stripe_id",)},
|
{"fields": ("billing_provider_id", "customer_id", "stripe_id")},
|
||||||
),
|
|
||||||
(
|
|
||||||
"Payment information",
|
|
||||||
{
|
|
||||||
"fields": (
|
|
||||||
"plans",
|
|
||||||
"last_payment",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
# (
|
||||||
|
# "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(User, CustomUserAdmin)
|
||||||
admin.site.register(Plan)
|
admin.site.register(NotificationSettings, NotificationSettingsAdmin)
|
||||||
admin.site.register(Session)
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth.forms import UserCreationForm
|
from django.contrib.auth.forms import UserCreationForm
|
||||||
|
from django.forms import ModelForm
|
||||||
|
from mixins.restrictions import RestrictedFormMixin
|
||||||
|
|
||||||
from .models import User
|
from .models import NotificationSettings, User
|
||||||
|
|
||||||
# Create your forms here.
|
# Create your forms here.
|
||||||
|
|
||||||
@@ -28,6 +30,19 @@ class NewUserForm(UserCreationForm):
|
|||||||
return user
|
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 CustomUserCreationForm(UserCreationForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
import stripe
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def expand_name(first_name, last_name):
|
|
||||||
"""
|
|
||||||
Convert two name variables into one.
|
|
||||||
Last name without a first name is ignored.
|
|
||||||
"""
|
|
||||||
name = None
|
|
||||||
if first_name:
|
|
||||||
name = first_name
|
|
||||||
# We only want to put the last name if we have a first name
|
|
||||||
if last_name:
|
|
||||||
name += f" {last_name}"
|
|
||||||
return name
|
|
||||||
|
|
||||||
|
|
||||||
def get_or_create(email, first_name, last_name):
|
|
||||||
"""
|
|
||||||
Get a customer ID from Stripe if one with the given email exists.
|
|
||||||
Create a customer if one does not.
|
|
||||||
Raise an exception if two or more customers matching the given email exist.
|
|
||||||
"""
|
|
||||||
# Let's see if we're just missing the ID
|
|
||||||
matching_customers = stripe.Customer.list(email=email, limit=2)
|
|
||||||
if len(matching_customers) == 2:
|
|
||||||
# Something is horribly wrong
|
|
||||||
logger.error(f"Two customers found for email {email}")
|
|
||||||
raise Exception(f"Two customers found for email {email}")
|
|
||||||
|
|
||||||
elif len(matching_customers) == 1:
|
|
||||||
# We found a customer. Let's copy the ID
|
|
||||||
customer = matching_customers["data"][0]
|
|
||||||
customer_id = customer["id"]
|
|
||||||
return customer_id
|
|
||||||
|
|
||||||
else:
|
|
||||||
# We didn't find anything. Create the customer
|
|
||||||
|
|
||||||
# Create a name, since we have 2 variables which could be null
|
|
||||||
name = expand_name(first_name, last_name)
|
|
||||||
cast = {"email": email}
|
|
||||||
if name:
|
|
||||||
cast["name"] = name
|
|
||||||
customer = stripe.Customer.create(**cast)
|
|
||||||
logger.info(f"Created new Stripe customer {customer.id} with email {email}")
|
|
||||||
|
|
||||||
return customer.id
|
|
||||||
|
|
||||||
|
|
||||||
def update_customer_fields(stripe_id, email=None, first_name=None, last_name=None):
|
|
||||||
"""
|
|
||||||
Update the customer fields in Stripe.
|
|
||||||
"""
|
|
||||||
if email:
|
|
||||||
stripe.Customer.modify(stripe_id, email=email)
|
|
||||||
logger.info(f"Modified Stripe customer {stripe_id} to have email {email}")
|
|
||||||
if first_name or last_name:
|
|
||||||
name = expand_name(first_name, last_name)
|
|
||||||
stripe.Customer.modify(stripe_id, name=name)
|
|
||||||
logger.info(f"Modified Stripe customer {stripe_id} to have email {name}")
|
|
||||||
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": "GIA"}
|
||||||
|
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)
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from asgiref.sync import sync_to_async
|
|
||||||
|
|
||||||
from core.models import Plan
|
|
||||||
|
|
||||||
|
|
||||||
async def assemble_plan_map(product_id_filter=None):
|
|
||||||
"""
|
|
||||||
Get all the plans from the database and create an object Stripe wants.
|
|
||||||
"""
|
|
||||||
line_items = []
|
|
||||||
for plan in await sync_to_async(list)(Plan.objects.all()):
|
|
||||||
if product_id_filter:
|
|
||||||
if plan.product_id != product_id_filter:
|
|
||||||
continue
|
|
||||||
line_items.append(
|
|
||||||
{
|
|
||||||
"price": plan.product_id,
|
|
||||||
"quantity": 1,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return line_items
|
|
||||||
0
core/management/__init__.py
Normal file
0
core/management/__init__.py
Normal file
0
core/management/commands/__init__.py
Normal file
0
core/management/commands/__init__.py
Normal file
11
core/management/commands/processing.py
Normal file
11
core/management/commands/processing.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import msgpack
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from core.util import logs
|
||||||
|
|
||||||
|
log = logs.get_logger("processing")
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
...
|
||||||
41
core/management/commands/scheduling.py
Normal file
41
core/management/commands/scheduling.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from core.util import logs
|
||||||
|
|
||||||
|
log = logs.get_logger("scheduling")
|
||||||
|
|
||||||
|
INTERVALS = [5, 60, 900, 1800, 3600, 14400, 86400]
|
||||||
|
|
||||||
|
|
||||||
|
async def job(interval_seconds):
|
||||||
|
"""
|
||||||
|
Run all schedules matching the given interval.
|
||||||
|
:param interval_seconds: The interval to run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
"""
|
||||||
|
Start the scheduling process.
|
||||||
|
"""
|
||||||
|
scheduler = AsyncIOScheduler()
|
||||||
|
for interval in INTERVALS:
|
||||||
|
log.debug(f"Scheduling {interval} second job")
|
||||||
|
scheduler.add_job(job, "interval", seconds=interval, args=[interval])
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
scheduler._eventloop = loop
|
||||||
|
scheduler.start()
|
||||||
|
try:
|
||||||
|
loop.run_forever()
|
||||||
|
except (KeyboardInterrupt, SystemExit):
|
||||||
|
log.info("Process terminating")
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
# Generated by Django 4.0.6 on 2022-07-10 19:54
|
# Generated by Django 4.2.6 on 2023-10-17 19:26
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
import django.contrib.auth.models
|
import django.contrib.auth.models
|
||||||
import django.contrib.auth.validators
|
import django.contrib.auth.validators
|
||||||
@@ -31,9 +33,11 @@ class Migration(migrations.Migration):
|
|||||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
('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')),
|
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||||
('stripe_id', models.CharField(blank=True, max_length=255, null=True)),
|
('stripe_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
('last_payment', models.DateTimeField(blank=True, 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)),
|
('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')),
|
('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={
|
options={
|
||||||
'verbose_name': 'user',
|
'verbose_name': 'user',
|
||||||
@@ -45,34 +49,12 @@ class Migration(migrations.Migration):
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Plan',
|
name='NotificationSettings',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('name', models.CharField(max_length=255, unique=True)),
|
('ntfy_topic', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
('description', models.CharField(blank=True, max_length=1024, null=True)),
|
('ntfy_url', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
('cost', models.IntegerField()),
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
('product_id', models.CharField(blank=True, max_length=255, null=True, unique=True)),
|
|
||||||
('image', models.CharField(blank=True, max_length=1024, null=True)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
|
||||||
name='Session',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('request', models.CharField(blank=True, max_length=255, null=True)),
|
|
||||||
('subscription_id', models.CharField(blank=True, max_length=255, null=True)),
|
|
||||||
('plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='core.plan')),
|
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='user',
|
|
||||||
name='plans',
|
|
||||||
field=models.ManyToManyField(blank=True, to='core.plan'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='user',
|
|
||||||
name='user_permissions',
|
|
||||||
field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions'),
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 4.0.6 on 2022-10-12 09:08
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('core', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='session',
|
|
||||||
name='session',
|
|
||||||
field=models.CharField(blank=True, max_length=255, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,77 +1,35 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
import stripe
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from core.lib.customers import get_or_create, update_customer_fields
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Plan(models.Model):
|
|
||||||
name = models.CharField(max_length=255, unique=True)
|
|
||||||
description = models.CharField(max_length=1024, null=True, blank=True)
|
|
||||||
cost = models.IntegerField()
|
|
||||||
product_id = models.CharField(max_length=255, unique=True, null=True, blank=True)
|
|
||||||
image = models.CharField(max_length=1024, null=True, blank=True)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.name} (£{self.cost})"
|
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
# Stripe customer ID
|
# Stripe customer ID
|
||||||
stripe_id = models.CharField(max_length=255, null=True, blank=True)
|
stripe_id = models.CharField(max_length=255, null=True, blank=True)
|
||||||
last_payment = models.DateTimeField(null=True, blank=True)
|
customer_id = models.UUIDField(default=uuid.uuid4, null=True, blank=True)
|
||||||
plans = models.ManyToManyField(Plan, blank=True)
|
billing_provider_id = models.CharField(max_length=255, null=True, blank=True)
|
||||||
email = models.EmailField(unique=True)
|
email = models.EmailField(unique=True)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self._original = self
|
self._original = self
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def get_notification_settings(self):
|
||||||
"""
|
return NotificationSettings.objects.get_or_create(user=self)[0]
|
||||||
Override the save function to create a Stripe customer.
|
|
||||||
"""
|
|
||||||
if settings.STRIPE_ENABLED:
|
|
||||||
if not self.stripe_id: # stripe ID not stored
|
|
||||||
self.stripe_id = get_or_create(
|
|
||||||
self.email, self.first_name, self.last_name
|
|
||||||
)
|
|
||||||
|
|
||||||
to_update = {}
|
|
||||||
if self.email != self._original.email:
|
|
||||||
to_update["email"] = self.email
|
|
||||||
if self.first_name != self._original.first_name:
|
|
||||||
to_update["first_name"] = self.first_name
|
|
||||||
if self.last_name != self._original.last_name:
|
|
||||||
to_update["last_name"] = self.last_name
|
|
||||||
|
|
||||||
update_customer_fields(self.stripe_id, **to_update)
|
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
|
||||||
if settings.STRIPE_ENABLED:
|
|
||||||
if self.stripe_id:
|
|
||||||
stripe.Customer.delete(self.stripe_id)
|
|
||||||
logger.info(f"Deleted Stripe customer {self.stripe_id}")
|
|
||||||
super().delete(*args, **kwargs)
|
|
||||||
|
|
||||||
def has_plan(self, plan):
|
|
||||||
plan_list = [plan.name for plan in self.plans.all()]
|
|
||||||
return plan in plan_list
|
|
||||||
|
|
||||||
|
|
||||||
class Session(models.Model):
|
class NotificationSettings(models.Model):
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||||
request = models.CharField(max_length=255, null=True, blank=True)
|
ntfy_topic = models.CharField(max_length=255, null=True, blank=True)
|
||||||
session = models.CharField(max_length=255, null=True, blank=True)
|
ntfy_url = models.CharField(max_length=255, null=True, blank=True)
|
||||||
subscription_id = models.CharField(max_length=255, null=True, blank=True)
|
|
||||||
plan = models.ForeignKey(Plan, null=True, blank=True, on_delete=models.CASCADE)
|
def __str__(self):
|
||||||
|
return f"Notification settings for {self.user}"
|
||||||
|
|
||||||
|
|
||||||
# class Perms(models.Model):
|
# class Perms(models.Model):
|
||||||
|
|||||||
4
core/static/css/bulma.min.css
vendored
4
core/static/css/bulma.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -6,16 +6,24 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>XF - {{ request.path_info }}</title>
|
<title>GIA - {{ request.path_info }}</title>
|
||||||
<link rel="shortcut icon" href="{% static 'favicon.ico' %}">
|
<link rel="shortcut icon" href="{% static 'favicon.ico' %}">
|
||||||
<link rel="manifest" href="{% static 'manifest.webmanifest' %}">
|
<link rel="manifest" href="{% static 'manifest.webmanifest' %}">
|
||||||
<link rel="stylesheet" href="{% static 'css/bulma.min.css' %}">
|
<link rel="stylesheet" href="{% static 'css/bulma.min.css' %}">
|
||||||
<link rel="stylesheet" href="{% static 'css/bulma-tooltip.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="https://site-assets.fontawesome.com/releases/v6.1.1/css/all.css">
|
||||||
|
<link rel="stylesheet" href="{% static 'css/bulma-slider.min.css' %}">
|
||||||
|
<link rel="stylesheet" href="{% static 'css/bulma-calendar.min.css' %}">
|
||||||
|
<link rel="stylesheet" href="{% static 'css/bulma-tagsinput.min.css' %}">
|
||||||
|
<link rel="stylesheet" href="{% static 'css/bulma-switch.min.css' %}">
|
||||||
<link rel="stylesheet" href="{% static 'css/gridstack.min.css' %}">
|
<link rel="stylesheet" href="{% static 'css/gridstack.min.css' %}">
|
||||||
|
<script src="{% static 'js/bulma-calendar.min.js' %}" integrity="sha384-DThNif0xGXbopX7+PE+UabkuClfI/zELNhaVqoGLutaWB76dyMw0vIQBGmUxSfVQ" crossorigin="anonymous"></script>
|
||||||
|
<script src="{% static 'js/bulma-slider.min.js' %}" integrity="sha384-wbyps8iLG8QzJE02viYc/27BtT5HSa11+b5V7QPR1/huVuA8f4LRTNGc82qAIeIZ" crossorigin="anonymous"></script>
|
||||||
<script src="{% static 'js/htmx.min.js' %}" integrity="sha384-cZuAZ+ZbwkNRnrKi05G/fjBX+azI9DNOkNYysZ0I/X5ZFgsmMiBXgDZof30F5ofc" crossorigin="anonymous"></script>
|
<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 defer src="{% static 'js/remove-me.js' %}" integrity="sha384-6fHcFNoQ8QEI3ZDgw9Z/A6Brk64gF7AnFbLgdrumo8/kBbsKQ/wo7wPegj5WkzuG" crossorigin="anonymous"></script>
|
||||||
|
<script defer src="{% static 'js/hyperscript.min.js' %}" integrity="sha384-6GYN8BDHOJkkru6zcpGOUa//1mn+5iZ/MyT6mq34WFIpuOeLF52kSi721q0SsYF9" crossorigin="anonymous"></script>
|
||||||
|
<script src="{% static 'js/bulma-tagsinput.min.js' %}"></script>
|
||||||
|
<script src="{% static 'js/jquery.min.js' %}"></script>
|
||||||
<script src="{% static 'js/gridstack-all.js' %}"></script>
|
<script src="{% static 'js/gridstack-all.js' %}"></script>
|
||||||
<script defer src="{% static 'js/magnet.min.js' %}"></script>
|
<script defer src="{% static 'js/magnet.min.js' %}"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -112,12 +120,37 @@
|
|||||||
cursor:pointer;
|
cursor:pointer;
|
||||||
background-color:rgba(221, 224, 255, 0.3) !important;
|
background-color:rgba(221, 224, 255, 0.3) !important;
|
||||||
}
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background-color: rgba(84, 84, 84, 0.9) !important;
|
||||||
|
--modal-color: rgba(81, 81, 81, 0.9) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background-color-light: rgba(210, 210, 210, 0.9) !important;
|
||||||
|
--background-color-dark: rgba(84, 84, 84, 0.9) !important;
|
||||||
|
--background-color-modal-light: rgba(250, 250, 250, 0.5) !important;
|
||||||
|
--background-color-modal-dark: rgba(210, 210, 210, 0.9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--background-color: var(--background-color-light);
|
||||||
|
--modal-color: var(--background-color-modal-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--background-color: var(--background-color-dark);
|
||||||
|
--modal-color: var(--background-color-modal-dark);
|
||||||
|
}
|
||||||
|
|
||||||
.panel, .box, .modal {
|
.panel, .box, .modal {
|
||||||
background-color:rgba(250, 250, 250, 0.5) !important;
|
/* background-color:rgba(250, 250, 250, 0.5) !important; */
|
||||||
|
background-color: var(--modal-color) !important;
|
||||||
}
|
}
|
||||||
.modal, .modal.box{
|
.modal, .modal.box{
|
||||||
background-color:rgba(210, 210, 210, 0.9) !important;
|
/* background-color:rgba(210, 210, 210, 0.9) !important; */
|
||||||
|
background-color: var(--background-color) !important;
|
||||||
}
|
}
|
||||||
.modal-background{
|
.modal-background{
|
||||||
background-color:rgba(255, 255, 255, 0.3) !important;
|
background-color:rgba(255, 255, 255, 0.3) !important;
|
||||||
@@ -201,25 +234,34 @@
|
|||||||
<a class="navbar-item" href="{% url 'home' %}">
|
<a class="navbar-item" href="{% url 'home' %}">
|
||||||
Home
|
Home
|
||||||
</a>
|
</a>
|
||||||
{% if settings.STRIPE_ENABLED %}
|
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<a class="navbar-item" href="{% url 'billing' %}">
|
|
||||||
Billing
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% if user.is_superuser %}
|
|
||||||
<div class="navbar-item has-dropdown is-hoverable">
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
<a class="navbar-link">
|
<a class="navbar-link">
|
||||||
Admin
|
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>
|
||||||
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
|
<a class="navbar-link">
|
||||||
|
Services
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="navbar-dropdown">
|
<div class="navbar-dropdown">
|
||||||
<a class="navbar-item" href="#">
|
<a class="navbar-item" href="#">
|
||||||
Admin1
|
Signal
|
||||||
</a>
|
</a>
|
||||||
<a class="navbar-item" href="#">
|
<a class="navbar-item" href="#">
|
||||||
Admin2
|
Instagram
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,7 +278,7 @@
|
|||||||
<a class="button is-info" href="{% url 'signup' %}">
|
<a class="button is-info" href="{% url 'signup' %}">
|
||||||
<strong>Sign up</strong>
|
<strong>Sign up</strong>
|
||||||
</a>
|
</a>
|
||||||
<a class="button is-light" href="{% url 'login' %}">
|
<a class="button" href="{% url 'two_factor:login' %}">
|
||||||
Log in
|
Log in
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -283,8 +325,16 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{% block content %}
|
{% block content_wrapper %}
|
||||||
|
{% block content %}
|
||||||
|
{% endblock %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
<div id="modals-here">
|
||||||
|
</div>
|
||||||
|
<div id="windows-here">
|
||||||
|
</div>
|
||||||
|
<div id="widgets-here" style="display: none;">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<article class="panel is-info">
|
|
||||||
<p class="panel-heading">
|
|
||||||
User information
|
|
||||||
</p>
|
|
||||||
<a class="panel-block is-active">
|
|
||||||
<span class="panel-icon">
|
|
||||||
<i class="fas fa-id-card" aria-hidden="true"></i>
|
|
||||||
</span>
|
|
||||||
<span class="tag is-info">{{ user.first_name }} {{ user.last_name }}</span>
|
|
||||||
</a>
|
|
||||||
<a class="panel-block">
|
|
||||||
<span class="panel-icon">
|
|
||||||
<i class="fas fa-binary" aria-hidden="true"></i>
|
|
||||||
</span>
|
|
||||||
{% for plan in user.plans.all %}
|
|
||||||
<span class="tag is-info">{{ plan.name }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</a>
|
|
||||||
<a class="panel-block">
|
|
||||||
<span class="panel-icon">
|
|
||||||
<i class="fas fa-credit-card" aria-hidden="true"></i>
|
|
||||||
</span>
|
|
||||||
<span class="tag">{{ user.last_payment }}</span>
|
|
||||||
</a>
|
|
||||||
<a class="panel-block" href="{% url 'portal' %}">
|
|
||||||
<span class="panel-icon">
|
|
||||||
<i class="fa-brands fa-stripe-s" aria-hidden="true"></i>
|
|
||||||
</span>
|
|
||||||
Subscription management
|
|
||||||
</a>
|
|
||||||
</article>
|
|
||||||
{% include "partials/product-list.html" %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
@@ -4,19 +4,6 @@
|
|||||||
{% block outer_content %}
|
{% block outer_content %}
|
||||||
|
|
||||||
<div class="grid-stack" id="grid-stack-main">
|
<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>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -34,62 +21,73 @@
|
|||||||
|
|
||||||
// a widget is ready to be loaded
|
// a widget is ready to be loaded
|
||||||
document.addEventListener('load-widget', function(event) {
|
document.addEventListener('load-widget', function(event) {
|
||||||
let container = htmx.find('#widget');
|
let containers = htmx.findAll('#widget');
|
||||||
// get the scripts, they won't be run on the new element so we need to eval them
|
for (let x = 0, len = containers.length; x < len; x++) {
|
||||||
var scripts = htmx.findAll(container, "script");
|
container = containers[x];
|
||||||
let widgetelement = container.firstElementChild.cloneNode(true);
|
// get the scripts, they won't be run on the new element so we need to eval them
|
||||||
var new_id = widgetelement.id;
|
let widgetelement = container.firstElementChild.cloneNode(true);
|
||||||
|
console.log(widgetelement);
|
||||||
|
var scripts = htmx.findAll(widgetelement, "script");
|
||||||
|
var new_id = widgetelement.id;
|
||||||
|
|
||||||
// check if there's an existing element like the one we want to swap
|
// check if there's an existing element like the one we want to swap
|
||||||
let grid_element = htmx.find('#grid-stack-main');
|
let grid_element = htmx.find('#grid-stack-main');
|
||||||
let existing_widget = htmx.find(grid_element, "#"+new_id);
|
let existing_widget = htmx.find(grid_element, "#"+new_id);
|
||||||
|
|
||||||
// get the size and position attributes
|
// get the size and position attributes
|
||||||
if (existing_widget) {
|
if (existing_widget) {
|
||||||
let attrs = existing_widget.getAttributeNames();
|
let attrs = existing_widget.getAttributeNames();
|
||||||
for (let i = 0, len = attrs.length; i < len; i++) {
|
for (let i = 0, len = attrs.length; i < len; i++) {
|
||||||
if (attrs[i].startsWith('gs-')) { // only target gridstack attributes
|
if (attrs[i].startsWith('gs-')) { // only target gridstack attributes
|
||||||
widgetelement.setAttribute(attrs[i], existing_widget.getAttribute(attrs[i]));
|
widgetelement.setAttribute(attrs[i], existing_widget.getAttribute(attrs[i]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
// clear the queue element
|
||||||
// clear the queue element
|
container.outerHTML = "";
|
||||||
container.outerHTML = "";
|
// container.firstElementChild.outerHTML = "";
|
||||||
grid.addWidget(widgetelement);
|
grid.addWidget(widgetelement);
|
||||||
|
|
||||||
// re-create the HTMX JS listeners, otherwise HTMX won't work inside the grid
|
// re-create the HTMX JS listeners, otherwise HTMX won't work inside the grid
|
||||||
htmx.process(widgetelement);
|
htmx.process(widgetelement);
|
||||||
|
|
||||||
// update the size of the widget according to its content
|
// update the size of the widget according to its content
|
||||||
var added_widget = htmx.find(grid_element, "#"+new_id);
|
var added_widget = htmx.find(grid_element, "#"+new_id);
|
||||||
var itemContent = htmx.find(added_widget, ".control");
|
var itemContent = htmx.find(added_widget, ".control");
|
||||||
var scrollheight = itemContent.scrollHeight+80;
|
var scrollheight = itemContent.scrollHeight+80;
|
||||||
var verticalmargin = 0;
|
var verticalmargin = 0;
|
||||||
var cellheight = grid.opts.cellHeight;
|
var cellheight = grid.opts.cellHeight;
|
||||||
var height = Math.ceil((scrollheight + verticalmargin) / (cellheight + verticalmargin));
|
var height = Math.ceil((scrollheight + verticalmargin) / (cellheight + verticalmargin));
|
||||||
var opts = {
|
var opts = {
|
||||||
h: height,
|
h: height,
|
||||||
}
|
}
|
||||||
grid.update(
|
grid.update(
|
||||||
added_widget,
|
added_widget,
|
||||||
opts
|
opts
|
||||||
);
|
);
|
||||||
|
|
||||||
// run the JS scripts inside the added element again
|
// run the JS scripts inside the added element again
|
||||||
for (var i = 0; i < scripts.length; i++) {
|
for (var i = 0; i < scripts.length; i++) {
|
||||||
eval(scripts[i].innerHTML);
|
eval(scripts[i].innerHTML);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// clear the containers we just added
|
||||||
|
// for (let x = 0, len = containers.length; x < len; x++) {
|
||||||
|
// container = containers[x];
|
||||||
|
// container.inner = "";
|
||||||
|
// }
|
||||||
|
grid.compact();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<div>
|
||||||
|
<!-- <div
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-get="#"
|
||||||
|
hx-target="#widgets-here"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="afterend"
|
||||||
|
style="display: none;"></div> -->
|
||||||
|
|
||||||
<div id="modals-here">
|
</div>
|
||||||
</div>
|
|
||||||
<div id="items-here">
|
|
||||||
</div>
|
|
||||||
<div id="widgets-here" style="display: none;">
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
{% extends 'wm/modal.html' %}
|
|
||||||
|
|
||||||
{% block modal_content %}
|
|
||||||
{% include 'window-content/main.html' %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
{% load static %}
|
|
||||||
|
|
||||||
{% for plan in plans %}
|
|
||||||
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
<article class="media">
|
|
||||||
<div class="media-left">
|
|
||||||
<figure class="image is-64x64">
|
|
||||||
<img src="{% static plan.image %}" alt="Image">
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
<div class="media-content">
|
|
||||||
<div class="content">
|
|
||||||
<p>
|
|
||||||
<strong>{{ plan.name }}</strong> <small>£{{ plan.cost }}</small>
|
|
||||||
{% if plan in user_plans %}
|
|
||||||
<i class="fas fa-check" aria-hidden="true"></i>
|
|
||||||
{% endif %}
|
|
||||||
<br>
|
|
||||||
{{ plan.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<nav class="level is-mobile">
|
|
||||||
<div class="level-left">
|
|
||||||
{% if plan not in user_plans %}
|
|
||||||
<a class="level-item" href="/order/{{ plan.name }}">
|
|
||||||
<span class="icon is-small has-text-success">
|
|
||||||
<i class="fas fa-plus" aria-hidden="true"></i>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if plan in user_plans %}
|
|
||||||
<a class="level-item" href="/cancel_subscription/{{ plan.name }}">
|
|
||||||
<span class="icon is-small has-text-info">
|
|
||||||
<i class="fas fa-cancel" aria-hidden="true"></i>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -7,12 +7,12 @@
|
|||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="columns is-centered">
|
<div class="columns is-centered">
|
||||||
<div class="column is-5-tablet is-4-desktop is-3-widescreen">
|
<div class="column is-5-tablet is-5-desktop is-4-widescreen">
|
||||||
<form method="POST" class="box">
|
<form method="POST" class="box">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form|crispy }}
|
{{ form|crispy }}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<button class="button is-success">
|
<button class="button">
|
||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,4 +22,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
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 %}
|
||||||
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>
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{% extends 'wm/widget.html' %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block widget_options %}
|
|
||||||
gs-w="10" gs-h="1" gs-y="10" gs-x="1"
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block heading %}
|
|
||||||
Widget
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block close_button %}
|
|
||||||
<i
|
|
||||||
class="fa-solid fa-xmark has-text-grey-light float-right"
|
|
||||||
onclick='grid.removeWidget("widget-{{ unique }}"); //grid.compact();'></i>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block panel_content %}
|
|
||||||
{% include 'window-content/main.html' %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{% extends 'wm/magnet.html' %}
|
|
||||||
|
|
||||||
{% block heading %}
|
|
||||||
Window
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block panel_content %}
|
|
||||||
{% include 'window-content/main.html' %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<magnet-block attract-distance="10" align-to="outer|center" class="floating-window">
|
|
||||||
{% extends 'wm/panel.html' %}
|
|
||||||
{% block heading %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block panel_content %}
|
|
||||||
{% endblock %}
|
|
||||||
</magnet-block>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{% load static %}
|
|
||||||
|
|
||||||
<script src="{% static 'modal.js' %}"></script>
|
|
||||||
{% block scripts %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block styles %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
<div id="modal" class="modal is-active is-clipped">
|
|
||||||
<div class="modal-background"></div>
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="box">
|
|
||||||
{% block modal_content %}
|
|
||||||
{% endblock %}
|
|
||||||
<button class="modal-close is-large" aria-label="close"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
|
|
||||||
<nav class="panel">
|
|
||||||
<p class="panel-heading" style="padding: .2em; line-height: .5em;">
|
|
||||||
<i class="fa-solid fa-arrows-up-down-left-right has-text-grey-light"></i>
|
|
||||||
{% block close_button %}
|
|
||||||
<i
|
|
||||||
class="fa-solid fa-xmark has-text-grey-light float-right"
|
|
||||||
data-script="on click remove the closest <nav/>"></i>
|
|
||||||
{% endblock %}
|
|
||||||
{% block heading %}
|
|
||||||
{% endblock %}
|
|
||||||
</p>
|
|
||||||
<article class="panel-block is-active">
|
|
||||||
<div class="control">
|
|
||||||
{% block panel_content %}
|
|
||||||
{% endblock %}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</nav>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<div id="widget">
|
|
||||||
<div id="widget-{{ unique }}" class="grid-stack-item" {% block widget_options %}{% endblock %}>
|
|
||||||
<div class="grid-stack-item-content">
|
|
||||||
|
|
||||||
<nav class="panel">
|
|
||||||
<p class="panel-heading" style="padding: .2em; line-height: .5em;">
|
|
||||||
<i class="fa-solid fa-arrows-up-down-left-right has-text-grey-light"></i>
|
|
||||||
{% block close_button %}
|
|
||||||
<i
|
|
||||||
class="fa-solid fa-xmark has-text-grey-light float-right"
|
|
||||||
onclick='grid.removeWidget("widget-{{ unique }}");'></i>
|
|
||||||
{% endblock %}
|
|
||||||
<i
|
|
||||||
class="fa-solid fa-arrows-minimize has-text-grey-light float-right"
|
|
||||||
onclick='grid.compact();'></i>
|
|
||||||
{% block heading %}
|
|
||||||
{% endblock %}
|
|
||||||
</p>
|
|
||||||
<article class="panel-block is-active">
|
|
||||||
<div class="control">
|
|
||||||
{% block panel_content %}
|
|
||||||
{% endblock %}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
{% block custom_script %}
|
|
||||||
{% endblock %}
|
|
||||||
var widget_event = new Event('load-widget');
|
|
||||||
document.dispatchEvent(widget_event);
|
|
||||||
</script>
|
|
||||||
{% block custom_end %}
|
|
||||||
{% endblock %}
|
|
||||||
0
core/tests/__init__.py
Normal file
0
core/tests/__init__.py
Normal file
@@ -43,7 +43,6 @@ class ColoredFormatter(logging.Formatter):
|
|||||||
|
|
||||||
|
|
||||||
def get_logger(name):
|
def get_logger(name):
|
||||||
|
|
||||||
# Define the logging format
|
# Define the logging format
|
||||||
FORMAT = "%(asctime)s %(levelname)18s $BOLD%(name)13s$RESET - %(message)s"
|
FORMAT = "%(asctime)s %(levelname)18s $BOLD%(name)13s$RESET - %(message)s"
|
||||||
COLOR_FORMAT = formatter_message(FORMAT, True)
|
COLOR_FORMAT = formatter_message(FORMAT, True)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
import stripe
|
# import stripe
|
||||||
from asgiref.sync import sync_to_async
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
@@ -11,8 +10,7 @@ from django.views import View
|
|||||||
from django.views.generic.edit import CreateView
|
from django.views.generic.edit import CreateView
|
||||||
|
|
||||||
from core.forms import NewUserForm
|
from core.forms import NewUserForm
|
||||||
from core.lib.products import assemble_plan_map
|
from core.lib.notify import raw_sendmsg
|
||||||
from core.models import Plan, Session
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -22,82 +20,26 @@ logger = logging.getLogger(__name__)
|
|||||||
class Home(View):
|
class Home(View):
|
||||||
template_name = "index.html"
|
template_name = "index.html"
|
||||||
|
|
||||||
async def get(self, request):
|
def get(self, request):
|
||||||
return render(request, self.template_name)
|
return render(request, self.template_name)
|
||||||
|
|
||||||
|
|
||||||
class Billing(LoginRequiredMixin, View):
|
|
||||||
template_name = "billing.html"
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
if not settings.STRIPE_ENABLED:
|
|
||||||
return redirect(reverse("home"))
|
|
||||||
plans = await sync_to_async(list)(Plan.objects.all())
|
|
||||||
user_plans = await sync_to_async(list)(request.user.plans.all())
|
|
||||||
context = {"plans": plans, "user_plans": user_plans}
|
|
||||||
return render(request, self.template_name, context)
|
|
||||||
|
|
||||||
|
|
||||||
class Order(LoginRequiredMixin, View):
|
|
||||||
async def get(self, request, plan_name):
|
|
||||||
if not settings.STRIPE_ENABLED:
|
|
||||||
return redirect(reverse("home"))
|
|
||||||
plan = Plan.objects.get(name=plan_name)
|
|
||||||
try:
|
|
||||||
cast = {
|
|
||||||
"payment_method_types": settings.ALLOWED_PAYMENT_METHODS,
|
|
||||||
"mode": "subscription",
|
|
||||||
"customer": request.user.stripe_id,
|
|
||||||
"line_items": await assemble_plan_map(
|
|
||||||
product_id_filter=plan.product_id
|
|
||||||
),
|
|
||||||
"success_url": request.build_absolute_uri(reverse("success")),
|
|
||||||
"cancel_url": request.build_absolute_uri(reverse("cancel")),
|
|
||||||
}
|
|
||||||
if request.user.is_superuser:
|
|
||||||
cast["discounts"] = [{"coupon": settings.STRIPE_ADMIN_COUPON}]
|
|
||||||
session = stripe.checkout.Session.create(**cast)
|
|
||||||
await Session.objects.acreate(user=request.user, session=session.id)
|
|
||||||
return redirect(session.url)
|
|
||||||
# return JsonResponse({'id': session.id})
|
|
||||||
except Exception as e:
|
|
||||||
# Raise a server error
|
|
||||||
return JsonResponse({"error": str(e)}, status=500)
|
|
||||||
|
|
||||||
|
|
||||||
class Cancel(LoginRequiredMixin, View):
|
|
||||||
async def get(self, request, plan_name):
|
|
||||||
if not settings.STRIPE_ENABLED:
|
|
||||||
return redirect(reverse("home"))
|
|
||||||
plan = Plan.objects.get(name=plan_name)
|
|
||||||
try:
|
|
||||||
subscriptions = stripe.Subscription.list(
|
|
||||||
customer=request.user.stripe_id, price=plan.product_id
|
|
||||||
)
|
|
||||||
for subscription in subscriptions["data"]:
|
|
||||||
items = subscription["items"]["data"]
|
|
||||||
for item in items:
|
|
||||||
stripe.Subscription.delete(item["subscription"])
|
|
||||||
return render(request, "subscriptioncancel.html", {"plan": plan})
|
|
||||||
# return JsonResponse({'id': session.id})
|
|
||||||
except Exception as e:
|
|
||||||
# Raise a server error
|
|
||||||
logging.error(f"Error cancelling subscription for user: {e}")
|
|
||||||
return JsonResponse({"error": "True"}, status=500)
|
|
||||||
|
|
||||||
|
|
||||||
class Signup(CreateView):
|
class Signup(CreateView):
|
||||||
form_class = NewUserForm
|
form_class = NewUserForm
|
||||||
success_url = reverse_lazy("login")
|
success_url = reverse_lazy("two_factor:login")
|
||||||
template_name = "registration/signup.html"
|
template_name = "registration/signup.html"
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
class Portal(LoginRequiredMixin, View):
|
"""If the form is valid, save the associated model."""
|
||||||
async def get(self, request):
|
self.object = form.save()
|
||||||
if not settings.STRIPE_ENABLED:
|
raw_sendmsg(
|
||||||
return redirect(reverse("home"))
|
f"New user signup: {self.object.username} - {self.object.email}",
|
||||||
session = stripe.billing_portal.Session.create(
|
title="New user",
|
||||||
customer=request.user.stripe_id,
|
topic=settings.NOTIFY_TOPIC,
|
||||||
return_url=request.build_absolute_uri(reverse("billing")),
|
|
||||||
)
|
)
|
||||||
return redirect(session.url)
|
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)
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import stripe
|
|
||||||
from django.conf import settings
|
|
||||||
from django.http import HttpResponse, JsonResponse
|
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
|
||||||
from rest_framework.parsers import JSONParser
|
|
||||||
from rest_framework.views import APIView
|
|
||||||
|
|
||||||
from core.models import Plan, Session, User
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Callback(APIView):
|
|
||||||
parser_classes = [JSONParser]
|
|
||||||
|
|
||||||
# TODO: make async
|
|
||||||
@csrf_exempt
|
|
||||||
def post(self, request):
|
|
||||||
payload = request.body
|
|
||||||
sig_header = request.META["HTTP_STRIPE_SIGNATURE"]
|
|
||||||
try:
|
|
||||||
stripe.Webhook.construct_event(
|
|
||||||
payload, sig_header, settings.STRIPE_ENDPOINT_SECRET
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
# Invalid payload
|
|
||||||
logger.error("Invalid payload")
|
|
||||||
return HttpResponse(status=400)
|
|
||||||
except stripe.error.SignatureVerificationError:
|
|
||||||
# Invalid signature
|
|
||||||
logger.error("Invalid signature")
|
|
||||||
return HttpResponse(status=400)
|
|
||||||
|
|
||||||
if request.data is None:
|
|
||||||
return JsonResponse({"success": False}, status=500)
|
|
||||||
if "type" in request.data.keys():
|
|
||||||
rtype = request.data["type"]
|
|
||||||
if rtype == "checkout.session.completed":
|
|
||||||
session = request.data["data"]["object"]["id"]
|
|
||||||
subscription_id = request.data["data"]["object"]["subscription"]
|
|
||||||
session_map = Session.objects.get(session=session)
|
|
||||||
if not session_map:
|
|
||||||
return JsonResponse({"success": False}, status=500)
|
|
||||||
user = session_map.user
|
|
||||||
session_map.subscription_id = subscription_id
|
|
||||||
session_map.save()
|
|
||||||
|
|
||||||
if rtype == "customer.subscription.updated":
|
|
||||||
stripe_id = request.data["data"]["object"]["customer"]
|
|
||||||
if not stripe_id:
|
|
||||||
logging.error("No stripe id")
|
|
||||||
return JsonResponse({"success": False}, status=500)
|
|
||||||
user = User.objects.get(stripe_id=stripe_id)
|
|
||||||
# ssubscription_active
|
|
||||||
subscription_id = request.data["data"]["object"]["id"]
|
|
||||||
sessions = Session.objects.filter(user=user)
|
|
||||||
session = None
|
|
||||||
for session_iter in sessions:
|
|
||||||
if session_iter.subscription_id == subscription_id:
|
|
||||||
session = session_iter
|
|
||||||
if not session:
|
|
||||||
logging.error(
|
|
||||||
f"No session found for subscription id {subscription_id}"
|
|
||||||
)
|
|
||||||
return JsonResponse({"success": False}, status=500)
|
|
||||||
# query Session objects
|
|
||||||
# iterate and check against product_id
|
|
||||||
session.request = request.data["request"]["id"]
|
|
||||||
product_id = request.data["data"]["object"]["plan"]["id"]
|
|
||||||
plan = Plan.objects.get(product_id=product_id)
|
|
||||||
if not plan:
|
|
||||||
logging.error(f"Plan not found: {product_id}")
|
|
||||||
return JsonResponse({"success": False}, status=500)
|
|
||||||
session.plan = plan
|
|
||||||
session.save()
|
|
||||||
|
|
||||||
elif rtype == "payment_intent.succeeded":
|
|
||||||
customer = request.data["data"]["object"]["customer"]
|
|
||||||
user = User.objects.get(stripe_id=customer)
|
|
||||||
if not user:
|
|
||||||
logging.error(f"No user found for customer: {customer}")
|
|
||||||
return JsonResponse({"success": False}, status=500)
|
|
||||||
session = Session.objects.get(request=request.data["request"]["id"])
|
|
||||||
|
|
||||||
user.plans.add(session.plan)
|
|
||||||
user.last_payment = datetime.utcnow()
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
elif rtype == "customer.subscription.deleted":
|
|
||||||
customer = request.data["data"]["object"]["customer"]
|
|
||||||
user = User.objects.get(stripe_id=customer)
|
|
||||||
if not user:
|
|
||||||
logging.error(f"No user found for customer {customer}")
|
|
||||||
return JsonResponse({"success": False}, status=500)
|
|
||||||
product_id = request.data["data"]["object"]["plan"]["id"]
|
|
||||||
plan = Plan.objects.get(product_id=product_id)
|
|
||||||
user.plans.remove(plan)
|
|
||||||
user.save()
|
|
||||||
else:
|
|
||||||
return JsonResponse({"success": False}, status=500)
|
|
||||||
return JsonResponse({"success": True})
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import uuid
|
|
||||||
|
|
||||||
from django.shortcuts import render
|
|
||||||
from django.views import View
|
|
||||||
|
|
||||||
|
|
||||||
class DemoModal(View):
|
|
||||||
template_name = "modals/modal.html"
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
return render(request, self.template_name)
|
|
||||||
|
|
||||||
|
|
||||||
class DemoWidget(View):
|
|
||||||
template_name = "widgets/widget.html"
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
unique = str(uuid.uuid4())[:8]
|
|
||||||
return render(request, self.template_name, {"unique": unique})
|
|
||||||
|
|
||||||
|
|
||||||
class DemoWindow(View):
|
|
||||||
template_name = "windows/window.html"
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
return render(request, self.template_name)
|
|
||||||
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
|
||||||
0
core/views/signal.py
Normal file
0
core/views/signal.py
Normal file
242
docker-compose.yml
Normal file
242
docker-compose.yml
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
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
|
||||||
|
- type: bind
|
||||||
|
source: /code/vrun
|
||||||
|
target: /var/run
|
||||||
|
environment:
|
||||||
|
APP_PORT: "${APP_PORT}"
|
||||||
|
REPO_DIR: "${REPO_DIR}"
|
||||||
|
APP_LOCAL_SETTINGS: "${APP_LOCAL_SETTINGS}"
|
||||||
|
APP_DATABASE_FILE: "${APP_DATABASE_FILE}"
|
||||||
|
DOMAIN: "${DOMAIN}"
|
||||||
|
URL: "${URL}"
|
||||||
|
ALLOWED_HOSTS: "${ALLOWED_HOSTS}"
|
||||||
|
NOTIFY_TOPIC: "${NOTIFY_TOPIC}"
|
||||||
|
CSRF_TRUSTED_ORIGINS: "${CSRF_TRUSTED_ORIGINS}"
|
||||||
|
DEBUG: "${DEBUG}"
|
||||||
|
SECRET_KEY: "${SECRET_KEY}"
|
||||||
|
STATIC_ROOT: "${STATIC_ROOT}"
|
||||||
|
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
|
||||||
|
OPERATION: "${OPERATION}"
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
migration:
|
||||||
|
condition: service_started
|
||||||
|
collectstatic:
|
||||||
|
condition: service_started
|
||||||
|
# deploy:
|
||||||
|
# resources:
|
||||||
|
# limits:
|
||||||
|
# cpus: '0.1'
|
||||||
|
# memory: 0.25G
|
||||||
|
#network_mode: host
|
||||||
|
|
||||||
|
|
||||||
|
signal-cli-rest-api:
|
||||||
|
image: bbernhard/signal-cli-rest-api:latest
|
||||||
|
container_name: signal
|
||||||
|
environment:
|
||||||
|
- MODE=normal #supported modes: json-rpc, native, normal
|
||||||
|
- AUTO_RECEIVE_SCHEDULE=0 22 * * *
|
||||||
|
# ports:
|
||||||
|
# - "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- "./signal-cli-config:/home/.local/share/signal-cli"
|
||||||
|
|
||||||
|
processing:
|
||||||
|
image: xf/envelope:prod
|
||||||
|
container_name: processing_envelope
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
OPERATION: ${OPERATION}
|
||||||
|
command: sh -c '. /venv/bin/activate && python manage.py processing'
|
||||||
|
volumes:
|
||||||
|
- ${REPO_DIR}:/code
|
||||||
|
- ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini
|
||||||
|
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
|
||||||
|
- type: bind
|
||||||
|
source: /code/vrun
|
||||||
|
target: /var/run
|
||||||
|
environment:
|
||||||
|
APP_PORT: "${APP_PORT}"
|
||||||
|
REPO_DIR: "${REPO_DIR}"
|
||||||
|
APP_LOCAL_SETTINGS: "${APP_LOCAL_SETTINGS}"
|
||||||
|
APP_DATABASE_FILE: "${APP_DATABASE_FILE}"
|
||||||
|
DOMAIN: "${DOMAIN}"
|
||||||
|
URL: "${URL}"
|
||||||
|
ALLOWED_HOSTS: "${ALLOWED_HOSTS}"
|
||||||
|
NOTIFY_TOPIC: "${NOTIFY_TOPIC}"
|
||||||
|
CSRF_TRUSTED_ORIGINS: "${CSRF_TRUSTED_ORIGINS}"
|
||||||
|
DEBUG: "${DEBUG}"
|
||||||
|
SECRET_KEY: "${SECRET_KEY}"
|
||||||
|
STATIC_ROOT: "${STATIC_ROOT}"
|
||||||
|
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
|
||||||
|
OPERATION: "${OPERATION}"
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
migration:
|
||||||
|
condition: service_started
|
||||||
|
collectstatic:
|
||||||
|
condition: service_started
|
||||||
|
# deploy:
|
||||||
|
# resources:
|
||||||
|
# limits:
|
||||||
|
# cpus: '0.25'
|
||||||
|
# memory: 0.25G
|
||||||
|
#network_mode: host
|
||||||
|
|
||||||
|
scheduling:
|
||||||
|
image: xf/envelope:prod
|
||||||
|
container_name: scheduling_envelope
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
OPERATION: ${OPERATION}
|
||||||
|
command: sh -c '. /venv/bin/activate && python manage.py scheduling'
|
||||||
|
volumes:
|
||||||
|
- ${REPO_DIR}:/code
|
||||||
|
- ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini
|
||||||
|
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
|
||||||
|
- type: bind
|
||||||
|
source: /code/vrun
|
||||||
|
target: /var/run
|
||||||
|
environment:
|
||||||
|
APP_PORT: "${APP_PORT}"
|
||||||
|
REPO_DIR: "${REPO_DIR}"
|
||||||
|
APP_LOCAL_SETTINGS: "${APP_LOCAL_SETTINGS}"
|
||||||
|
APP_DATABASE_FILE: "${APP_DATABASE_FILE}"
|
||||||
|
DOMAIN: "${DOMAIN}"
|
||||||
|
URL: "${URL}"
|
||||||
|
ALLOWED_HOSTS: "${ALLOWED_HOSTS}"
|
||||||
|
NOTIFY_TOPIC: "${NOTIFY_TOPIC}"
|
||||||
|
CSRF_TRUSTED_ORIGINS: "${CSRF_TRUSTED_ORIGINS}"
|
||||||
|
DEBUG: "${DEBUG}"
|
||||||
|
SECRET_KEY: "${SECRET_KEY}"
|
||||||
|
STATIC_ROOT: "${STATIC_ROOT}"
|
||||||
|
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
|
||||||
|
OPERATION: "${OPERATION}"
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
migration:
|
||||||
|
condition: service_started
|
||||||
|
collectstatic:
|
||||||
|
condition: service_started
|
||||||
|
# deploy:
|
||||||
|
# resources:
|
||||||
|
# limits:
|
||||||
|
# cpus: '0.25'
|
||||||
|
# memory: 0.25G
|
||||||
|
#network_mode: host
|
||||||
|
|
||||||
|
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
|
||||||
|
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
|
||||||
|
- type: bind
|
||||||
|
source: /code/vrun
|
||||||
|
target: /var/run
|
||||||
|
environment:
|
||||||
|
APP_PORT: "${APP_PORT}"
|
||||||
|
REPO_DIR: "${REPO_DIR}"
|
||||||
|
APP_LOCAL_SETTINGS: "${APP_LOCAL_SETTINGS}"
|
||||||
|
APP_DATABASE_FILE: "${APP_DATABASE_FILE}"
|
||||||
|
DOMAIN: "${DOMAIN}"
|
||||||
|
URL: "${URL}"
|
||||||
|
ALLOWED_HOSTS: "${ALLOWED_HOSTS}"
|
||||||
|
NOTIFY_TOPIC: "${NOTIFY_TOPIC}"
|
||||||
|
CSRF_TRUSTED_ORIGINS: "${CSRF_TRUSTED_ORIGINS}"
|
||||||
|
DEBUG: "${DEBUG}"
|
||||||
|
SECRET_KEY: "${SECRET_KEY}"
|
||||||
|
STATIC_ROOT: "${STATIC_ROOT}"
|
||||||
|
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
|
||||||
|
OPERATION: "${OPERATION}"
|
||||||
|
# deploy:
|
||||||
|
# resources:
|
||||||
|
# limits:
|
||||||
|
# cpus: '0.25'
|
||||||
|
# memory: 0.25G
|
||||||
|
#network_mode: host
|
||||||
|
|
||||||
|
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
|
||||||
|
- type: bind
|
||||||
|
source: /code/vrun
|
||||||
|
target: /var/run
|
||||||
|
environment:
|
||||||
|
APP_PORT: "${APP_PORT}"
|
||||||
|
REPO_DIR: "${REPO_DIR}"
|
||||||
|
APP_LOCAL_SETTINGS: "${APP_LOCAL_SETTINGS}"
|
||||||
|
APP_DATABASE_FILE: "${APP_DATABASE_FILE}"
|
||||||
|
DOMAIN: "${DOMAIN}"
|
||||||
|
URL: "${URL}"
|
||||||
|
ALLOWED_HOSTS: "${ALLOWED_HOSTS}"
|
||||||
|
NOTIFY_TOPIC: "${NOTIFY_TOPIC}"
|
||||||
|
CSRF_TRUSTED_ORIGINS: "${CSRF_TRUSTED_ORIGINS}"
|
||||||
|
DEBUG: "${DEBUG}"
|
||||||
|
SECRET_KEY: "${SECRET_KEY}"
|
||||||
|
STATIC_ROOT: "${STATIC_ROOT}"
|
||||||
|
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
|
||||||
|
OPERATION: "${OPERATION}"
|
||||||
|
# deploy:
|
||||||
|
# resources:
|
||||||
|
# limits:
|
||||||
|
# cpus: '0.25'
|
||||||
|
# memory: 0.25G
|
||||||
|
#network_mode: host
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis
|
||||||
|
container_name: redis_envelope
|
||||||
|
command: redis-server /etc/redis.conf
|
||||||
|
volumes:
|
||||||
|
- ${REPO_DIR}/docker/redis.conf:/etc/redis.conf
|
||||||
|
- envelope_redis_data:/data
|
||||||
|
- type: bind
|
||||||
|
source: /code/vrun
|
||||||
|
target: /var/run
|
||||||
|
healthcheck:
|
||||||
|
test: "redis-cli ping"
|
||||||
|
interval: 2s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 15
|
||||||
|
# deploy:
|
||||||
|
# resources:
|
||||||
|
# limits:
|
||||||
|
# cpus: '0.25'
|
||||||
|
# memory: 0.25G
|
||||||
|
#network_mode: host
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
envelope_redis_data: {}
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
version: "2.2"
|
|
||||||
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
image: xf/envelope:prod
|
|
||||||
build: ${PORTAINER_GIT_DIR}/docker/prod
|
|
||||||
volumes:
|
|
||||||
- ${PORTAINER_GIT_DIR}:/code
|
|
||||||
# - ${PORTAINER_GIT_DIR}/docker/prod/uwsgi.ini:/conf/uwsgi.ini
|
|
||||||
- ${APP_LOCAL_SETTINGS}:/code/app/local_settings.py
|
|
||||||
- ${APP_DATABASE_FILE}:/code/db.sqlite3
|
|
||||||
#ports:
|
|
||||||
# - "8000:8000" # uwsgi socket
|
|
||||||
env_file:
|
|
||||||
- ../stack.env
|
|
||||||
volumes_from:
|
|
||||||
- tmp
|
|
||||||
depends_on:
|
|
||||||
# redis:
|
|
||||||
# condition: service_healthy
|
|
||||||
migration:
|
|
||||||
condition: service_started
|
|
||||||
collectstatic:
|
|
||||||
condition: service_started
|
|
||||||
|
|
||||||
migration:
|
|
||||||
image: xf/envelope:prod
|
|
||||||
build: ./docker/prod
|
|
||||||
command: sh -c '. /venv/bin/activate && python manage.py migrate --noinput'
|
|
||||||
volumes:
|
|
||||||
- ${PORTAINER_GIT_DIR}:/code
|
|
||||||
- ${APP_LOCAL_SETTINGS}:/code/app/local_settings.py
|
|
||||||
- ${APP_DATABASE_FILE}:/code/db.sqlite3
|
|
||||||
env_file:
|
|
||||||
- ../stack.env
|
|
||||||
|
|
||||||
collectstatic:
|
|
||||||
image: xf/envelope:prod
|
|
||||||
build: ./docker/prod
|
|
||||||
command: sh -c '. /venv/bin/activate && python manage.py collectstatic --noinput'
|
|
||||||
volumes:
|
|
||||||
- ${PORTAINER_GIT_DIR}:/code
|
|
||||||
- ${APP_LOCAL_SETTINGS}:/code/app/local_settings.py
|
|
||||||
- ${APP_DATABASE_FILE}:/code/db.sqlite3
|
|
||||||
env_file:
|
|
||||||
- ../stack.env
|
|
||||||
|
|
||||||
nginx:
|
|
||||||
image: nginx:latest
|
|
||||||
ports:
|
|
||||||
- ${APP_PORT}:9999
|
|
||||||
ulimits:
|
|
||||||
nproc: 65535
|
|
||||||
nofile:
|
|
||||||
soft: 65535
|
|
||||||
hard: 65535
|
|
||||||
volumes:
|
|
||||||
- ${PORTAINER_GIT_DIR}:/code
|
|
||||||
- ${PORTAINER_GIT_DIR}/docker/nginx/conf.d:/etc/nginx/conf.d
|
|
||||||
volumes_from:
|
|
||||||
- tmp
|
|
||||||
depends_on:
|
|
||||||
app:
|
|
||||||
condition: service_started
|
|
||||||
|
|
||||||
|
|
||||||
# volumes_from:
|
|
||||||
# - tmp
|
|
||||||
# depends_on:
|
|
||||||
# redis:
|
|
||||||
# condition: service_healthy
|
|
||||||
|
|
||||||
tmp:
|
|
||||||
image: busybox
|
|
||||||
command: chmod -R 777 /var/run/socks
|
|
||||||
volumes:
|
|
||||||
- /var/run/socks
|
|
||||||
|
|
||||||
# redis:
|
|
||||||
# image: redis
|
|
||||||
# command: redis-server /etc/redis.conf
|
|
||||||
# ulimits:
|
|
||||||
# nproc: 65535
|
|
||||||
# nofile:
|
|
||||||
# soft: 65535
|
|
||||||
# hard: 65535
|
|
||||||
# volumes:
|
|
||||||
# - ${PORTAINER_GIT_DIR}/docker/redis.conf:/etc/redis.conf
|
|
||||||
# - redis_data:/data
|
|
||||||
# volumes_from:
|
|
||||||
# - tmp
|
|
||||||
# healthcheck:
|
|
||||||
# test: "redis-cli -s /var/run/redis/redis.sock ping"
|
|
||||||
# interval: 2s
|
|
||||||
# timeout: 2s
|
|
||||||
# retries: 15
|
|
||||||
|
|
||||||
networks:
|
|
||||||
default:
|
|
||||||
external:
|
|
||||||
name: xf
|
|
||||||
|
|
||||||
# volumes:
|
|
||||||
# redis_data: {}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
upstream django {
|
|
||||||
#server app:8000;
|
|
||||||
server unix:///var/run/socks/app.sock;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 9999;
|
|
||||||
|
|
||||||
location = /favicon.ico { access_log off; log_not_found off; }
|
|
||||||
|
|
||||||
location /static/ {
|
|
||||||
root /code/core/;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
include /etc/nginx/uwsgi_params; # the uwsgi_params file you installed
|
|
||||||
proxy_pass http://django;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
24
docker/nginx/conf.d/dev.conf
Normal file
24
docker/nginx/conf.d/dev.conf
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
upstream django {
|
||||||
|
#server app:8000;
|
||||||
|
server unix:///var/run/uwsgi-envelope.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
FROM python:3
|
|
||||||
|
|
||||||
RUN useradd -d /code xf
|
|
||||||
RUN mkdir /code
|
|
||||||
RUN chown xf:xf /code
|
|
||||||
|
|
||||||
RUN mkdir /conf
|
|
||||||
RUN chown xf:xf /conf
|
|
||||||
|
|
||||||
RUN mkdir /venv
|
|
||||||
RUN chown xf:xf /venv
|
|
||||||
|
|
||||||
USER xf
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
WORKDIR /code
|
|
||||||
COPY requirements.prod.txt /code/
|
|
||||||
RUN python -m venv /venv
|
|
||||||
RUN . /venv/bin/activate && pip install -r requirements.prod.txt
|
|
||||||
# CMD . /venv/bin/activate && uwsgi --ini /conf/uwsgi.ini
|
|
||||||
CMD . /venv/bin/activate && uvicorn --reload --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
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
wheel
|
|
||||||
django
|
|
||||||
pre-commit
|
|
||||||
django-crispy-forms
|
|
||||||
crispy-bulma
|
|
||||||
stripe
|
|
||||||
django-rest-framework
|
|
||||||
uvloop
|
|
||||||
uvicorn[standard]
|
|
||||||
gunicorn
|
|
||||||
django-htmx
|
|
||||||
cryptography
|
|
||||||
django-debug-toolbar
|
|
||||||
django-debug-toolbar-template-profiler
|
|
||||||
orjson
|
|
||||||
watchfiles
|
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
unixsocket /var/run/redis/redis.sock
|
unixsocket /var/run/envelope-redis.sock
|
||||||
unixsocketperm 777
|
unixsocketperm 777
|
||||||
|
port 0
|
||||||
22
docker/uwsgi.ini
Normal file
22
docker/uwsgi.ini
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[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
|
||||||
|
socket=/var/run/uwsgi-envelope.sock
|
||||||
|
# socket 777
|
||||||
|
chmod-socket=777
|
||||||
|
harakiri=20
|
||||||
|
#max-requests=100000
|
||||||
|
# Set a lower value for max-requests to prevent memory leaks from building up over time
|
||||||
|
max-requests=1000
|
||||||
|
# Ensure old worker processes are cleaned up properly
|
||||||
|
reload-on-as=512
|
||||||
|
reload-on-rss=256
|
||||||
|
vacuum=1
|
||||||
|
home=/venv
|
||||||
|
processes=4
|
||||||
|
threads=2
|
||||||
|
log-level=debug
|
||||||
32
requirements.txt
Normal file
32
requirements.txt
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
wheel
|
||||||
|
uwsgi
|
||||||
|
django
|
||||||
|
pre-commit
|
||||||
|
django-crispy-forms==1.14.0
|
||||||
|
crispy-bulma
|
||||||
|
# stripe
|
||||||
|
django-rest-framework
|
||||||
|
uvloop
|
||||||
|
django-htmx
|
||||||
|
cryptography
|
||||||
|
django-debug-toolbar
|
||||||
|
django-debug-toolbar-template-profiler
|
||||||
|
orjson
|
||||||
|
msgpack
|
||||||
|
apscheduler
|
||||||
|
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
|
||||||
|
requests
|
||||||
15
stack.env.example
Normal file
15
stack.env.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
APP_PORT=5006
|
||||||
|
REPO_DIR=.
|
||||||
|
APP_LOCAL_SETTINGS=./app/local_settings.py
|
||||||
|
APP_DATABASE_FILE=./db.sqlite3
|
||||||
|
DOMAIN=example.com
|
||||||
|
URL=https://example.com
|
||||||
|
ALLOWED_HOSTS=example.com
|
||||||
|
NOTIFY_TOPIC=example-topic
|
||||||
|
CSRF_TRUSTED_ORIGINS=https://example.com
|
||||||
|
DEBUG=y
|
||||||
|
SECRET_KEY=
|
||||||
|
STATIC_ROOT=/code/static
|
||||||
|
REGISTRATION_OPEN=0
|
||||||
|
OPERATION=uwsgi
|
||||||
|
BILLING_ENABLED=0
|
||||||
Reference in New Issue
Block a user