Initial commit: SmoothSchedule multi-tenant scheduling platform
This commit includes: - Django backend with multi-tenancy (django-tenants) - React + TypeScript frontend with Vite - Platform administration API with role-based access control - Authentication system with token-based auth - Quick login dev tools for testing different user roles - CORS and CSRF configuration for local development - Docker development environment setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
20
smoothschedule/.devcontainer/bashrc.override.sh
Normal file
20
smoothschedule/.devcontainer/bashrc.override.sh
Normal file
@@ -0,0 +1,20 @@
|
||||
|
||||
#
|
||||
# .bashrc.override.sh
|
||||
#
|
||||
|
||||
# persistent bash history
|
||||
HISTFILE=~/.bash_history
|
||||
PROMPT_COMMAND="history -a; $PROMPT_COMMAND"
|
||||
|
||||
# set some django env vars
|
||||
source /entrypoint
|
||||
|
||||
# restore default shell options
|
||||
set +o errexit
|
||||
set +o pipefail
|
||||
set +o nounset
|
||||
|
||||
# start ssh-agent
|
||||
# https://code.visualstudio.com/docs/remote/troubleshooting
|
||||
eval "$(ssh-agent -s)"
|
||||
36
smoothschedule/.devcontainer/devcontainer.json
Normal file
36
smoothschedule/.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,36 @@
|
||||
// For format details, see https://containers.dev/implementors/json_reference/
|
||||
{
|
||||
"name": "smoothschedule_dev",
|
||||
"dockerComposeFile": [
|
||||
"../docker-compose.local.yml"
|
||||
],
|
||||
"init": true,
|
||||
"mounts": [
|
||||
{
|
||||
"source": "./.devcontainer/bash_history",
|
||||
"target": "/home/dev-user/.bash_history",
|
||||
"type": "bind"
|
||||
},
|
||||
{
|
||||
"source": "~/.ssh",
|
||||
"target": "/home/dev-user/.ssh",
|
||||
"type": "bind"
|
||||
}
|
||||
],
|
||||
// Tells devcontainer.json supporting services / tools whether they should run
|
||||
// /bin/sh -c "while sleep 1000; do :; done" when starting the container instead of the container’s default command
|
||||
"overrideCommand": false,
|
||||
"service": "django",
|
||||
// "remoteEnv": {"PATH": "/home/dev-user/.local/bin:${containerEnv:PATH}"},
|
||||
"remoteUser": "dev-user",
|
||||
"workspaceFolder": "/app",
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"customizations": {
|
||||
},
|
||||
// Uncomment the next line if you want start specific services in your Docker Compose config.
|
||||
// "runServices": [],
|
||||
// Uncomment the next line if you want to keep your containers running after VS Code shuts down.
|
||||
// "shutdownAction": "none",
|
||||
// Uncomment the next line to run commands after the container is created.
|
||||
"postCreateCommand": "cat .devcontainer/bashrc.override.sh >> ~/.bashrc"
|
||||
}
|
||||
12
smoothschedule/.dockerignore
Normal file
12
smoothschedule/.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
.editorconfig
|
||||
.gitattributes
|
||||
.github
|
||||
.gitignore
|
||||
.gitlab-ci.yml
|
||||
.idea
|
||||
.pre-commit-config.yaml
|
||||
.readthedocs.yml
|
||||
.travis.yml
|
||||
venv
|
||||
.git
|
||||
.envs/
|
||||
27
smoothschedule/.editorconfig
Normal file
27
smoothschedule/.editorconfig
Normal file
@@ -0,0 +1,27 @@
|
||||
# http://editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{py,rst,ini}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{html,css,scss,json,yml,xml,toml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
[default.conf]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
14
smoothschedule/.envs/.local/.django
Normal file
14
smoothschedule/.envs/.local/.django
Normal file
@@ -0,0 +1,14 @@
|
||||
# General
|
||||
# ------------------------------------------------------------------------------
|
||||
USE_DOCKER=yes
|
||||
IPYTHONDIR=/app/.ipython
|
||||
# Redis
|
||||
# ------------------------------------------------------------------------------
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# Celery
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# Flower
|
||||
CELERY_FLOWER_USER=aHPdcOatgRsYSHJThjUFyLTrzRXkiVsp
|
||||
CELERY_FLOWER_PASSWORD=mH26NSH3PjskvgwrXplFvX1zFyIjl7O3Tqr9ddpbxd6zjceofepCcITJFVjS9ZwH
|
||||
8
smoothschedule/.envs/.local/.postgres
Normal file
8
smoothschedule/.envs/.local/.postgres
Normal file
@@ -0,0 +1,8 @@
|
||||
# PostgreSQL
|
||||
# ------------------------------------------------------------------------------
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=smoothschedule
|
||||
POSTGRES_USER=FDuVLuQjfpYzGizTmaTavMcaimqpCMRM
|
||||
POSTGRES_PASSWORD=Liqdsbh6vLi5vCvvmdRUrwJWIXwNgL0MP4lV4EHaR4qM7ouzBObM5imX4bKEjgBo
|
||||
DATABASE_URL=postgres://FDuVLuQjfpYzGizTmaTavMcaimqpCMRM:Liqdsbh6vLi5vCvvmdRUrwJWIXwNgL0MP4lV4EHaR4qM7ouzBObM5imX4bKEjgBo@postgres:5432/smoothschedule
|
||||
1
smoothschedule/.gitattributes
vendored
Normal file
1
smoothschedule/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto
|
||||
70
smoothschedule/.github/dependabot.yml
vendored
Normal file
70
smoothschedule/.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
# Config for Dependabot updates. See Documentation here:
|
||||
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# Update GitHub actions in workflows
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
# Every weekday
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- '*'
|
||||
|
||||
# Enable version updates for Docker
|
||||
- package-ecosystem: 'docker'
|
||||
# Look for a `Dockerfile` in the `compose/local/django` directory
|
||||
directories:
|
||||
- 'compose/local/django/'
|
||||
- 'compose/local/docs/'
|
||||
- 'compose/production/django/'
|
||||
# Every weekday
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
# Ignore minor version updates (3.10 -> 3.11) but update patch versions
|
||||
ignore:
|
||||
- dependency-name: '*'
|
||||
update-types:
|
||||
- 'version-update:semver-major'
|
||||
- 'version-update:semver-minor'
|
||||
groups:
|
||||
docker-python:
|
||||
patterns:
|
||||
- '*'
|
||||
|
||||
|
||||
- package-ecosystem: 'docker'
|
||||
# Look for a `Dockerfile` in the listed directories
|
||||
directories:
|
||||
- 'compose/local/node/'
|
||||
- 'compose/production/aws/'
|
||||
- 'compose/production/postgres/'
|
||||
- 'compose/production/traefik/'
|
||||
# Every weekday
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
|
||||
# Enable version updates for Docker Compose files
|
||||
- package-ecosystem: 'docker-compose'
|
||||
directories:
|
||||
- '/'
|
||||
# Every weekday
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
|
||||
# Enable version updates for Python/Pip - Production
|
||||
- package-ecosystem: 'pip'
|
||||
# Look for a `requirements.txt` in the `root` directory
|
||||
# also 'setup.cfg', '.python-version' and 'requirements/*.txt'
|
||||
directory: '/'
|
||||
# Every weekday
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
groups:
|
||||
python:
|
||||
update-types:
|
||||
- 'minor'
|
||||
- 'patch'
|
||||
80
smoothschedule/.github/workflows/ci.yml
vendored
Normal file
80
smoothschedule/.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
name: CI
|
||||
|
||||
# Enable Buildkit and let compose use it to speed up image building
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ['main']
|
||||
paths-ignore: ['docs/**']
|
||||
|
||||
push:
|
||||
branches: ['main']
|
||||
paths-ignore: ['docs/**']
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
linter:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: '.python-version'
|
||||
# Consider using pre-commit.ci for open source project
|
||||
- name: Run pre-commit
|
||||
uses: pre-commit/action@v3.0.1
|
||||
|
||||
# With no caching at all the entire ci process takes 3m to complete!
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Code Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and cache local backend
|
||||
uses: docker/bake-action@v6
|
||||
with:
|
||||
push: false
|
||||
load: true
|
||||
files: docker-compose.local.yml
|
||||
targets: django
|
||||
set: |
|
||||
django.cache-from=type=gha,scope=django-cached-tests
|
||||
django.cache-to=type=gha,scope=django-cached-tests,mode=max
|
||||
postgres.cache-from=type=gha,scope=postgres-cached-tests
|
||||
postgres.cache-to=type=gha,scope=postgres-cached-tests,mode=max
|
||||
|
||||
- name: Build and cache docs
|
||||
uses: docker/bake-action@v6
|
||||
with:
|
||||
push: false
|
||||
load: true
|
||||
files: docker-compose.docs.yml
|
||||
set: |
|
||||
docs.cache-from=type=gha,scope=cached-docs
|
||||
docs.cache-to=type=gha,scope=cached-docs,mode=max
|
||||
|
||||
- name: Check DB Migrations
|
||||
run: docker compose -f docker-compose.local.yml run --rm django python manage.py makemigrations --check
|
||||
|
||||
- name: Run DB Migrations
|
||||
run: docker compose -f docker-compose.local.yml run --rm django python manage.py migrate
|
||||
|
||||
- name: Run Django Tests
|
||||
run: docker compose -f docker-compose.local.yml run django pytest
|
||||
|
||||
- name: Tear down the Stack
|
||||
run: docker compose -f docker-compose.local.yml down
|
||||
278
smoothschedule/.gitignore
vendored
Normal file
278
smoothschedule/.gitignore
vendored
Normal file
@@ -0,0 +1,278 @@
|
||||
### Python template
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
staticfiles/
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# Environments
|
||||
.venv
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
|
||||
|
||||
### Node template
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Typescript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
|
||||
### Linux template
|
||||
*~
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
|
||||
### VisualStudioCode template
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# Local History for devcontainer
|
||||
.devcontainer/bash_history
|
||||
|
||||
|
||||
|
||||
|
||||
### Windows template
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
Desktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
|
||||
### macOS template
|
||||
# General
|
||||
*.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
|
||||
### SublimeText template
|
||||
# Cache files for Sublime Text
|
||||
*.tmlanguage.cache
|
||||
*.tmPreferences.cache
|
||||
*.stTheme.cache
|
||||
|
||||
# Workspace files are user-specific
|
||||
*.sublime-workspace
|
||||
|
||||
# Project files should be checked into the repository, unless a significant
|
||||
# proportion of contributors will probably not be using Sublime Text
|
||||
# *.sublime-project
|
||||
|
||||
# SFTP configuration file
|
||||
sftp-config.json
|
||||
|
||||
# Package control specific files
|
||||
Package Control.last-run
|
||||
Package Control.ca-list
|
||||
Package Control.ca-bundle
|
||||
Package Control.system-ca-bundle
|
||||
Package Control.cache/
|
||||
Package Control.ca-certs/
|
||||
Package Control.merged-ca-bundle
|
||||
Package Control.user-ca-bundle
|
||||
oscrypto-ca-bundle.crt
|
||||
bh_unicode_properties.cache
|
||||
|
||||
# Sublime-github package stores a github token in this file
|
||||
# https://packagecontrol.io/packages/sublime-github
|
||||
GitHub.sublime-settings
|
||||
|
||||
|
||||
### Vim template
|
||||
# Swap
|
||||
[._]*.s[a-v][a-z]
|
||||
[._]*.sw[a-p]
|
||||
[._]s[a-v][a-z]
|
||||
[._]sw[a-p]
|
||||
|
||||
# Session
|
||||
Session.vim
|
||||
|
||||
# Temporary
|
||||
.netrwhist
|
||||
|
||||
# Auto-generated tag files
|
||||
tags
|
||||
|
||||
# Redis dump file
|
||||
dump.rdb
|
||||
|
||||
### Project template
|
||||
smoothschedule/media/
|
||||
|
||||
.pytest_cache/
|
||||
.ipython/
|
||||
.env
|
||||
.envs/*
|
||||
!.envs/.local/
|
||||
50
smoothschedule/.pre-commit-config.yaml
Normal file
50
smoothschedule/.pre-commit-config.yaml
Normal file
@@ -0,0 +1,50 @@
|
||||
exclude: '^docs/|/migrations/|devcontainer.json'
|
||||
default_stages: [pre-commit]
|
||||
minimum_pre_commit_version: "3.2.0"
|
||||
|
||||
default_language_version:
|
||||
python: python3.13
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: check-xml
|
||||
- id: check-yaml
|
||||
- id: debug-statements
|
||||
- id: check-builtin-literals
|
||||
- id: check-case-conflict
|
||||
- id: check-docstring-first
|
||||
- id: detect-private-key
|
||||
|
||||
- repo: https://github.com/adamchainz/django-upgrade
|
||||
rev: '1.29.1'
|
||||
hooks:
|
||||
- id: django-upgrade
|
||||
args: ['--target-version', '5.2']
|
||||
|
||||
# Run the Ruff linter.
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.6
|
||||
hooks:
|
||||
# Linter
|
||||
- id: ruff-check
|
||||
args: [--fix, --exit-non-zero-on-fix]
|
||||
# Formatter
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/Riverside-Healthcare/djLint
|
||||
rev: v1.36.4
|
||||
hooks:
|
||||
- id: djlint-reformat-django
|
||||
- id: djlint-django
|
||||
|
||||
# sets up .pre-commit-ci.yaml to ensure pre-commit dependencies stay up to date
|
||||
ci:
|
||||
autoupdate_schedule: weekly
|
||||
skip: []
|
||||
submodules: false
|
||||
1
smoothschedule/.python-version
Normal file
1
smoothschedule/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.13
|
||||
21
smoothschedule/.readthedocs.yml
Normal file
21
smoothschedule/.readthedocs.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
# Read the Docs configuration file
|
||||
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||
|
||||
version: 2
|
||||
|
||||
build:
|
||||
os: ubuntu-24.04
|
||||
tools:
|
||||
python: "3.13"
|
||||
jobs:
|
||||
pre_create_environment:
|
||||
- asdf plugin add uv
|
||||
- asdf install uv latest
|
||||
- asdf global uv latest
|
||||
create_environment:
|
||||
- uv venv "${READTHEDOCS_VIRTUALENV_PATH}"
|
||||
install:
|
||||
- UV_PROJECT_ENVIRONMENT="${READTHEDOCS_VIRTUALENV_PATH}" uv sync --frozen --no-dev --only-group docs
|
||||
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
1
smoothschedule/CONTRIBUTORS.txt
Normal file
1
smoothschedule/CONTRIBUTORS.txt
Normal file
@@ -0,0 +1 @@
|
||||
Smooth Schedule Team
|
||||
9
smoothschedule/LICENSE
Normal file
9
smoothschedule/LICENSE
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
The MIT License (MIT)
|
||||
Copyright (c) 2025, Smooth Schedule Team
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
125
smoothschedule/QUICKSTART.md
Normal file
125
smoothschedule/QUICKSTART.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Smooth Schedule - Quick Start (Post-Setup)
|
||||
|
||||
## ✅ Database Migrations Complete!
|
||||
|
||||
All migrations have been successfully applied:
|
||||
- ✅ Shared schema (public) created
|
||||
- ✅ Core app migrations applied (Tenant, Domain, PermissionGrant)
|
||||
- ✅ Users app migrations applied (Custom User with role hierarchy)
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
### 1. Create a Superuser
|
||||
|
||||
Run this interactively (it will prompt for password):
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.local.yml run --rm django python manage.py createsuperuser
|
||||
```
|
||||
|
||||
When prompted:
|
||||
- **Email**: admin@smoothschedule.com (or your preferred email)
|
||||
- **Password**: (choose a secure password)
|
||||
- **Password confirmation**: (repeat the password)
|
||||
|
||||
The superuser will automatically have:
|
||||
- `role` = SUPERUSER
|
||||
- `is_staff` = True
|
||||
- `is_superuser` = True
|
||||
- `tenant` = None (platform-level user)
|
||||
|
||||
### 2. Access Django Admin
|
||||
|
||||
Start the services if not running:
|
||||
```bash
|
||||
docker-compose -f docker-compose.local.yml up -d
|
||||
```
|
||||
|
||||
Access the admin interface:
|
||||
- URL: **http://localhost:8000/admin/**
|
||||
- Login with the email and password you just created
|
||||
|
||||
### 3. Create Your First Tenant
|
||||
|
||||
In Django Admin, go to **Core > Tenants** and click "Add Tenant+":
|
||||
|
||||
**Example**:
|
||||
- Name: Demo Company
|
||||
- Schema name: `demo` (lowercase, no spaces)
|
||||
- Subscription tier: PROFESSIONAL
|
||||
- Max users: 50
|
||||
- Max resources: 100
|
||||
|
||||
Then go to **Core > Domains** and click "Add Domain+":
|
||||
- Domain: `demo.localhost`
|
||||
- Tenant: Demo Company (select from dropdown)
|
||||
- Is primary: ✓ (checked)
|
||||
|
||||
### 4. Run Tenant Migrations
|
||||
|
||||
After creating your first tenant:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.local.yml run --rm django python manage.py migrate_schemas
|
||||
```
|
||||
|
||||
This applies migrations to all tenant schemas.
|
||||
|
||||
### 5. Create Tenant Users
|
||||
|
||||
In Django Admin, go to **Users > Users** and click "Add User+":
|
||||
|
||||
**Example Tenant Owner**:
|
||||
- Email: owner@demo.com
|
||||
- Password: (secure password)
|
||||
- Role: TENANT_OWNER
|
||||
- Tenant: Demo Company
|
||||
- First name: John
|
||||
- Last name: Owner
|
||||
- Is active: ✓
|
||||
|
||||
**Example Demo Account** (for Sales to masquerade):
|
||||
- Email: demo@demo.com
|
||||
- Password: demo
|
||||
- Role: TENANT_OWNER
|
||||
- Tenant: Demo Company
|
||||
- Is temporary: ✓ (checked - allows Platform Sales to hijack)
|
||||
|
||||
### 6. Test Masquerading
|
||||
|
||||
1. Login to admin as superuser
|
||||
2. Go to Users list
|
||||
3. Find the demo or tenant owner user
|
||||
4. Look for the "Hijack" button (usually in the row actions)
|
||||
5. Click it to masquerade as that user
|
||||
6. Check `logs/masquerade.log` for audit trail:
|
||||
```bash
|
||||
docker-compose -f docker-compose.local.yml logs django | grep masquerade
|
||||
```
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
**Can't access admin?**
|
||||
- Ensure containers are running: `docker-compose -f docker-compose.local.yml ps`
|
||||
- Check logs: `docker-compose -f docker-compose.local.yml logs -f django`
|
||||
|
||||
**Forgot password?**
|
||||
- Reset via command: `docker-compose -f docker-compose.local.yml run --rm django python manage.py changepassword admin@smoothschedule.com`
|
||||
|
||||
**Need to access tenant subdomain?**
|
||||
- Add to `/etc/hosts`: `127.0.0.1 demo.localhost`
|
||||
- Access: http://demo.localhost:8000/
|
||||
|
||||
## 📚 Key Files Created
|
||||
|
||||
- `core/models.py` - Tenant, Domain, PermissionGrant
|
||||
- `smoothschedule/users/models.py` - Custom User with 8 roles
|
||||
- `core/permissions.py` - Masquerading permission matrix
|
||||
- `core/middleware.py` - Audit logging middleware
|
||||
- `core/admin.py` - Admin interfaces for core models
|
||||
- `smoothschedule/users/admin.py` - User admin with hijack button
|
||||
- `config/settings/multitenancy.py` - Multi-tenancy configuration
|
||||
|
||||
## 🎉 You're Ready!
|
||||
|
||||
Your multi-tenant SaaS platform is now fully operational. All database tables are created, migrations are applied, and the system is ready for your first tenant!
|
||||
88
smoothschedule/README.md
Normal file
88
smoothschedule/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Smooth Schedule
|
||||
|
||||
Multi-Tenant SaaS Resource Orchestration Platform
|
||||
|
||||
[](https://github.com/cookiecutter/cookiecutter-django/)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||
|
||||
License: MIT
|
||||
|
||||
## Settings
|
||||
|
||||
Moved to [settings](https://cookiecutter-django.readthedocs.io/en/latest/1-getting-started/settings.html).
|
||||
|
||||
## Basic Commands
|
||||
|
||||
### Setting Up Your Users
|
||||
|
||||
- To create a **normal user account**, just go to Sign Up and fill out the form. Once you submit it, you'll see a "Verify Your E-mail Address" page. Go to your console to see a simulated email verification message. Copy the link into your browser. Now the user's email should be verified and ready to go.
|
||||
|
||||
- To create a **superuser account**, use this command:
|
||||
|
||||
uv run python manage.py createsuperuser
|
||||
|
||||
For convenience, you can keep your normal user logged in on Chrome and your superuser logged in on Firefox (or similar), so that you can see how the site behaves for both kinds of users.
|
||||
|
||||
### Type checks
|
||||
|
||||
Running type checks with mypy:
|
||||
|
||||
uv run mypy smoothschedule
|
||||
|
||||
### Test coverage
|
||||
|
||||
To run the tests, check your test coverage, and generate an HTML coverage report:
|
||||
|
||||
uv run coverage run -m pytest
|
||||
uv run coverage html
|
||||
uv run open htmlcov/index.html
|
||||
|
||||
#### Running tests with pytest
|
||||
|
||||
uv run pytest
|
||||
|
||||
### Live reloading and Sass CSS compilation
|
||||
|
||||
Moved to [Live reloading and SASS compilation](https://cookiecutter-django.readthedocs.io/en/latest/2-local-development/developing-locally.html#using-webpack-or-gulp).
|
||||
|
||||
### Celery
|
||||
|
||||
This app comes with Celery.
|
||||
|
||||
To run a celery worker:
|
||||
|
||||
```bash
|
||||
cd smoothschedule
|
||||
uv run celery -A config.celery_app worker -l info
|
||||
```
|
||||
|
||||
Please note: For Celery's import magic to work, it is important _where_ the celery commands are run. If you are in the same folder with _manage.py_, you should be right.
|
||||
|
||||
To run [periodic tasks](https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html), you'll need to start the celery beat scheduler service. You can start it as a standalone process:
|
||||
|
||||
```bash
|
||||
cd smoothschedule
|
||||
uv run celery -A config.celery_app beat
|
||||
```
|
||||
|
||||
or you can embed the beat service inside a worker with the `-B` option (not recommended for production use):
|
||||
|
||||
```bash
|
||||
cd smoothschedule
|
||||
uv run celery -A config.celery_app worker -B -l info
|
||||
```
|
||||
|
||||
### Sentry
|
||||
|
||||
Sentry is an error logging aggregator service. You can sign up for a free account at <https://sentry.io/signup/?code=cookiecutter> or download and host it yourself.
|
||||
The system is set up with reasonable defaults, including 404 logging and integration with the WSGI application.
|
||||
|
||||
You must set the DSN url in production.
|
||||
|
||||
## Deployment
|
||||
|
||||
The following details how to deploy this application.
|
||||
|
||||
### Docker
|
||||
|
||||
See detailed [cookiecutter-django Docker documentation](https://cookiecutter-django.readthedocs.io/en/latest/3-deployment/deployment-with-docker.html).
|
||||
361
smoothschedule/SETUP_GUIDE.md
Normal file
361
smoothschedule/SETUP_GUIDE.md
Normal file
@@ -0,0 +1,361 @@
|
||||
# Smooth Schedule - Installation & Setup Guide
|
||||
|
||||
## 🎉 Project Structure Created Successfully!
|
||||
|
||||
Your Smooth Schedule multi-tenant SaaS platform has been initialized with:
|
||||
- ✅ Cookiecutter-Django base structure
|
||||
- ✅ Multi-tenancy with django-tenants 3.9.0
|
||||
- ✅ Masquerading security with django-hijack 3.7.4
|
||||
- ✅ Custom 8-tier role hierarchy
|
||||
- ✅ Audit logging middleware
|
||||
- ✅ PostgreSQL schema-per-tenant architecture
|
||||
|
||||
## 📁 Project Location
|
||||
|
||||
```
|
||||
/home/poduck/Desktop/smoothschedule2/smoothschedule/
|
||||
```
|
||||
|
||||
## 🚀 Next Steps to Get Running
|
||||
|
||||
### 1. Navigate to Project Directory
|
||||
|
||||
```bash
|
||||
cd /home/poduck/Desktop/smoothschedule2/smoothschedule
|
||||
```
|
||||
|
||||
### 2. Configure Environment Variables
|
||||
|
||||
Create `.envs/.local/.django` file:
|
||||
|
||||
```bash
|
||||
mkdir -p .envs/.local
|
||||
cat > .envs/.local/.django << 'EOF'
|
||||
# General
|
||||
# ------------------------------------------------------------------------------
|
||||
DJANGO_SETTINGS_MODULE=config.settings.local
|
||||
DJANGO_SECRET_KEY=your-secret-key-here-change-in-production
|
||||
DJANGO_DEBUG=True
|
||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,.localhost,.127.0.0.1
|
||||
USE_DOCKER=yes
|
||||
|
||||
# Database
|
||||
# ------------------------------------------------------------------------------
|
||||
DATABASE_URL=postgres://postgres:changeme@postgres:5432/smoothschedule
|
||||
|
||||
# Redis
|
||||
# ------------------------------------------------------------------------------
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
#AWS
|
||||
# ------------------------------------------------------------------------------
|
||||
DJANGO_AWS_ACCESS_KEY_ID=
|
||||
DJANGO_AWS_SECRET_ACCESS_KEY=
|
||||
DJANGO_AWS_STORAGE_BUCKET_NAME=smoothschedule-media
|
||||
DJANGO_AWS_S3_REGION_NAME=us-east-1
|
||||
EOF
|
||||
```
|
||||
|
||||
Create `.envs/.local/.postgres` file:
|
||||
|
||||
```bash
|
||||
cat > .envs/.local/.postgres << 'EOF'
|
||||
# PostgreSQL
|
||||
# ------------------------------------------------------------------------------
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=smoothschedule
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=changeme
|
||||
EOF
|
||||
```
|
||||
|
||||
### 3. Build and Start Docker Containers
|
||||
|
||||
```bash
|
||||
# Build the Docker images
|
||||
docker-compose -f docker-compose.local.yml build
|
||||
|
||||
# Start all services (Django, PostgreSQL, Redis, Celery)
|
||||
docker-compose -f docker-compose.local.yml up -d
|
||||
|
||||
# Check logs
|
||||
docker-compose -f docker-compose.local.yml logs -f
|
||||
```
|
||||
|
||||
### 4. Run Database Migrations
|
||||
|
||||
**IMPORTANT**: With django-tenants, you must run migrations in a specific order:
|
||||
|
||||
```bash
|
||||
# 1. Create the shared schema (public) FIRST
|
||||
docker-compose -f docker-compose.local.yml run --rm django python manage.py migrate_schemas --shared
|
||||
|
||||
# 2. Create a superuser (in the public schema)
|
||||
docker-compose -f docker-compose.local.yml run --rm django python manage.py createsuperuser
|
||||
```
|
||||
|
||||
### 5. Create Your First Tenant
|
||||
|
||||
Open Django shell:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.local.yml run --rm django python manage.py shell
|
||||
```
|
||||
|
||||
In the shell, create a tenant:
|
||||
|
||||
```python
|
||||
from core.models import Tenant, Domain
|
||||
|
||||
# Create the tenant
|
||||
tenant = Tenant.objects.create(
|
||||
name="Demo Company",
|
||||
schema_name="demo", # This will be the PostgreSQL schema name
|
||||
subscription_tier="PROFESSIONAL",
|
||||
max_users=50,
|
||||
max_resources=100,
|
||||
)
|
||||
|
||||
# Create a domain for the tenant
|
||||
domain = Domain.objects.create(
|
||||
domain="demo.localhost", # For local development
|
||||
tenant=tenant,
|
||||
is_primary=True,
|
||||
)
|
||||
|
||||
print(f"Created tenant: {tenant.name} with schema: {tenant.schema_name}")
|
||||
print(f"Domain: {domain.domain}")
|
||||
```
|
||||
|
||||
Exit the shell (`exit()` or Ctrl+D).
|
||||
|
||||
### 6. Run Tenant Migrations
|
||||
|
||||
Now migrate all tenant schemas:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.local.yml run --rm django python manage.py migrate_schemas
|
||||
```
|
||||
|
||||
### 7. Access the Application
|
||||
|
||||
- **Django Admin**: http://localhost:8000/admin/
|
||||
- Login with the superuser you created
|
||||
- You can manage tenants, users, and permission grants here
|
||||
|
||||
- **API Documentation**: http://localhost:8000/api/schema/swagger-ui/
|
||||
- DRF Spectacular auto-generated API docs
|
||||
|
||||
- **Tenant Access**:
|
||||
- Add entry to `/etc/hosts`:
|
||||
```bash
|
||||
127.0.0.1 demo.localhost
|
||||
```
|
||||
- Access tenant at: http://demo.localhost:8000/
|
||||
|
||||
## 🔐 Creating Users with Roles
|
||||
|
||||
In Django shell or admin, create users with different roles:
|
||||
|
||||
```python
|
||||
from smoothschedule.users.models import User
|
||||
from core.models import Tenant
|
||||
|
||||
# Get the tenant
|
||||
tenant = Tenant.objects.get(schema_name="demo")
|
||||
|
||||
# Create a Tenant Owner
|
||||
owner = User.objects.create_user(
|
||||
email="owner@demo.com",
|
||||
password="secure-password",
|
||||
role=User.Role.TENANT_OWNER,
|
||||
tenant=tenant,
|
||||
first_name="John",
|
||||
last_name="Owner",
|
||||
)
|
||||
|
||||
# Create a Tenant Staff member
|
||||
staff = User.objects.create_user(
|
||||
email="staff@demo.com",
|
||||
password="secure-password",
|
||||
role=User.Role.TENANT_STAFF,
|
||||
tenant=tenant,
|
||||
first_name="Jane",
|
||||
last_name="Staff",
|
||||
)
|
||||
|
||||
# Create a demo account (for sales)
|
||||
demo_user = User.objects.create_user(
|
||||
email="demo@demo.com",
|
||||
password="demo-password",
|
||||
role=User.Role.TENANT_OWNER,
|
||||
tenant=tenant,
|
||||
is_temporary=True, # Sales can masquerade as this user
|
||||
first_name="Demo",
|
||||
last_name="Account",
|
||||
)
|
||||
|
||||
# Create a Platform Support user
|
||||
support = User.objects.create_user(
|
||||
email="support@smoothschedule.com",
|
||||
password="support-password",
|
||||
role=User.Role.PLATFORM_SUPPORT,
|
||||
tenant=None, # Platform users have no tenant
|
||||
is_staff=True, # Needed for admin access
|
||||
first_name="Support",
|
||||
last_name="Team",
|
||||
)
|
||||
```
|
||||
|
||||
## 🎭 Testing Masquerading
|
||||
|
||||
1. Login to admin as a superuser or support user
|
||||
2. Go to Users section
|
||||
3. Click the "Hijack" button next to a user you can masquerade as
|
||||
4. You'll now see the interface as that user
|
||||
5. Check `logs/masquerade.log` for audit trail:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.local.yml exec django cat logs/masquerade.log
|
||||
```
|
||||
|
||||
## 📊 Understanding the Architecture
|
||||
|
||||
### Multi-Tenancy
|
||||
|
||||
Each tenant gets their own PostgreSQL schema:
|
||||
|
||||
```
|
||||
PostgreSQL Database: smoothschedule
|
||||
├── public (shared schema)
|
||||
│ ├── core_tenant
|
||||
│ ├── core_domain
|
||||
│ ├── users_user
|
||||
│ └── core_permissiongrant
|
||||
├── demo (tenant schema for "Demo Company")
|
||||
│ └── (your tenant-scoped apps go here)
|
||||
└── acme (tenant schema for "Acme Corp")
|
||||
└── (completely isolated data)
|
||||
```
|
||||
|
||||
### Role Hierarchy
|
||||
|
||||
| Role | Level | Can Hijack |
|
||||
|------|-------|-----------|
|
||||
| SUPERUSER | Platform | Everyone |
|
||||
| PLATFORM_SUPPORT | Platform | Tenant users |
|
||||
| PLATFORM_SALES | Platform | Demo accounts only |
|
||||
| TENANT_OWNER | Tenant | Staff in same tenant |
|
||||
| TENANT_MANAGER | Tenant | - |
|
||||
| TENANT_STAFF | Tenant | - |
|
||||
| CUSTOMER | Tenant | - |
|
||||
|
||||
## 🛠️ Development Workflow
|
||||
|
||||
### Adding Tenant-Scoped Apps
|
||||
|
||||
1. Create your app:
|
||||
```bash
|
||||
docker-compose -f docker-compose.local.yml run --rm django python manage.py startapp myapp
|
||||
```
|
||||
|
||||
2. Add to `TENANT_APPS` in `config/settings/multitenancy.py`:
|
||||
```python
|
||||
TENANT_APPS = [
|
||||
'django.contrib.contenttypes',
|
||||
'myapp', # Your new app
|
||||
]
|
||||
```
|
||||
|
||||
3. Run migrations:
|
||||
```bash
|
||||
docker-compose -f docker-compose.local.yml run --rm django python manage.py migrate_schemas
|
||||
```
|
||||
|
||||
### Useful Commands
|
||||
|
||||
```bash
|
||||
# View logs
|
||||
docker-compose -f docker-compose.local.yml logs -f django
|
||||
|
||||
# Shell access
|
||||
docker-compose -f docker-compose.local.yml run --rm django python manage.py shell
|
||||
|
||||
# Create migrations
|
||||
docker-compose -f docker-compose.local.yml run --rm django python manage.py makemigrations
|
||||
|
||||
# Migrate all tenants
|
||||
docker-compose -f docker-compose.local.yml run --rm django python manage.py migrate_schemas
|
||||
|
||||
# Collect static files
|
||||
docker-compose -f docker-compose.local.yml run --rm django python manage.py collectstatic --noinput
|
||||
```
|
||||
|
||||
## 📝 Key Files Reference
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `config/settings/multitenancy.py` | Multi-tenancy configuration |
|
||||
| `config/settings/local.py` | Local development settings |
|
||||
| `smoothschedule/core/` | Tenant, Domain, PermissionGrant models |
|
||||
| `smoothschedule/users/models.py` | Custom User with 8 roles |
|
||||
| `core/permissions.py` | Masquerading permission matrix |
|
||||
| `core/middleware.py` | Audit logging middleware |
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Problem: "django_tenants not found"
|
||||
|
||||
**Solution**: Rebuild the Docker image:
|
||||
```bash
|
||||
docker-compose -f docker-compose.local.yml build --no-cache django
|
||||
```
|
||||
|
||||
### Problem: "relation does not exist"
|
||||
|
||||
**Solution**: Run migrations in correct order:
|
||||
```bash
|
||||
# 1. Shared schema first
|
||||
docker-compose -f docker-compose.local.yml run --rm django python manage.py migrate_schemas --shared
|
||||
|
||||
# 2. Then tenant schemas
|
||||
docker-compose -f docker-compose.local.yml run --rm django python manage.py migrate_schemas
|
||||
```
|
||||
|
||||
### Problem: Cannot access tenant URL
|
||||
|
||||
**Solution**: Add to `/etc/hosts`:
|
||||
```bash
|
||||
echo "127.0.0.1 demo.localhost" | sudo tee -a /etc/hosts
|
||||
```
|
||||
|
||||
### Problem: Hijack button doesn't appear
|
||||
|
||||
**Solution**:
|
||||
1. Ensure user has appropriate role (see permission matrix)
|
||||
2. Check that `HijackUserAdminMixin` is in `users/admin.py`
|
||||
3. Verify middleware order in settings
|
||||
|
||||
## 🎯 Production Deployment
|
||||
|
||||
Before deploying to production:
|
||||
|
||||
1. **Update SECRET_KEY**: Use environment variable
|
||||
2. **Set DEBUG=False**: In production settings
|
||||
3. **Configure ALLOWED_HOSTS**: Add your domain
|
||||
4. **Update Database Credentials**: Use secure passwords
|
||||
5. **Configure AWS**: Set real S3 and Route53 credentials
|
||||
6. **Enable HTTPS**: Set `SECURE_SSL_REDIRECT = True`
|
||||
7. **Review Security Settings**: Check `config/settings/production.py`
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- [django-tenants Documentation](https://django-tenants.readthedocs.io/)
|
||||
- [django-hijack Documentation](https://django-hijack.readthedocs.io/)
|
||||
- [cookiecutter-django Documentation](https://cookiecutter-django.readthedocs.io/)
|
||||
- [Django REST Framework](https://www.django-rest-framework.org/)
|
||||
|
||||
---
|
||||
|
||||
**Your multi-tenant SaaS platform is ready! 🚀**
|
||||
0
smoothschedule/communication/__init__.py
Normal file
0
smoothschedule/communication/__init__.py
Normal file
3
smoothschedule/communication/admin.py
Normal file
3
smoothschedule/communication/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
smoothschedule/communication/apps.py
Normal file
6
smoothschedule/communication/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommunicationConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'communication'
|
||||
38
smoothschedule/communication/migrations/0001_initial.py
Normal file
38
smoothschedule/communication/migrations/0001_initial.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-27 04:43
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CommunicationSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('twilio_conversation_sid', models.CharField(db_index=True, help_text='Twilio Conversation SID', max_length=255, unique=True)),
|
||||
('is_active', models.BooleanField(db_index=True, default=True, help_text='Whether the conversation is active')),
|
||||
('staff_phone', models.CharField(help_text="Staff member's phone number (E.164 format)", max_length=20, validators=[django.core.validators.RegexValidator(message='Phone number must be in E.164 format (+1234567890)', regex='^\\+?1?\\d{9,15}$')])),
|
||||
('customer_phone', models.CharField(help_text="Customer's phone number (E.164 format)", max_length=20, validators=[django.core.validators.RegexValidator(message='Phone number must be in E.164 format (+1234567890)', regex='^\\+?1?\\d{9,15}$')])),
|
||||
('language_code', models.CharField(default='en', help_text='Language preference for SMS templates (en/es/fr/de)', max_length=10)),
|
||||
('staff_participant_sid', models.CharField(blank=True, help_text='Twilio Participant SID for staff', max_length=255)),
|
||||
('customer_participant_sid', models.CharField(blank=True, help_text='Twilio Participant SID for customer', max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('closed_at', models.DateTimeField(blank=True, help_text='When the conversation was closed', null=True)),
|
||||
('event', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='communication_session', to='schedule.event')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['is_active', 'created_at'], name='communicati_is_acti_ebcdc9_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
smoothschedule/communication/migrations/__init__.py
Normal file
0
smoothschedule/communication/migrations/__init__.py
Normal file
102
smoothschedule/communication/models.py
Normal file
102
smoothschedule/communication/models.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from django.db import models
|
||||
from django.core.validators import RegexValidator
|
||||
|
||||
|
||||
class CommunicationSession(models.Model):
|
||||
"""
|
||||
Masked communication session using Twilio Conversations.
|
||||
|
||||
Enables staff and customers to communicate via SMS/chat without
|
||||
exposing personal phone numbers. Twilio routes messages through
|
||||
a proxy number.
|
||||
"""
|
||||
|
||||
# Link to event
|
||||
event = models.OneToOneField(
|
||||
'schedule.Event',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='communication_session'
|
||||
)
|
||||
|
||||
# Twilio identifiers
|
||||
twilio_conversation_sid = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
db_index=True,
|
||||
help_text="Twilio Conversation SID"
|
||||
)
|
||||
|
||||
# Session status
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
db_index=True,
|
||||
help_text="Whether the conversation is active"
|
||||
)
|
||||
|
||||
# Phone validation regex (E.164 format)
|
||||
phone_regex = RegexValidator(
|
||||
regex=r'^\+?1?\d{9,15}$',
|
||||
message="Phone number must be in E.164 format (+1234567890)"
|
||||
)
|
||||
|
||||
# Secure phone storage
|
||||
# NOTE: In production, consider encrypting these fields
|
||||
staff_phone = models.CharField(
|
||||
max_length=20,
|
||||
validators=[phone_regex],
|
||||
help_text="Staff member's phone number (E.164 format)"
|
||||
)
|
||||
|
||||
customer_phone = models.CharField(
|
||||
max_length=20,
|
||||
validators=[phone_regex],
|
||||
help_text="Customer's phone number (E.164 format)"
|
||||
)
|
||||
|
||||
# Localization
|
||||
language_code = models.CharField(
|
||||
max_length=10,
|
||||
default='en',
|
||||
help_text="Language preference for SMS templates (en/es/fr/de)"
|
||||
)
|
||||
|
||||
# Participant SIDs (for cleanup)
|
||||
staff_participant_sid = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text="Twilio Participant SID for staff"
|
||||
)
|
||||
|
||||
customer_participant_sid = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text="Twilio Participant SID for customer"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
closed_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When the conversation was closed"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['is_active', 'created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
status = "Active" if self.is_active else "Closed"
|
||||
return f"Session for {self.event.title} ({status})"
|
||||
|
||||
@property
|
||||
def message_count(self):
|
||||
"""
|
||||
Get total message count (would require Twilio API call).
|
||||
Stub for future implementation.
|
||||
"""
|
||||
# TODO: Implement via Twilio API
|
||||
return None
|
||||
231
smoothschedule/communication/services.py
Normal file
231
smoothschedule/communication/services.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
Communication Services - Twilio Conversations Integration
|
||||
|
||||
Provides masked phone/SMS communication between staff and customers.
|
||||
CRITICAL: teardown_session() must call Twilio API to close conversations,
|
||||
not just update local database, to prevent ongoing charges.
|
||||
"""
|
||||
from twilio.rest import Client
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from .models import CommunicationSession
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TwilioService:
|
||||
"""
|
||||
Service for Twilio Conversations (masked chat/SMS).
|
||||
|
||||
Enables staff and customers to communicate without exposing
|
||||
personal phone numbers. Messages are routed through Twilio.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Twilio client"""
|
||||
self.client = Client(
|
||||
settings.TWILIO_ACCOUNT_SID,
|
||||
settings.TWILIO_AUTH_TOKEN
|
||||
)
|
||||
|
||||
def create_masked_session(
|
||||
self,
|
||||
event,
|
||||
staff_phone,
|
||||
customer_phone,
|
||||
language_code='en'
|
||||
):
|
||||
"""
|
||||
Create a masked communication session for an event.
|
||||
|
||||
Creates a Twilio Conversation and adds both staff and customer
|
||||
as participants. Messages are routed through Twilio without
|
||||
exposing phone numbers.
|
||||
|
||||
Args:
|
||||
event: schedule.Event instance
|
||||
staff_phone: Staff member's phone (E.164 format)
|
||||
customer_phone: Customer's phone (E.164 format)
|
||||
language_code: Language for SMS templates (en/es/fr/de)
|
||||
|
||||
Returns:
|
||||
CommunicationSession instance
|
||||
|
||||
Raises:
|
||||
TwilioRestException: On API errors
|
||||
"""
|
||||
# Step 1: Create Twilio Conversation
|
||||
conversation = self.client.conversations.v1.conversations.create(
|
||||
friendly_name=f"Event: {event.title} (ID: {event.id})",
|
||||
unique_name=f"event_{event.id}_{timezone.now().timestamp()}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Created Twilio Conversation: {conversation.sid}",
|
||||
extra={'event_id': event.id}
|
||||
)
|
||||
|
||||
# Step 2: Add Staff Participant (SMS)
|
||||
staff_participant = conversation.participants.create(
|
||||
messaging_binding_address=staff_phone,
|
||||
messaging_binding_proxy_address=settings.TWILIO_PROXY_NUMBER,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Added staff participant: {staff_participant.sid}",
|
||||
extra={'phone': staff_phone[:6] + '****'} # Masked for logging
|
||||
)
|
||||
|
||||
# Step 3: Add Customer Participant (SMS)
|
||||
customer_participant = conversation.participants.create(
|
||||
messaging_binding_address=customer_phone,
|
||||
messaging_binding_proxy_address=settings.TWILIO_PROXY_NUMBER,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Added customer participant: {customer_participant.sid}",
|
||||
extra={'phone': customer_phone[:6] + '****'}
|
||||
)
|
||||
|
||||
# Step 4: Send initial message (localized)
|
||||
# FUTURE: Load SMS templates based on language_code
|
||||
welcome_message = self._get_welcome_message(language_code, event)
|
||||
conversation.messages.create(
|
||||
body=welcome_message,
|
||||
author='system'
|
||||
)
|
||||
|
||||
# Step 5: Create local session record
|
||||
session = CommunicationSession.objects.create(
|
||||
event=event,
|
||||
twilio_conversation_sid=conversation.sid,
|
||||
is_active=True,
|
||||
staff_phone=staff_phone,
|
||||
customer_phone=customer_phone,
|
||||
language_code=language_code,
|
||||
staff_participant_sid=staff_participant.sid,
|
||||
customer_participant_sid=customer_participant.sid,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Created CommunicationSession: {session.id}",
|
||||
extra={'event_id': event.id, 'conversation_sid': conversation.sid}
|
||||
)
|
||||
|
||||
return session
|
||||
|
||||
def teardown_session(self, event):
|
||||
"""
|
||||
Close and cleanup a communication session.
|
||||
|
||||
CRITICAL: This MUST call the Twilio API to actually close/delete
|
||||
the conversation. Just updating the local DB is NOT sufficient
|
||||
and will result in ongoing charges.
|
||||
|
||||
Args:
|
||||
event: schedule.Event instance
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
"""
|
||||
try:
|
||||
# Fetch the session
|
||||
session = CommunicationSession.objects.get(
|
||||
event=event,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# CRITICAL: Actually call Twilio API to close conversation
|
||||
# This prevents ongoing message charges
|
||||
conversation = self.client.conversations.v1.conversations(
|
||||
session.twilio_conversation_sid
|
||||
)
|
||||
|
||||
# Delete the conversation (closes it and removes all participants)
|
||||
conversation.delete()
|
||||
|
||||
logger.info(
|
||||
f"Deleted Twilio Conversation: {session.twilio_conversation_sid}",
|
||||
extra={'event_id': event.id}
|
||||
)
|
||||
|
||||
# Now update local database
|
||||
session.is_active = False
|
||||
session.closed_at = timezone.now()
|
||||
session.save()
|
||||
|
||||
logger.info(
|
||||
f"Closed CommunicationSession: {session.id}",
|
||||
extra={'event_id': event.id}
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except CommunicationSession.DoesNotExist:
|
||||
logger.warning(f"No active session found for Event {event.id}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error tearing down session for Event {event.id}: {str(e)}",
|
||||
exc_info=e
|
||||
)
|
||||
return False
|
||||
|
||||
def send_message(self, session, message_body, author='staff'):
|
||||
"""
|
||||
Send a message in an existing conversation.
|
||||
|
||||
Args:
|
||||
session: CommunicationSession instance
|
||||
message_body: Message text
|
||||
author: 'staff' or 'customer'
|
||||
"""
|
||||
if not session.is_active:
|
||||
raise ValueError("Cannot send message to inactive session")
|
||||
|
||||
conversation = self.client.conversations.v1.conversations(
|
||||
session.twilio_conversation_sid
|
||||
)
|
||||
|
||||
message = conversation.messages.create(
|
||||
body=message_body,
|
||||
author=author
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Sent message in conversation {session.twilio_conversation_sid}",
|
||||
extra={'author': author}
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
def _get_welcome_message(self, language_code, event):
|
||||
"""
|
||||
Get localized welcome message.
|
||||
|
||||
FUTURE: Load from database or template files based on language_code.
|
||||
Currently returns English only.
|
||||
|
||||
Args:
|
||||
language_code: Language preference (en/es/fr/de)
|
||||
event: Event instance
|
||||
|
||||
Returns:
|
||||
str: Localized welcome message
|
||||
"""
|
||||
# STUB: Future implementation would load from i18n templates
|
||||
templates = {
|
||||
'en': f"Hi! This is a secure line for your appointment: {event.title}. Reply to this message to communicate.",
|
||||
'es': f"¡Hola! Esta es una línea segura para su cita: {event.title}. Responda a este mensaje para comunicarse.",
|
||||
'fr': f"Bonjour! Ceci est une ligne sécurisée pour votre rendez-vous: {event.title}. Répondez à ce message pour communiquer.",
|
||||
'de': f"Hallo! Dies ist eine sichere Leitung für Ihren Termin: {event.title}. Antworten Sie auf diese Nachricht, um zu kommunizieren.",
|
||||
}
|
||||
|
||||
return templates.get(language_code, templates['en'])
|
||||
|
||||
|
||||
# Helper function
|
||||
def get_twilio_service():
|
||||
"""Factory function to get TwilioService"""
|
||||
return TwilioService()
|
||||
3
smoothschedule/communication/tests.py
Normal file
3
smoothschedule/communication/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
3
smoothschedule/communication/views.py
Normal file
3
smoothschedule/communication/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
71
smoothschedule/compose/local/django/Dockerfile
Normal file
71
smoothschedule/compose/local/django/Dockerfile
Normal file
@@ -0,0 +1,71 @@
|
||||
# define an alias for the specific python version used in this file.
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS python
|
||||
|
||||
# Python build stage
|
||||
FROM python AS python-build-stage
|
||||
|
||||
ARG APP_HOME=/app
|
||||
|
||||
WORKDIR ${APP_HOME}
|
||||
|
||||
# we need to move the virtualenv outside of the $APP_HOME directory because it will be overriden by the docker compose mount
|
||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=0
|
||||
|
||||
# Install apt packages
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
# dependencies for building Python packages
|
||||
build-essential \
|
||||
# psycopg dependencies
|
||||
libpq-dev \
|
||||
gettext \
|
||||
wait-for-it
|
||||
|
||||
# Requirements are installed here to ensure they will be cached.
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock:rw \
|
||||
uv sync --no-install-project
|
||||
|
||||
COPY . ${APP_HOME}
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock:rw \
|
||||
uv sync
|
||||
|
||||
# devcontainer dependencies and utils
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
sudo git bash-completion nano ssh
|
||||
|
||||
# Create devcontainer user and add it to sudoers
|
||||
RUN groupadd --gid 1000 dev-user \
|
||||
&& useradd --uid 1000 --gid dev-user --shell /bin/bash --create-home dev-user \
|
||||
&& echo dev-user ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/dev-user \
|
||||
&& chmod 0440 /etc/sudoers.d/dev-user
|
||||
|
||||
ENV PATH="/${APP_HOME}/.venv/bin:$PATH"
|
||||
ENV PYTHONPATH="${APP_HOME}/.venv/lib/python3.13/site-packages:$PYTHONPATH"
|
||||
|
||||
COPY ./compose/production/django/entrypoint /entrypoint
|
||||
RUN sed -i 's/\r$//g' /entrypoint
|
||||
RUN chmod +x /entrypoint
|
||||
|
||||
COPY ./compose/local/django/start /start
|
||||
RUN sed -i 's/\r$//g' /start
|
||||
RUN chmod +x /start
|
||||
|
||||
|
||||
COPY ./compose/local/django/celery/worker/start /start-celeryworker
|
||||
RUN sed -i 's/\r$//g' /start-celeryworker
|
||||
RUN chmod +x /start-celeryworker
|
||||
|
||||
COPY ./compose/local/django/celery/beat/start /start-celerybeat
|
||||
RUN sed -i 's/\r$//g' /start-celerybeat
|
||||
RUN chmod +x /start-celerybeat
|
||||
|
||||
COPY ./compose/local/django/celery/flower/start /start-flower
|
||||
RUN sed -i 's/\r$//g' /start-flower
|
||||
RUN chmod +x /start-flower
|
||||
|
||||
|
||||
ENTRYPOINT ["/entrypoint"]
|
||||
8
smoothschedule/compose/local/django/celery/beat/start
Normal file
8
smoothschedule/compose/local/django/celery/beat/start
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o nounset
|
||||
|
||||
|
||||
rm -f './celerybeat.pid'
|
||||
exec watchfiles --filter python celery.__main__.main --args '-A config.celery_app beat -l INFO'
|
||||
16
smoothschedule/compose/local/django/celery/flower/start
Normal file
16
smoothschedule/compose/local/django/celery/flower/start
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o nounset
|
||||
|
||||
|
||||
until timeout 10 celery -A config.celery_app inspect ping; do
|
||||
>&2 echo "Celery workers not available"
|
||||
done
|
||||
|
||||
echo 'Starting flower'
|
||||
|
||||
|
||||
exec watchfiles --filter python celery.__main__.main \
|
||||
--args \
|
||||
"-A config.celery_app -b \"${REDIS_URL}\" flower --basic_auth=\"${CELERY_FLOWER_USER}:${CELERY_FLOWER_PASSWORD}\""
|
||||
7
smoothschedule/compose/local/django/celery/worker/start
Normal file
7
smoothschedule/compose/local/django/celery/worker/start
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o nounset
|
||||
|
||||
|
||||
exec watchfiles --filter python celery.__main__.main --args '-A config.celery_app worker -l INFO'
|
||||
9
smoothschedule/compose/local/django/start
Normal file
9
smoothschedule/compose/local/django/start
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
|
||||
python manage.py migrate
|
||||
exec python manage.py runserver_plus 0.0.0.0:8000
|
||||
65
smoothschedule/compose/local/docs/Dockerfile
Normal file
65
smoothschedule/compose/local/docs/Dockerfile
Normal file
@@ -0,0 +1,65 @@
|
||||
# define an alias for the specific python version used in this file.
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS python
|
||||
|
||||
|
||||
# Python build stage
|
||||
FROM python AS python-build-stage
|
||||
|
||||
ARG APP_HOME=/app
|
||||
|
||||
WORKDIR ${APP_HOME}
|
||||
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
# dependencies for building Python packages
|
||||
build-essential \
|
||||
# psycopg dependencies
|
||||
libpq-dev \
|
||||
# cleaning up unused files
|
||||
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Requirements are installed here to ensure they will be cached.
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --no-install-project
|
||||
|
||||
COPY . ${APP_HOME}
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync
|
||||
|
||||
|
||||
# Python 'run' stage
|
||||
FROM python AS python-run-stage
|
||||
|
||||
ARG BUILD_ENVIRONMENT
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
# To run the Makefile
|
||||
make \
|
||||
# psycopg dependencies
|
||||
libpq-dev \
|
||||
# Translations dependencies
|
||||
gettext \
|
||||
# Uncomment below lines to enable Sphinx output to latex and pdf
|
||||
# texlive-latex-recommended \
|
||||
# texlive-fonts-recommended \
|
||||
# texlive-latex-extra \
|
||||
# latexmk \
|
||||
# cleaning up unused files
|
||||
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# copy python dependency wheels from python-build-stage
|
||||
COPY --from=python-build-stage --chown=app:app /app /app
|
||||
|
||||
COPY ./compose/local/docs/start /start-docs
|
||||
RUN sed -i 's/\r$//g' /start-docs
|
||||
RUN chmod +x /start-docs
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
WORKDIR /docs
|
||||
7
smoothschedule/compose/local/docs/start
Normal file
7
smoothschedule/compose/local/docs/start
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
exec make livehtml
|
||||
13
smoothschedule/compose/production/aws/Dockerfile
Normal file
13
smoothschedule/compose/production/aws/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM docker.io/amazon/aws-cli:2.32.3
|
||||
|
||||
# Clear entrypoint from the base image, otherwise it's always calling the aws CLI
|
||||
ENTRYPOINT []
|
||||
CMD ["/bin/bash"]
|
||||
|
||||
COPY ./compose/production/aws/maintenance /usr/local/bin/maintenance
|
||||
COPY ./compose/production/postgres/maintenance/_sourced /usr/local/bin/maintenance/_sourced
|
||||
|
||||
RUN chmod +x /usr/local/bin/maintenance/*
|
||||
|
||||
RUN mv /usr/local/bin/maintenance/* /usr/local/bin \
|
||||
&& rmdir /usr/local/bin/maintenance
|
||||
23
smoothschedule/compose/production/aws/maintenance/download
Normal file
23
smoothschedule/compose/production/aws/maintenance/download
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
|
||||
### Download a file from your Amazon S3 bucket to the postgres /backups folder
|
||||
###
|
||||
### Usage:
|
||||
### $ docker compose -f docker-compose.production.yml run --rm awscli <1>
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
working_dir="$(dirname ${0})"
|
||||
source "${working_dir}/_sourced/constants.sh"
|
||||
source "${working_dir}/_sourced/messages.sh"
|
||||
|
||||
export AWS_ACCESS_KEY_ID="${DJANGO_AWS_ACCESS_KEY_ID}"
|
||||
export AWS_SECRET_ACCESS_KEY="${DJANGO_AWS_SECRET_ACCESS_KEY}"
|
||||
export AWS_STORAGE_BUCKET_NAME="${DJANGO_AWS_STORAGE_BUCKET_NAME}"
|
||||
|
||||
|
||||
aws s3 cp s3://${AWS_STORAGE_BUCKET_NAME}${BACKUP_DIR_PATH}/${1} ${BACKUP_DIR_PATH}/${1}
|
||||
|
||||
message_success "Finished downloading ${1}."
|
||||
29
smoothschedule/compose/production/aws/maintenance/upload
Normal file
29
smoothschedule/compose/production/aws/maintenance/upload
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
|
||||
### Upload the /backups folder to Amazon S3
|
||||
###
|
||||
### Usage:
|
||||
### $ docker compose -f docker-compose.production.yml run --rm awscli upload
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
working_dir="$(dirname ${0})"
|
||||
source "${working_dir}/_sourced/constants.sh"
|
||||
source "${working_dir}/_sourced/messages.sh"
|
||||
|
||||
export AWS_ACCESS_KEY_ID="${DJANGO_AWS_ACCESS_KEY_ID}"
|
||||
export AWS_SECRET_ACCESS_KEY="${DJANGO_AWS_SECRET_ACCESS_KEY}"
|
||||
export AWS_STORAGE_BUCKET_NAME="${DJANGO_AWS_STORAGE_BUCKET_NAME}"
|
||||
|
||||
|
||||
message_info "Upload the backups directory to S3 bucket {$AWS_STORAGE_BUCKET_NAME}"
|
||||
|
||||
aws s3 cp ${BACKUP_DIR_PATH} s3://${AWS_STORAGE_BUCKET_NAME}${BACKUP_DIR_PATH} --recursive
|
||||
|
||||
message_info "Cleaning the directory ${BACKUP_DIR_PATH}"
|
||||
|
||||
rm -rf ${BACKUP_DIR_PATH}/*
|
||||
|
||||
message_success "Finished uploading and cleaning."
|
||||
92
smoothschedule/compose/production/django/Dockerfile
Normal file
92
smoothschedule/compose/production/django/Dockerfile
Normal file
@@ -0,0 +1,92 @@
|
||||
|
||||
# define an alias for the specific python version used in this file.
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS python-build-stage
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=0
|
||||
|
||||
ARG APP_HOME=/app
|
||||
|
||||
WORKDIR ${APP_HOME}
|
||||
|
||||
# Install apt packages
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
# dependencies for building Python packages
|
||||
build-essential \
|
||||
# psycopg dependencies
|
||||
libpq-dev
|
||||
|
||||
|
||||
# Requirements are installed here to ensure they will be cached.
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-install-project --no-dev
|
||||
COPY . ${APP_HOME}
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
# Python 'run' stage
|
||||
FROM python:3.13-slim-bookworm AS python-run-stage
|
||||
|
||||
ARG APP_HOME=/app
|
||||
|
||||
WORKDIR ${APP_HOME}
|
||||
|
||||
RUN addgroup --system django \
|
||||
&& adduser --system --ingroup django django
|
||||
|
||||
|
||||
# Install required system dependencies
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
# psycopg dependencies
|
||||
libpq-dev \
|
||||
# Translations dependencies
|
||||
gettext \
|
||||
# entrypoint
|
||||
wait-for-it \
|
||||
# cleaning up unused files
|
||||
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
COPY --chown=django:django ./compose/production/django/entrypoint /entrypoint
|
||||
RUN sed -i 's/\r$//g' /entrypoint
|
||||
RUN chmod +x /entrypoint
|
||||
|
||||
|
||||
COPY --chown=django:django ./compose/production/django/start /start
|
||||
RUN sed -i 's/\r$//g' /start
|
||||
RUN chmod +x /start
|
||||
COPY --chown=django:django ./compose/production/django/celery/worker/start /start-celeryworker
|
||||
RUN sed -i 's/\r$//g' /start-celeryworker
|
||||
RUN chmod +x /start-celeryworker
|
||||
|
||||
|
||||
COPY --chown=django:django ./compose/production/django/celery/beat/start /start-celerybeat
|
||||
RUN sed -i 's/\r$//g' /start-celerybeat
|
||||
RUN chmod +x /start-celerybeat
|
||||
|
||||
|
||||
COPY --chown=django:django ./compose/production/django/celery/flower/start /start-flower
|
||||
RUN sed -i 's/\r$//g' /start-flower
|
||||
RUN chmod +x /start-flower
|
||||
|
||||
# Copy the application from the builder
|
||||
COPY --from=python-build-stage --chown=django:django ${APP_HOME} ${APP_HOME}
|
||||
|
||||
# make django owner of the WORKDIR directory as well.
|
||||
RUN chown django:django ${APP_HOME}
|
||||
|
||||
# Place executables in the environment at the front of the path
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
USER django
|
||||
|
||||
RUN DATABASE_URL="" \
|
||||
DJANGO_SETTINGS_MODULE="config.settings.test" \
|
||||
python manage.py compilemessages
|
||||
|
||||
ENTRYPOINT ["/entrypoint"]
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
|
||||
exec celery -A config.celery_app beat -l INFO
|
||||
19
smoothschedule/compose/production/django/celery/flower/start
Normal file
19
smoothschedule/compose/production/django/celery/flower/start
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o nounset
|
||||
|
||||
|
||||
|
||||
until timeout 10 celery -A config.celery_app inspect ping; do
|
||||
>&2 echo "Celery workers not available"
|
||||
done
|
||||
|
||||
echo 'Starting flower'
|
||||
|
||||
|
||||
exec celery \
|
||||
-A config.celery_app \
|
||||
-b "${REDIS_URL}" \
|
||||
flower \
|
||||
--basic_auth="${CELERY_FLOWER_USER}:${CELERY_FLOWER_PASSWORD}"
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
|
||||
exec celery -A config.celery_app worker -l INFO
|
||||
17
smoothschedule/compose/production/django/entrypoint
Normal file
17
smoothschedule/compose/production/django/entrypoint
Normal file
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
if [ -z "${POSTGRES_USER}" ]; then
|
||||
base_postgres_image_default_user='postgres'
|
||||
export POSTGRES_USER="${base_postgres_image_default_user}"
|
||||
fi
|
||||
export DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
|
||||
|
||||
wait-for-it "${POSTGRES_HOST}:${POSTGRES_PORT}" -t 30
|
||||
|
||||
>&2 echo 'PostgreSQL is available'
|
||||
|
||||
exec "$@"
|
||||
10
smoothschedule/compose/production/django/start
Normal file
10
smoothschedule/compose/production/django/start
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
|
||||
python /app/manage.py collectstatic --noinput
|
||||
|
||||
exec gunicorn config.wsgi --bind 0.0.0.0:5000 --chdir=/app
|
||||
6
smoothschedule/compose/production/postgres/Dockerfile
Normal file
6
smoothschedule/compose/production/postgres/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM docker.io/postgres:14
|
||||
|
||||
COPY ./compose/production/postgres/maintenance /usr/local/bin/maintenance
|
||||
RUN chmod +x /usr/local/bin/maintenance/*
|
||||
RUN mv /usr/local/bin/maintenance/* /usr/local/bin \
|
||||
&& rmdir /usr/local/bin/maintenance
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
|
||||
BACKUP_DIR_PATH='/backups'
|
||||
BACKUP_FILE_PREFIX='backup'
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
|
||||
countdown() {
|
||||
declare desc="A simple countdown. Source: https://superuser.com/a/611582"
|
||||
local seconds="${1}"
|
||||
local d=$(($(date +%s) + "${seconds}"))
|
||||
while [ "$d" -ge `date +%s` ]; do
|
||||
echo -ne "$(date -u --date @$(($d - `date +%s`)) +%H:%M:%S)\r";
|
||||
sleep 0.1
|
||||
done
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
|
||||
message_newline() {
|
||||
echo
|
||||
}
|
||||
|
||||
message_debug()
|
||||
{
|
||||
echo -e "DEBUG: ${@}"
|
||||
}
|
||||
|
||||
message_welcome()
|
||||
{
|
||||
echo -e "\e[1m${@}\e[0m"
|
||||
}
|
||||
|
||||
message_warning()
|
||||
{
|
||||
echo -e "\e[33mWARNING\e[0m: ${@}"
|
||||
}
|
||||
|
||||
message_error()
|
||||
{
|
||||
echo -e "\e[31mERROR\e[0m: ${@}"
|
||||
}
|
||||
|
||||
message_info()
|
||||
{
|
||||
echo -e "\e[37mINFO\e[0m: ${@}"
|
||||
}
|
||||
|
||||
message_suggestion()
|
||||
{
|
||||
echo -e "\e[33mSUGGESTION\e[0m: ${@}"
|
||||
}
|
||||
|
||||
message_success()
|
||||
{
|
||||
echo -e "\e[32mSUCCESS\e[0m: ${@}"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
|
||||
yes_no() {
|
||||
declare desc="Prompt for confirmation. \$\"\{1\}\": confirmation message."
|
||||
local arg1="${1}"
|
||||
|
||||
local response=
|
||||
read -r -p "${arg1} (y/[n])? " response
|
||||
if [[ "${response}" =~ ^[Yy]$ ]]
|
||||
then
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
|
||||
### Create a database backup.
|
||||
###
|
||||
### Usage:
|
||||
### $ docker compose -f <environment>.yml (exec |run --rm) postgres backup
|
||||
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
|
||||
working_dir="$(dirname ${0})"
|
||||
source "${working_dir}/_sourced/constants.sh"
|
||||
source "${working_dir}/_sourced/messages.sh"
|
||||
|
||||
|
||||
message_welcome "Backing up the '${POSTGRES_DB}' database..."
|
||||
|
||||
|
||||
if [[ "${POSTGRES_USER}" == "postgres" ]]; then
|
||||
message_error "Backing up as 'postgres' user is not supported. Assign 'POSTGRES_USER' env with another one and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PGHOST="${POSTGRES_HOST}"
|
||||
export PGPORT="${POSTGRES_PORT}"
|
||||
export PGUSER="${POSTGRES_USER}"
|
||||
export PGPASSWORD="${POSTGRES_PASSWORD}"
|
||||
export PGDATABASE="${POSTGRES_DB}"
|
||||
|
||||
backup_filename="${BACKUP_FILE_PREFIX}_$(date +'%Y_%m_%dT%H_%M_%S').sql.gz"
|
||||
pg_dump | gzip > "${BACKUP_DIR_PATH}/${backup_filename}"
|
||||
|
||||
|
||||
message_success "'${POSTGRES_DB}' database backup '${backup_filename}' has been created and placed in '${BACKUP_DIR_PATH}'."
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
|
||||
### View backups.
|
||||
###
|
||||
### Usage:
|
||||
### $ docker compose -f <environment>.yml (exec |run --rm) postgres backups
|
||||
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
|
||||
working_dir="$(dirname ${0})"
|
||||
source "${working_dir}/_sourced/constants.sh"
|
||||
source "${working_dir}/_sourced/messages.sh"
|
||||
|
||||
|
||||
message_welcome "These are the backups you have got:"
|
||||
|
||||
ls -lht "${BACKUP_DIR_PATH}"
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
|
||||
### Restore database from a backup.
|
||||
###
|
||||
### Parameters:
|
||||
### <1> filename of an existing backup.
|
||||
###
|
||||
### Usage:
|
||||
### $ docker compose -f <environment>.yml (exec |run --rm) postgres restore <1>
|
||||
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
|
||||
working_dir="$(dirname ${0})"
|
||||
source "${working_dir}/_sourced/constants.sh"
|
||||
source "${working_dir}/_sourced/messages.sh"
|
||||
|
||||
|
||||
if [[ -z ${1+x} ]]; then
|
||||
message_error "Backup filename is not specified yet it is a required parameter. Make sure you provide one and try again."
|
||||
exit 1
|
||||
fi
|
||||
backup_filename="${BACKUP_DIR_PATH}/${1}"
|
||||
if [[ ! -f "${backup_filename}" ]]; then
|
||||
message_error "No backup with the specified filename found. Check out the 'backups' maintenance script output to see if there is one and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
message_welcome "Restoring the '${POSTGRES_DB}' database from the '${backup_filename}' backup..."
|
||||
|
||||
if [[ "${POSTGRES_USER}" == "postgres" ]]; then
|
||||
message_error "Restoring as 'postgres' user is not supported. Assign 'POSTGRES_USER' env with another one and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PGHOST="${POSTGRES_HOST}"
|
||||
export PGPORT="${POSTGRES_PORT}"
|
||||
export PGUSER="${POSTGRES_USER}"
|
||||
export PGPASSWORD="${POSTGRES_PASSWORD}"
|
||||
export PGDATABASE="${POSTGRES_DB}"
|
||||
|
||||
message_info "Dropping the database..."
|
||||
dropdb "${PGDATABASE}"
|
||||
|
||||
message_info "Creating a new database..."
|
||||
createdb --owner="${POSTGRES_USER}"
|
||||
|
||||
message_info "Applying the backup to the new database..."
|
||||
gunzip -c "${backup_filename}" | psql "${POSTGRES_DB}"
|
||||
|
||||
message_success "The '${POSTGRES_DB}' database has been restored from the '${backup_filename}' backup."
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
### Remove a database backup.
|
||||
###
|
||||
### Parameters:
|
||||
### <1> filename of a backup to remove.
|
||||
###
|
||||
### Usage:
|
||||
### $ docker-compose -f <environment>.yml (exec |run --rm) postgres rmbackup <1>
|
||||
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
|
||||
working_dir="$(dirname ${0})"
|
||||
source "${working_dir}/_sourced/constants.sh"
|
||||
source "${working_dir}/_sourced/messages.sh"
|
||||
|
||||
|
||||
if [[ -z ${1+x} ]]; then
|
||||
message_error "Backup filename is not specified yet it is a required parameter. Make sure you provide one and try again."
|
||||
exit 1
|
||||
fi
|
||||
backup_filename="${BACKUP_DIR_PATH}/${1}"
|
||||
if [[ ! -f "${backup_filename}" ]]; then
|
||||
message_error "No backup with the specified filename found. Check out the 'backups' maintenance script output to see if there is one and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
message_welcome "Removing the '${backup_filename}' backup file..."
|
||||
|
||||
rm -r "${backup_filename}"
|
||||
|
||||
message_success "The '${backup_filename}' database backup has been removed."
|
||||
5
smoothschedule/compose/production/traefik/Dockerfile
Normal file
5
smoothschedule/compose/production/traefik/Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
||||
FROM docker.io/traefik:3.6.2
|
||||
RUN mkdir -p /etc/traefik/acme \
|
||||
&& touch /etc/traefik/acme/acme.json \
|
||||
&& chmod 600 /etc/traefik/acme/acme.json
|
||||
COPY ./compose/production/traefik/traefik.yml /etc/traefik
|
||||
75
smoothschedule/compose/production/traefik/traefik.yml
Normal file
75
smoothschedule/compose/production/traefik/traefik.yml
Normal file
@@ -0,0 +1,75 @@
|
||||
log:
|
||||
level: INFO
|
||||
|
||||
entryPoints:
|
||||
web:
|
||||
# http
|
||||
address: ':80'
|
||||
http:
|
||||
# https://doc.traefik.io/traefik/routing/entrypoints/#entrypoint
|
||||
redirections:
|
||||
entryPoint:
|
||||
to: web-secure
|
||||
|
||||
web-secure:
|
||||
# https
|
||||
address: ':443'
|
||||
|
||||
flower:
|
||||
address: ':5555'
|
||||
|
||||
certificatesResolvers:
|
||||
letsencrypt:
|
||||
# https://doc.traefik.io/traefik/https/acme/#lets-encrypt
|
||||
acme:
|
||||
email: 'admin@smoothschedule.com'
|
||||
storage: /etc/traefik/acme/acme.json
|
||||
# https://doc.traefik.io/traefik/https/acme/#httpchallenge
|
||||
httpChallenge:
|
||||
entryPoint: web
|
||||
|
||||
http:
|
||||
routers:
|
||||
web-secure-router:
|
||||
rule: 'Host(`smoothschedule.com`) || Host(`www.smoothschedule.com`)'
|
||||
entryPoints:
|
||||
- web-secure
|
||||
middlewares:
|
||||
- csrf
|
||||
service: django
|
||||
tls:
|
||||
# https://doc.traefik.io/traefik/routing/routers/#certresolver
|
||||
certResolver: letsencrypt
|
||||
|
||||
flower-secure-router:
|
||||
rule: 'Host(`smoothschedule.com`)'
|
||||
entryPoints:
|
||||
- flower
|
||||
service: flower
|
||||
tls:
|
||||
# https://doc.traefik.io/traefik/master/routing/routers/#certresolver
|
||||
certResolver: letsencrypt
|
||||
|
||||
middlewares:
|
||||
csrf:
|
||||
# https://doc.traefik.io/traefik/master/middlewares/http/headers/#hostsproxyheaders
|
||||
# https://docs.djangoproject.com/en/dev/ref/csrf/#ajax
|
||||
headers:
|
||||
hostsProxyHeaders: ['X-CSRFToken']
|
||||
|
||||
services:
|
||||
django:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: http://django:5000
|
||||
|
||||
flower:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: http://flower:5555
|
||||
|
||||
providers:
|
||||
# https://doc.traefik.io/traefik/master/providers/file/
|
||||
file:
|
||||
filename: /etc/traefik/traefik.yml
|
||||
watch: true
|
||||
5
smoothschedule/config/__init__.py
Normal file
5
smoothschedule/config/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# This will make sure the app is always imported when
|
||||
# Django starts so that shared_task will use this app.
|
||||
from .celery_app import app as celery_app
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
13
smoothschedule/config/api_router.py
Normal file
13
smoothschedule/config/api_router.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.conf import settings
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from rest_framework.routers import SimpleRouter
|
||||
|
||||
from smoothschedule.users.api.views import UserViewSet
|
||||
|
||||
router = DefaultRouter() if settings.DEBUG else SimpleRouter()
|
||||
|
||||
router.register("users", UserViewSet)
|
||||
|
||||
|
||||
app_name = "api"
|
||||
urlpatterns = router.urls
|
||||
28
smoothschedule/config/celery_app.py
Normal file
28
smoothschedule/config/celery_app.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from celery.signals import setup_logging
|
||||
|
||||
# set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
||||
|
||||
app = Celery("smoothschedule")
|
||||
|
||||
# Using a string here means the worker doesn't have to serialize
|
||||
# the configuration object to child processes.
|
||||
# - namespace='CELERY' means all celery-related configuration keys
|
||||
# should have a `CELERY_` prefix.
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
|
||||
|
||||
@setup_logging.connect
|
||||
def config_loggers(*args, **kwargs):
|
||||
from logging.config import dictConfig # noqa: PLC0415
|
||||
|
||||
from django.conf import settings # noqa: PLC0415
|
||||
|
||||
dictConfig(settings.LOGGING)
|
||||
|
||||
|
||||
# Load task modules from all registered Django app configs.
|
||||
app.autodiscover_tasks()
|
||||
0
smoothschedule/config/settings/__init__.py
Normal file
0
smoothschedule/config/settings/__init__.py
Normal file
320
smoothschedule/config/settings/base.py
Normal file
320
smoothschedule/config/settings/base.py
Normal file
@@ -0,0 +1,320 @@
|
||||
# ruff: noqa: ERA001, E501
|
||||
"""Base settings to build other settings files upon."""
|
||||
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
|
||||
import environ
|
||||
|
||||
BASE_DIR = Path(__file__).resolve(strict=True).parent.parent.parent
|
||||
# smoothschedule/
|
||||
APPS_DIR = BASE_DIR / "smoothschedule"
|
||||
env = environ.Env()
|
||||
|
||||
READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=False)
|
||||
if READ_DOT_ENV_FILE:
|
||||
# OS environment variables take precedence over variables from .env
|
||||
env.read_env(str(BASE_DIR / ".env"))
|
||||
|
||||
# GENERAL
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#debug
|
||||
DEBUG = env.bool("DJANGO_DEBUG", False)
|
||||
# Local time zone. Choices are
|
||||
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
|
||||
# though not all of them may be available with every OS.
|
||||
# In Windows, this must be set to your system time zone.
|
||||
TIME_ZONE = "UTC"
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#language-code
|
||||
LANGUAGE_CODE = "en-us"
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#languages
|
||||
# from django.utils.translation import gettext_lazy as _
|
||||
# LANGUAGES = [
|
||||
# ('en', _('English')),
|
||||
# ('fr-fr', _('French')),
|
||||
# ('pt-br', _('Portuguese')),
|
||||
# ]
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#site-id
|
||||
SITE_ID = 1
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#use-i18n
|
||||
USE_I18N = True
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#use-tz
|
||||
USE_TZ = True
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#locale-paths
|
||||
LOCALE_PATHS = [str(BASE_DIR / "locale")]
|
||||
|
||||
# DATABASES
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#databases
|
||||
DATABASES = {"default": env.db("DATABASE_URL")}
|
||||
DATABASES["default"]["ATOMIC_REQUESTS"] = True
|
||||
# https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-DEFAULT_AUTO_FIELD
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# URLS
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf
|
||||
ROOT_URLCONF = "config.urls"
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
# APPS
|
||||
# ------------------------------------------------------------------------------
|
||||
DJANGO_APPS = [
|
||||
"django.forms",
|
||||
]
|
||||
THIRD_PARTY_APPS = [
|
||||
"crispy_forms",
|
||||
"crispy_bootstrap5",
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
"allauth.mfa",
|
||||
"allauth.socialaccount",
|
||||
"django_celery_beat",
|
||||
"rest_framework",
|
||||
"rest_framework.authtoken",
|
||||
"corsheaders",
|
||||
"drf_spectacular",
|
||||
]
|
||||
|
||||
LOCAL_APPS = [
|
||||
"smoothschedule.users",
|
||||
"core",
|
||||
"schedule",
|
||||
"payments",
|
||||
"platform_admin",
|
||||
# Your stuff: custom apps go here
|
||||
]
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
|
||||
# MIGRATIONS
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#migration-modules
|
||||
|
||||
# AUTHENTICATION
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#authentication-backends
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"allauth.account.auth_backends.AuthenticationBackend",
|
||||
]
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#auth-user-model
|
||||
AUTH_USER_MODEL = "users.User"
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#login-redirect-url
|
||||
LOGIN_REDIRECT_URL = "users:redirect"
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#login-url
|
||||
LOGIN_URL = "account_login"
|
||||
|
||||
|
||||
|
||||
|
||||
# MIDDLEWARE
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#middleware
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.middleware.locale.LocaleMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"allauth.account.middleware.AccountMiddleware",
|
||||
]
|
||||
|
||||
# STATIC
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#static-root
|
||||
STATIC_ROOT = str(BASE_DIR / "staticfiles")
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#static-url
|
||||
STATIC_URL = "/static/"
|
||||
STATICFILES_DIRS = [str(APPS_DIR / "static")]
|
||||
STATICFILES_FINDERS = [
|
||||
]
|
||||
|
||||
# MEDIA
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#media-root
|
||||
MEDIA_ROOT = str(APPS_DIR / "media")
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#media-url
|
||||
MEDIA_URL = "/media/"
|
||||
|
||||
# TEMPLATES
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#templates
|
||||
TEMPLATES = [
|
||||
{
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#dirs
|
||||
"DIRS": [str(APPS_DIR / "templates")],
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#app-dirs
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.template.context_processors.i18n",
|
||||
"django.template.context_processors.media",
|
||||
"django.template.context_processors.static",
|
||||
"django.template.context_processors.tz",
|
||||
"smoothschedule.users.context_processors.allauth_settings",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#form-renderer
|
||||
FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
|
||||
|
||||
# http://django-crispy-forms.readthedocs.io/en/latest/install.html#template-packs
|
||||
CRISPY_TEMPLATE_PACK = "bootstrap5"
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
|
||||
|
||||
# FIXTURES
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#fixture-dirs
|
||||
FIXTURE_DIRS = (str(APPS_DIR / "fixtures"),)
|
||||
|
||||
# SECURITY
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-httponly
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-httponly
|
||||
CSRF_COOKIE_HTTPONLY = True
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#x-frame-options
|
||||
X_FRAME_OPTIONS = "DENY"
|
||||
|
||||
# EMAIL
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
|
||||
EMAIL_BACKEND = env(
|
||||
"DJANGO_EMAIL_BACKEND",
|
||||
default="django.core.mail.backends.smtp.EmailBackend",
|
||||
)
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#email-timeout
|
||||
EMAIL_TIMEOUT = 5
|
||||
|
||||
# ADMIN
|
||||
# ------------------------------------------------------------------------------
|
||||
# Django Admin URL.
|
||||
ADMIN_URL = "admin/"
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#admins
|
||||
ADMINS = [("""Smooth Schedule Team""", "admin@smoothschedule.com")]
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#managers
|
||||
MANAGERS = ADMINS
|
||||
# https://cookiecutter-django.readthedocs.io/en/latest/settings.html#other-environment-settings
|
||||
# Force the `admin` sign in process to go through the `django-allauth` workflow
|
||||
DJANGO_ADMIN_FORCE_ALLAUTH = env.bool("DJANGO_ADMIN_FORCE_ALLAUTH", default=False)
|
||||
|
||||
# LOGGING
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#logging
|
||||
# See https://docs.djangoproject.com/en/dev/topics/logging for
|
||||
# more details on how to customize your logging configuration.
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"verbose": {
|
||||
"format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"level": "DEBUG",
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose",
|
||||
},
|
||||
},
|
||||
"root": {"level": "INFO", "handlers": ["console"]},
|
||||
}
|
||||
|
||||
REDIS_URL = env("REDIS_URL", default="redis://redis:6379/0")
|
||||
REDIS_SSL = REDIS_URL.startswith("rediss://")
|
||||
|
||||
# Celery
|
||||
# ------------------------------------------------------------------------------
|
||||
if USE_TZ:
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std:setting-timezone
|
||||
CELERY_TIMEZONE = TIME_ZONE
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std:setting-broker_url
|
||||
CELERY_BROKER_URL = REDIS_URL
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#redis-backend-use-ssl
|
||||
CELERY_BROKER_USE_SSL = {"ssl_cert_reqs": ssl.CERT_NONE} if REDIS_SSL else None
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std:setting-result_backend
|
||||
CELERY_RESULT_BACKEND = REDIS_URL
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#redis-backend-use-ssl
|
||||
CELERY_REDIS_BACKEND_USE_SSL = CELERY_BROKER_USE_SSL
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#result-extended
|
||||
CELERY_RESULT_EXTENDED = True
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#result-backend-always-retry
|
||||
# https://github.com/celery/celery/pull/6122
|
||||
CELERY_RESULT_BACKEND_ALWAYS_RETRY = True
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#result-backend-max-retries
|
||||
CELERY_RESULT_BACKEND_MAX_RETRIES = 10
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std:setting-accept_content
|
||||
CELERY_ACCEPT_CONTENT = ["json"]
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std:setting-task_serializer
|
||||
CELERY_TASK_SERIALIZER = "json"
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std:setting-result_serializer
|
||||
CELERY_RESULT_SERIALIZER = "json"
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-time-limit
|
||||
# TODO: set to whatever value is adequate in your circumstances
|
||||
CELERY_TASK_TIME_LIMIT = 5 * 60
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-soft-time-limit
|
||||
# TODO: set to whatever value is adequate in your circumstances
|
||||
CELERY_TASK_SOFT_TIME_LIMIT = 60
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-scheduler
|
||||
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#worker-send-task-events
|
||||
CELERY_WORKER_SEND_TASK_EVENTS = True
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std-setting-task_send_sent_event
|
||||
CELERY_TASK_SEND_SENT_EVENT = True
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#worker-hijack-root-logger
|
||||
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
||||
# django-allauth
|
||||
# ------------------------------------------------------------------------------
|
||||
ACCOUNT_ALLOW_REGISTRATION = env.bool("DJANGO_ACCOUNT_ALLOW_REGISTRATION", True)
|
||||
# https://docs.allauth.org/en/latest/account/configuration.html
|
||||
ACCOUNT_LOGIN_METHODS = {"username"}
|
||||
# https://docs.allauth.org/en/latest/account/configuration.html
|
||||
ACCOUNT_SIGNUP_FIELDS = ["email*", "username*", "password1*", "password2*"]
|
||||
# https://docs.allauth.org/en/latest/account/configuration.html
|
||||
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
|
||||
# https://docs.allauth.org/en/latest/account/configuration.html
|
||||
ACCOUNT_ADAPTER = "smoothschedule.users.adapters.AccountAdapter"
|
||||
# https://docs.allauth.org/en/latest/account/forms.html
|
||||
ACCOUNT_FORMS = {"signup": "smoothschedule.users.forms.UserSignupForm"}
|
||||
# https://docs.allauth.org/en/latest/socialaccount/configuration.html
|
||||
SOCIALACCOUNT_ADAPTER = "smoothschedule.users.adapters.SocialAccountAdapter"
|
||||
# https://docs.allauth.org/en/latest/socialaccount/configuration.html
|
||||
SOCIALACCOUNT_FORMS = {"signup": "smoothschedule.users.forms.UserSocialSignupForm"}
|
||||
|
||||
# django-rest-framework
|
||||
# -------------------------------------------------------------------------------
|
||||
# django-rest-framework - https://www.django-rest-framework.org/api-guide/settings/
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
"rest_framework.authentication.TokenAuthentication",
|
||||
),
|
||||
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
}
|
||||
|
||||
# django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup
|
||||
CORS_URLS_REGEX = r"^/api/.*$"
|
||||
|
||||
# By Default swagger ui is available only to admin user(s). You can change permission classes to change that
|
||||
# See more configuration options at https://drf-spectacular.readthedocs.io/en/latest/settings.html#settings
|
||||
SPECTACULAR_SETTINGS = {
|
||||
"TITLE": "Smooth Schedule API",
|
||||
"DESCRIPTION": "Documentation of API endpoints of Smooth Schedule",
|
||||
"VERSION": "1.0.0",
|
||||
"SERVE_PERMISSIONS": ["rest_framework.permissions.IsAdminUser"],
|
||||
"SCHEMA_PATH_PREFIX": "/api/",
|
||||
}
|
||||
# Your stuff...
|
||||
# ------------------------------------------------------------------------------
|
||||
102
smoothschedule/config/settings/local.py
Normal file
102
smoothschedule/config/settings/local.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# ruff: noqa: F403, F405
|
||||
from .multitenancy import * # noqa
|
||||
from .multitenancy import env, INSTALLED_APPS, MIDDLEWARE
|
||||
|
||||
# GENERAL
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#debug
|
||||
DEBUG = True
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
|
||||
SECRET_KEY = env(
|
||||
"DJANGO_SECRET_KEY",
|
||||
default="JETIHIJaLl2niIyj134Crg2S2dTURSzyXtd02XPicYcjaK5lJb1otLmNHqs6ZVs0",
|
||||
)
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
|
||||
ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1", ".lvh.me", "lvh.me"] # noqa: S104
|
||||
|
||||
# django-cors-headers
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://github.com/adamchainz/django-cors-headers#configuration
|
||||
# When using credentials, we can't use CORS_ALLOW_ALL_ORIGINS
|
||||
# Must specify allowed origins explicitly
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"http://lvh.me:5173",
|
||||
"http://lvh.me:5174",
|
||||
"http://platform.lvh.me:5173",
|
||||
"http://platform.lvh.me:5174",
|
||||
]
|
||||
CORS_ALLOWED_ORIGIN_REGEXES = [
|
||||
r"^http://.*\.lvh\.me:517[34]$", # Allow all subdomains on ports 5173/5174
|
||||
]
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
# CSRF
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-trusted-origins
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
"http://lvh.me:5173",
|
||||
"http://lvh.me:5174",
|
||||
"http://platform.lvh.me:5173",
|
||||
"http://platform.lvh.me:5174",
|
||||
"http://*.lvh.me:5173",
|
||||
"http://*.lvh.me:5174",
|
||||
]
|
||||
|
||||
# CACHES
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#caches
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
"LOCATION": "",
|
||||
},
|
||||
}
|
||||
|
||||
# EMAIL
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
|
||||
EMAIL_BACKEND = env(
|
||||
"DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend",
|
||||
)
|
||||
|
||||
# WhiteNoise
|
||||
# ------------------------------------------------------------------------------
|
||||
# http://whitenoise.evans.io/en/latest/django.html#using-whitenoise-in-development
|
||||
INSTALLED_APPS = ["whitenoise.runserver_nostatic", *INSTALLED_APPS]
|
||||
|
||||
|
||||
# django-debug-toolbar
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#prerequisites
|
||||
INSTALLED_APPS += ["debug_toolbar"]
|
||||
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#middleware
|
||||
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
|
||||
# https://django-debug-toolbar.readthedocs.io/en/latest/configuration.html#debug-toolbar-config
|
||||
DEBUG_TOOLBAR_CONFIG = {
|
||||
"DISABLE_PANELS": [
|
||||
"debug_toolbar.panels.redirects.RedirectsPanel",
|
||||
# Disable profiling panel due to an issue with Python 3.12+:
|
||||
# https://github.com/jazzband/django-debug-toolbar/issues/1875
|
||||
"debug_toolbar.panels.profiling.ProfilingPanel",
|
||||
],
|
||||
"SHOW_TEMPLATE_CONTEXT": True,
|
||||
}
|
||||
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#internal-ips
|
||||
INTERNAL_IPS = ["127.0.0.1", "10.0.2.2"]
|
||||
if env("USE_DOCKER") == "yes":
|
||||
import socket
|
||||
|
||||
hostname, _, ips = socket.gethostbyname_ex(socket.gethostname())
|
||||
INTERNAL_IPS += [".".join([*ip.split(".")[:-1], "1"]) for ip in ips]
|
||||
|
||||
# django-extensions
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration
|
||||
INSTALLED_APPS += ["django_extensions"]
|
||||
# Celery
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-eager-propagates
|
||||
CELERY_TASK_EAGER_PROPAGATES = True
|
||||
# Your stuff...
|
||||
# ------------------------------------------------------------------------------
|
||||
213
smoothschedule/config/settings/multitenancy.py
Normal file
213
smoothschedule/config/settings/multitenancy.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Smooth Schedule - Multi-Tenancy Settings Override
|
||||
This file extends base.py and adds django-tenants configuration
|
||||
"""
|
||||
|
||||
from .base import * # noqa
|
||||
from .base import INSTALLED_APPS, MIDDLEWARE, DATABASES, LOGGING, env
|
||||
|
||||
# =============================================================================
|
||||
# MULTI-TENANCY CONFIGURATION (django-tenants)
|
||||
# =============================================================================
|
||||
|
||||
# Shared apps - Available to all tenants (stored in 'public' schema)
|
||||
SHARED_APPS = [
|
||||
'django_tenants', # Must be first
|
||||
'core', # Core models (Tenant, Domain, PermissionGrant)
|
||||
|
||||
# Django built-ins (must be in shared
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.sites',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.admin',
|
||||
|
||||
# Users app (shared across tenants)
|
||||
'smoothschedule.users',
|
||||
|
||||
# Third-party apps that should be shared
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
'corsheaders',
|
||||
'drf_spectacular',
|
||||
'allauth',
|
||||
'allauth.account',
|
||||
'allauth.mfa',
|
||||
'allauth.socialaccount',
|
||||
'django_celery_beat',
|
||||
'hijack',
|
||||
'hijack.contrib.admin',
|
||||
'crispy_forms',
|
||||
'crispy_bootstrap5',
|
||||
]
|
||||
|
||||
# Tenant-specific apps - Each tenant gets isolated data in their own schema
|
||||
TENANT_APPS = [
|
||||
'django.contrib.contenttypes', # Needed for tenant schemas
|
||||
'schedule', # Resource scheduling with configurable concurrency
|
||||
'payments', # Stripe Connect payments bridge
|
||||
'communication', # Twilio masked communications
|
||||
|
||||
# Add your tenant-scoped business logic apps here:
|
||||
# 'appointments',
|
||||
# 'customers',
|
||||
# 'analytics',
|
||||
]
|
||||
|
||||
# Override INSTALLED_APPS to include all unique apps
|
||||
INSTALLED_APPS = list(dict.fromkeys(SHARED_APPS + TENANT_APPS))
|
||||
|
||||
# Tenant model configuration
|
||||
TENANT_MODEL = "core.Tenant"
|
||||
TENANT_DOMAIN_MODEL = "core.Domain"
|
||||
PUBLIC_SCHEMA_NAME = 'public'
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE CONFIGURATION (Multi-schema)
|
||||
# =============================================================================
|
||||
|
||||
# Override database engine for django-tenants
|
||||
DATABASES['default']['ENGINE'] ='django_tenants.postgresql_backend'
|
||||
|
||||
# Database routers for tenant isolation
|
||||
DATABASE_ROUTERS = [
|
||||
'django_tenants.routers.TenantSyncRouter',
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# MIDDLEWARE CONFIGURATION
|
||||
# =============================================================================
|
||||
# CRITICAL: Order matters!
|
||||
|
||||
MIDDLEWARE = [
|
||||
# 1. MUST BE FIRST: Tenant resolution
|
||||
'django_tenants.middleware.main.TenantMainMiddleware',
|
||||
|
||||
# 2. Security middleware
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware', # Moved up for better CORS handling
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
|
||||
# 3. Session & CSRF
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
|
||||
# 4. Authentication
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
|
||||
# 5. Hijack (Masquerading) - MUST come before our audit middleware
|
||||
'hijack.middleware.HijackUserMiddleware',
|
||||
|
||||
# 6. MASQUERADE AUDIT - MUST come AFTER HijackUserMiddleware
|
||||
'core.middleware.MasqueradeAuditMiddleware',
|
||||
|
||||
# 7. Messages, Clickjacking, and Allauth
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'allauth.account.middleware.AccountMiddleware',
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# TEMPLATE CONTEXT PROCESSORS (for admin)
|
||||
# =============================================================================
|
||||
|
||||
# Ensure required context processors are present
|
||||
from .base import TEMPLATES # noqa
|
||||
if TEMPLATES and len(TEMPLATES) > 0:
|
||||
context_processors = TEMPLATES[0]['OPTIONS']['context_processors']
|
||||
if 'django.contrib.auth.context_processors.auth' not in context_processors:
|
||||
context_processors.insert(0, 'django.contrib.auth.context_processors.auth')
|
||||
if 'django.contrib.messages.context_processors.messages' not in context_processors:
|
||||
context_processors.append('django.contrib.messages.context_processors.messages')
|
||||
|
||||
# =============================================================================
|
||||
# PASSWORD VALIDATION
|
||||
# =============================================================================
|
||||
|
||||
# Password hashers
|
||||
PASSWORD_HASHERS = [
|
||||
'django.contrib.auth.hashers.Argon2PasswordHasher',
|
||||
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
|
||||
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
|
||||
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
|
||||
]
|
||||
|
||||
# Password validators
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
'OPTIONS': {
|
||||
'min_length': 10,
|
||||
}
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# HIJACK (MASQUERADING) CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
HIJACK_AUTHORIZATION_CHECK = 'core.permissions.can_hijack'
|
||||
HIJACK_DISPLAY_ADMIN_BUTTON = True
|
||||
HIJACK_USE_BOOTSTRAP = True
|
||||
HIJACK_ALLOW_GET_REQUESTS = False # Security: require POST
|
||||
HIJACK_INSERT_BEFORE = True
|
||||
|
||||
# =============================================================================
|
||||
# ENHANCED LOGGING FOR SECURITY AUDIT
|
||||
# =============================================================================
|
||||
|
||||
# Extend existing logging configuration
|
||||
LOGGING['formatters']['json'] = {
|
||||
'()': 'django.utils.log.ServerFormatter',
|
||||
'format': '[{server_time}] {message}',
|
||||
'style': '{',
|
||||
}
|
||||
|
||||
LOGGING['handlers']['security_file'] = {
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': str(BASE_DIR / 'logs' / 'security.log'),
|
||||
'maxBytes': 1024 * 1024 * 10, # 10 MB
|
||||
'backupCount': 5,
|
||||
'formatter': 'json',
|
||||
}
|
||||
|
||||
LOGGING['handlers']['masquerade_file'] = {
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': str(BASE_DIR / 'logs' / 'masquerade.log'),
|
||||
'maxBytes': 1024 * 1024 * 10, # 10 MB
|
||||
'backupCount': 5,
|
||||
'formatter': 'json',
|
||||
}
|
||||
|
||||
# Ensure 'loggers' key exists in LOGGING
|
||||
if 'loggers' not in LOGGING:
|
||||
LOGGING['loggers'] = {}
|
||||
|
||||
LOGGING['loggers']['smoothschedule.security'] = {
|
||||
'handlers': ['console', 'security_file'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
}
|
||||
|
||||
LOGGING['loggers']['smoothschedule.security.masquerade'] = {
|
||||
'handlers': ['console', 'masquerade_file'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
}
|
||||
|
||||
# Create logs directory if it doesn't exist
|
||||
import os
|
||||
os.makedirs(BASE_DIR / 'logs', exist_ok=True)
|
||||
218
smoothschedule/config/settings/production.py
Normal file
218
smoothschedule/config/settings/production.py
Normal file
@@ -0,0 +1,218 @@
|
||||
# ruff: noqa: E501
|
||||
import logging
|
||||
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
from sentry_sdk.integrations.redis import RedisIntegration
|
||||
|
||||
from .base import * # noqa: F403
|
||||
from .base import DATABASES
|
||||
from .base import INSTALLED_APPS
|
||||
from .base import REDIS_URL
|
||||
from .base import SPECTACULAR_SETTINGS
|
||||
from .base import env
|
||||
|
||||
# GENERAL
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
|
||||
SECRET_KEY = env("DJANGO_SECRET_KEY")
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
|
||||
ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["smoothschedule.com"])
|
||||
|
||||
# DATABASES
|
||||
# ------------------------------------------------------------------------------
|
||||
DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=60)
|
||||
|
||||
# CACHES
|
||||
# ------------------------------------------------------------------------------
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": REDIS_URL,
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
# Mimicking memcache behavior.
|
||||
# https://github.com/jazzband/django-redis#memcached-exceptions-behavior
|
||||
"IGNORE_EXCEPTIONS": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# SECURITY
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-proxy-ssl-header
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-ssl-redirect
|
||||
SECURE_SSL_REDIRECT = env.bool("DJANGO_SECURE_SSL_REDIRECT", default=True)
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-secure
|
||||
SESSION_COOKIE_SECURE = True
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-name
|
||||
SESSION_COOKIE_NAME = "__Secure-sessionid"
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-secure
|
||||
CSRF_COOKIE_SECURE = True
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-name
|
||||
CSRF_COOKIE_NAME = "__Secure-csrftoken"
|
||||
# https://docs.djangoproject.com/en/dev/topics/security/#ssl-https
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-seconds
|
||||
# TODO: set this to 60 seconds first and then to 518400 once you prove the former works
|
||||
SECURE_HSTS_SECONDS = 60
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-include-subdomains
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool(
|
||||
"DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS",
|
||||
default=True,
|
||||
)
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-preload
|
||||
SECURE_HSTS_PRELOAD = env.bool("DJANGO_SECURE_HSTS_PRELOAD", default=True)
|
||||
# https://docs.djangoproject.com/en/dev/ref/middleware/#x-content-type-options-nosniff
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = env.bool(
|
||||
"DJANGO_SECURE_CONTENT_TYPE_NOSNIFF",
|
||||
default=True,
|
||||
)
|
||||
|
||||
|
||||
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
|
||||
AWS_ACCESS_KEY_ID = env("DJANGO_AWS_ACCESS_KEY_ID")
|
||||
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
|
||||
AWS_SECRET_ACCESS_KEY = env("DJANGO_AWS_SECRET_ACCESS_KEY")
|
||||
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
|
||||
AWS_STORAGE_BUCKET_NAME = env("DJANGO_AWS_STORAGE_BUCKET_NAME")
|
||||
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
|
||||
AWS_QUERYSTRING_AUTH = False
|
||||
# DO NOT change these unless you know what you're doing.
|
||||
_AWS_EXPIRY = 60 * 60 * 24 * 7
|
||||
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
|
||||
AWS_S3_OBJECT_PARAMETERS = {
|
||||
"CacheControl": f"max-age={_AWS_EXPIRY}, s-maxage={_AWS_EXPIRY}, must-revalidate",
|
||||
}
|
||||
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
|
||||
AWS_S3_MAX_MEMORY_SIZE = env.int(
|
||||
"DJANGO_AWS_S3_MAX_MEMORY_SIZE",
|
||||
default=100_000_000, # 100MB
|
||||
)
|
||||
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
|
||||
AWS_S3_REGION_NAME = env("DJANGO_AWS_S3_REGION_NAME", default=None)
|
||||
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#cloudfront
|
||||
AWS_S3_CUSTOM_DOMAIN = env("DJANGO_AWS_S3_CUSTOM_DOMAIN", default=None)
|
||||
aws_s3_domain = AWS_S3_CUSTOM_DOMAIN or f"{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com"
|
||||
# STATIC & MEDIA
|
||||
# ------------------------
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "storages.backends.s3.S3Storage",
|
||||
"OPTIONS": {
|
||||
"location": "media",
|
||||
"file_overwrite": False,
|
||||
},
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
MEDIA_URL = f"https://{aws_s3_domain}/media/"
|
||||
|
||||
# EMAIL
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email
|
||||
DEFAULT_FROM_EMAIL = env(
|
||||
"DJANGO_DEFAULT_FROM_EMAIL",
|
||||
default="Smooth Schedule <noreply@smoothschedule.com>",
|
||||
)
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#server-email
|
||||
SERVER_EMAIL = env("DJANGO_SERVER_EMAIL", default=DEFAULT_FROM_EMAIL)
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#email-subject-prefix
|
||||
EMAIL_SUBJECT_PREFIX = env(
|
||||
"DJANGO_EMAIL_SUBJECT_PREFIX",
|
||||
default="[Smooth Schedule] ",
|
||||
)
|
||||
ACCOUNT_EMAIL_SUBJECT_PREFIX = EMAIL_SUBJECT_PREFIX
|
||||
|
||||
# ADMIN
|
||||
# ------------------------------------------------------------------------------
|
||||
# Django Admin URL regex.
|
||||
ADMIN_URL = env("DJANGO_ADMIN_URL")
|
||||
|
||||
# Anymail
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://anymail.readthedocs.io/en/stable/installation/#installing-anymail
|
||||
INSTALLED_APPS += ["anymail"]
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
|
||||
# https://anymail.readthedocs.io/en/stable/installation/#anymail-settings-reference
|
||||
# https://anymail.readthedocs.io/en/stable/esps/mailgun/
|
||||
EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend"
|
||||
ANYMAIL = {
|
||||
"MAILGUN_API_KEY": env("MAILGUN_API_KEY"),
|
||||
"MAILGUN_SENDER_DOMAIN": env("MAILGUN_DOMAIN"),
|
||||
"MAILGUN_API_URL": env("MAILGUN_API_URL", default="https://api.mailgun.net/v3"),
|
||||
}
|
||||
|
||||
|
||||
# LOGGING
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#logging
|
||||
# See https://docs.djangoproject.com/en/dev/topics/logging for
|
||||
# more details on how to customize your logging configuration.
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": True,
|
||||
"formatters": {
|
||||
"verbose": {
|
||||
"format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"level": "DEBUG",
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose",
|
||||
},
|
||||
},
|
||||
"root": {"level": "INFO", "handlers": ["console"]},
|
||||
"loggers": {
|
||||
"django.db.backends": {
|
||||
"level": "ERROR",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
# Errors logged by the SDK itself
|
||||
"sentry_sdk": {"level": "ERROR", "handlers": ["console"], "propagate": False},
|
||||
"django.security.DisallowedHost": {
|
||||
"level": "ERROR",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Sentry
|
||||
# ------------------------------------------------------------------------------
|
||||
SENTRY_DSN = env("SENTRY_DSN")
|
||||
SENTRY_LOG_LEVEL = env.int("DJANGO_SENTRY_LOG_LEVEL", logging.INFO)
|
||||
|
||||
sentry_logging = LoggingIntegration(
|
||||
level=SENTRY_LOG_LEVEL, # Capture info and above as breadcrumbs
|
||||
event_level=logging.ERROR, # Send errors as events
|
||||
)
|
||||
integrations = [
|
||||
sentry_logging,
|
||||
DjangoIntegration(),
|
||||
CeleryIntegration(),
|
||||
RedisIntegration(),
|
||||
]
|
||||
sentry_sdk.init(
|
||||
dsn=SENTRY_DSN,
|
||||
integrations=integrations,
|
||||
environment=env("SENTRY_ENVIRONMENT", default="production"),
|
||||
traces_sample_rate=env.float("SENTRY_TRACES_SAMPLE_RATE", default=0.0),
|
||||
)
|
||||
|
||||
# django-rest-framework
|
||||
# -------------------------------------------------------------------------------
|
||||
# Tools that generate code samples can use SERVERS to point to the correct domain
|
||||
SPECTACULAR_SETTINGS["SERVERS"] = [
|
||||
{"url": "https://smoothschedule.com", "description": "Production server"},
|
||||
]
|
||||
# Your stuff...
|
||||
# ------------------------------------------------------------------------------
|
||||
37
smoothschedule/config/settings/test.py
Normal file
37
smoothschedule/config/settings/test.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
With these settings, tests run faster.
|
||||
"""
|
||||
|
||||
from .base import * # noqa: F403
|
||||
from .base import TEMPLATES
|
||||
from .base import env
|
||||
|
||||
# GENERAL
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
|
||||
SECRET_KEY = env(
|
||||
"DJANGO_SECRET_KEY",
|
||||
default="aESXwQWpusSVR5SFLuhCcVXO5slQ2pUljzw0SGLFI109HqeidikhS7dZMy1GC394",
|
||||
)
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#test-runner
|
||||
TEST_RUNNER = "django.test.runner.DiscoverRunner"
|
||||
|
||||
# PASSWORDS
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers
|
||||
|
||||
# EMAIL
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
|
||||
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
|
||||
|
||||
# DEBUGGING FOR TEMPLATES
|
||||
# ------------------------------------------------------------------------------
|
||||
TEMPLATES[0]["OPTIONS"]["debug"] = True # type: ignore[index]
|
||||
|
||||
# MEDIA
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#media-url
|
||||
MEDIA_URL = "http://media.testserver/"
|
||||
# Your stuff...
|
||||
# ------------------------------------------------------------------------------
|
||||
75
smoothschedule/config/urls.py
Normal file
75
smoothschedule/config/urls.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import include
|
||||
from django.urls import path
|
||||
from django.views import defaults as default_views
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.generic import TemplateView
|
||||
from drf_spectacular.views import SpectacularAPIView
|
||||
from drf_spectacular.views import SpectacularSwaggerView
|
||||
from rest_framework.authtoken.views import obtain_auth_token
|
||||
|
||||
from smoothschedule.users.api_views import current_user_view
|
||||
from schedule.api_views import current_business_view
|
||||
|
||||
urlpatterns = [
|
||||
# Django Admin, use {% url 'admin:index' %}
|
||||
path(settings.ADMIN_URL, admin.site.urls),
|
||||
# User management
|
||||
path("users/", include("smoothschedule.users.urls", namespace="users")),
|
||||
path("accounts/", include("allauth.urls")),
|
||||
# Your stuff: custom urls includes go here
|
||||
# ...
|
||||
# Media files
|
||||
*static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT),
|
||||
]
|
||||
|
||||
# API URLS
|
||||
urlpatterns += [
|
||||
# Schedule API
|
||||
path("api/", include("schedule.urls")),
|
||||
# Payments API
|
||||
path("api/payments/", include("payments.urls")),
|
||||
# Platform API
|
||||
path("api/platform/", include("platform_admin.urls", namespace="platform")),
|
||||
# Auth API
|
||||
path("api/auth-token/", csrf_exempt(obtain_auth_token), name="obtain_auth_token"),
|
||||
path("api/auth/me/", current_user_view, name="current_user"),
|
||||
# Business API
|
||||
path("api/business/current/", current_business_view, name="current_business"),
|
||||
# API Docs
|
||||
path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"),
|
||||
path(
|
||||
"api/docs/",
|
||||
SpectacularSwaggerView.as_view(url_name="api-schema"),
|
||||
name="api-docs",
|
||||
),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += [
|
||||
path(
|
||||
"400/",
|
||||
default_views.bad_request,
|
||||
kwargs={"exception": Exception("Bad Request!")},
|
||||
),
|
||||
path(
|
||||
"403/",
|
||||
default_views.permission_denied,
|
||||
kwargs={"exception": Exception("Permission Denied")},
|
||||
),
|
||||
path(
|
||||
"404/",
|
||||
default_views.page_not_found,
|
||||
kwargs={"exception": Exception("Page not Found")},
|
||||
),
|
||||
path("500/", default_views.server_error),
|
||||
]
|
||||
if "debug_toolbar" in settings.INSTALLED_APPS:
|
||||
import debug_toolbar
|
||||
|
||||
urlpatterns = [
|
||||
path("__debug__/", include(debug_toolbar.urls)),
|
||||
*urlpatterns,
|
||||
]
|
||||
32
smoothschedule/config/wsgi.py
Normal file
32
smoothschedule/config/wsgi.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
WSGI config for Smooth Schedule project.
|
||||
|
||||
This module contains the WSGI application used by Django's development server
|
||||
and any production WSGI deployments. It should expose a module-level variable
|
||||
named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover
|
||||
this application via the ``WSGI_APPLICATION`` setting.
|
||||
|
||||
Usually you will have the standard Django WSGI application here, but it also
|
||||
might make sense to replace the whole Django WSGI application with a custom one
|
||||
that later delegates to the Django one. For example, you could introduce WSGI
|
||||
middleware here, or combine a Django application with an application of another
|
||||
framework.
|
||||
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
# This allows easy placement of apps within the interior
|
||||
# smoothschedule directory.
|
||||
BASE_DIR = Path(__file__).resolve(strict=True).parent.parent
|
||||
sys.path.append(str(BASE_DIR / "smoothschedule"))
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
|
||||
|
||||
# This application object is used by any WSGI server configured to use this
|
||||
# file. This includes Django's development server, if the WSGI_APPLICATION
|
||||
# setting points here.
|
||||
application = get_wsgi_application()
|
||||
2
smoothschedule/core/__init__.py
Normal file
2
smoothschedule/core/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Core app initialization
|
||||
default_app_config = 'core.apps.CoreConfig'
|
||||
237
smoothschedule/core/admin.py
Normal file
237
smoothschedule/core/admin.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Smooth Schedule Core App Admin Configuration
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django_tenants.admin import TenantAdminMixin
|
||||
from .models import Tenant, Domain, PermissionGrant
|
||||
|
||||
|
||||
@admin.register(Tenant)
|
||||
class TenantAdmin(TenantAdminMixin, admin.ModelAdmin):
|
||||
"""
|
||||
Admin interface for Tenant management.
|
||||
"""
|
||||
list_display = [
|
||||
'name',
|
||||
'schema_name',
|
||||
'subscription_tier',
|
||||
'is_active',
|
||||
'created_on',
|
||||
'user_count',
|
||||
'domain_list',
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'is_active',
|
||||
'subscription_tier',
|
||||
'created_on',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'name',
|
||||
'schema_name',
|
||||
'contact_email',
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'schema_name',
|
||||
'created_on',
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
('Basic Information', {
|
||||
'fields': ('name', 'schema_name', 'created_on')
|
||||
}),
|
||||
('Subscription', {
|
||||
'fields': ('subscription_tier', 'is_active', 'max_users', 'max_resources')
|
||||
}),
|
||||
('Contact', {
|
||||
'fields': ('contact_email', 'phone')
|
||||
}),
|
||||
)
|
||||
|
||||
def user_count(self, obj):
|
||||
"""Display count of users in this tenant"""
|
||||
count = obj.users.count()
|
||||
return format_html(
|
||||
'<span style="color: {};">{}</span>',
|
||||
'green' if count < obj.max_users else 'red',
|
||||
count
|
||||
)
|
||||
user_count.short_description = 'Users'
|
||||
|
||||
def domain_list(self, obj):
|
||||
"""Display list of domains for this tenant"""
|
||||
domains = obj.domain_set.all()
|
||||
if not domains:
|
||||
return '-'
|
||||
|
||||
domain_links = []
|
||||
for domain in domains:
|
||||
url = reverse('admin:core_domain_change', args=[domain.pk])
|
||||
domain_links.append(f'<a href="{url}">{domain.domain}</a>')
|
||||
|
||||
return format_html(' | '.join(domain_links))
|
||||
domain_list.short_description = 'Domains'
|
||||
|
||||
|
||||
@admin.register(Domain)
|
||||
class DomainAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin interface for Domain management.
|
||||
"""
|
||||
list_display = [
|
||||
'domain',
|
||||
'tenant',
|
||||
'is_primary',
|
||||
'is_custom_domain',
|
||||
'verified_status',
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'is_primary',
|
||||
'is_custom_domain',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'domain',
|
||||
'tenant__name',
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'verified_at',
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
('Domain Information', {
|
||||
'fields': ('domain', 'tenant', 'is_primary')
|
||||
}),
|
||||
('Custom Domain Settings', {
|
||||
'fields': (
|
||||
'is_custom_domain',
|
||||
'route53_zone_id',
|
||||
'route53_record_set_id',
|
||||
'ssl_certificate_arn',
|
||||
'verified_at',
|
||||
),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
def verified_status(self, obj):
|
||||
"""Display verification status with color coding"""
|
||||
if obj.is_verified():
|
||||
return format_html(
|
||||
'<span style="color: green;">✓ Verified</span>'
|
||||
)
|
||||
else:
|
||||
return format_html(
|
||||
'<span style="color: orange;">⚠ Pending</span>'
|
||||
)
|
||||
verified_status.short_description = 'Status'
|
||||
|
||||
|
||||
@admin.register(PermissionGrant)
|
||||
class PermissionGrantAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin interface for Permission Grant management.
|
||||
"""
|
||||
list_display = [
|
||||
'id',
|
||||
'grantor',
|
||||
'grantee',
|
||||
'action',
|
||||
'granted_at',
|
||||
'expires_at',
|
||||
'status',
|
||||
'time_left',
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'action',
|
||||
'granted_at',
|
||||
'expires_at',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'grantor__email',
|
||||
'grantee__email',
|
||||
'action',
|
||||
'reason',
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'granted_at',
|
||||
'grantor',
|
||||
'grantee',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
('Grant Information', {
|
||||
'fields': ('grantor', 'grantee', 'action', 'reason')
|
||||
}),
|
||||
('Timing', {
|
||||
'fields': ('granted_at', 'expires_at', 'revoked_at')
|
||||
}),
|
||||
('Audit Trail', {
|
||||
'fields': ('ip_address', 'user_agent'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
def status(self, obj):
|
||||
"""Display status with color coding"""
|
||||
if obj.revoked_at:
|
||||
return format_html(
|
||||
'<span style="color: red;">✗ Revoked</span>'
|
||||
)
|
||||
elif obj.is_active():
|
||||
return format_html(
|
||||
'<span style="color: green;">✓ Active</span>'
|
||||
)
|
||||
else:
|
||||
return format_html(
|
||||
'<span style="color: gray;">⊘ Expired</span>'
|
||||
)
|
||||
status.short_description = 'Status'
|
||||
|
||||
def time_left(self, obj):
|
||||
"""Display remaining time"""
|
||||
remaining = obj.time_remaining()
|
||||
if remaining is None:
|
||||
return '-'
|
||||
|
||||
minutes = int(remaining.total_seconds() / 60)
|
||||
if minutes < 5:
|
||||
color = 'red'
|
||||
elif minutes < 15:
|
||||
color = 'orange'
|
||||
else:
|
||||
color = 'green'
|
||||
|
||||
return format_html(
|
||||
'<span style="color: {};">{} min</span>',
|
||||
color,
|
||||
minutes
|
||||
)
|
||||
time_left.short_description = 'Time Left'
|
||||
|
||||
actions = ['revoke_grants']
|
||||
|
||||
def revoke_grants(self, request, queryset):
|
||||
"""Admin action to revoke permission grants"""
|
||||
count = 0
|
||||
for grant in queryset:
|
||||
if grant.is_active():
|
||||
grant.revoke()
|
||||
count += 1
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f'Successfully revoked {count} permission grant(s).'
|
||||
)
|
||||
revoke_grants.short_description = 'Revoke selected grants'
|
||||
18
smoothschedule/core/apps.py
Normal file
18
smoothschedule/core/apps.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Smooth Schedule Core App Configuration
|
||||
"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'core'
|
||||
verbose_name = 'Smooth Schedule Core'
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Import signals and perform app initialization.
|
||||
"""
|
||||
# Import signals here when needed
|
||||
# from . import signals
|
||||
pass
|
||||
241
smoothschedule/core/middleware.py
Normal file
241
smoothschedule/core/middleware.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
Smooth Schedule Masquerade Audit Middleware
|
||||
Captures and logs masquerading activity for compliance and security auditing
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.utils import timezone
|
||||
|
||||
logger = logging.getLogger('smoothschedule.security.masquerade')
|
||||
|
||||
|
||||
class MasqueradeAuditMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
Audit middleware that tracks masquerading (hijack) activity.
|
||||
|
||||
CRITICAL: This middleware MUST be placed AFTER HijackUserMiddleware in settings.
|
||||
|
||||
Responsibilities:
|
||||
1. Detect when a user is being masqueraded (hijacked)
|
||||
2. Extract the original admin user from session
|
||||
3. Enrich request object with audit context
|
||||
4. Log structured audit events
|
||||
|
||||
The enriched request will have:
|
||||
- request.actual_user: The original admin (if masquerading)
|
||||
- request.is_masquerading: Boolean flag
|
||||
- request.masquerade_metadata: Dict with audit info
|
||||
|
||||
Example log output:
|
||||
{
|
||||
"timestamp": "2024-01-15T10:30:00Z",
|
||||
"action": "API_CALL",
|
||||
"endpoint": "/api/customers/",
|
||||
"method": "GET",
|
||||
"apparent_user": "customer@example.com",
|
||||
"actual_user": "support@chronoflow.com",
|
||||
"masquerading": true,
|
||||
"ip_address": "192.168.1.1",
|
||||
"user_agent": "Mozilla/5.0..."
|
||||
}
|
||||
"""
|
||||
|
||||
def process_request(self, request):
|
||||
"""
|
||||
Process incoming request to detect and log masquerading.
|
||||
"""
|
||||
# Initialize masquerade flags
|
||||
request.is_masquerading = False
|
||||
request.actual_user = None
|
||||
request.masquerade_metadata = {}
|
||||
|
||||
# Check if user is authenticated
|
||||
if not hasattr(request, 'user') or not request.user.is_authenticated:
|
||||
return None
|
||||
|
||||
# Check for hijack session data
|
||||
# django-hijack stores the original user ID in session['hijack_history']
|
||||
hijack_history = request.session.get('hijack_history', [])
|
||||
|
||||
if hijack_history and len(hijack_history) > 0:
|
||||
# User is being masqueraded
|
||||
request.is_masquerading = True
|
||||
|
||||
# Extract original admin user ID from hijack history
|
||||
# hijack_history is a list of user IDs: [original_user_id, ...]
|
||||
original_user_id = hijack_history[0]
|
||||
|
||||
# Load the actual admin user
|
||||
from users.models import User
|
||||
try:
|
||||
actual_user = User.objects.get(pk=original_user_id)
|
||||
request.actual_user = actual_user
|
||||
|
||||
# Build metadata for audit logging
|
||||
request.masquerade_metadata = {
|
||||
'apparent_user_id': request.user.id,
|
||||
'apparent_user_email': request.user.email,
|
||||
'apparent_user_role': request.user.role,
|
||||
'actual_user_id': actual_user.id,
|
||||
'actual_user_email': actual_user.email,
|
||||
'actual_user_role': actual_user.role,
|
||||
'hijack_started_at': request.session.get('hijack_started_at'),
|
||||
'session_key': request.session.session_key,
|
||||
}
|
||||
|
||||
except User.DoesNotExist:
|
||||
# Original user was deleted? This shouldn't happen but log it
|
||||
logger.error(
|
||||
f"Hijack session references non-existent user ID: {original_user_id}. "
|
||||
f"Current user: {request.user.email}"
|
||||
)
|
||||
# Clear the corrupted hijack session
|
||||
request.session.pop('hijack_history', None)
|
||||
request.is_masquerading = False
|
||||
|
||||
return None
|
||||
|
||||
def process_view(self, request, view_func, view_args, view_kwargs):
|
||||
"""
|
||||
Log audit event when masquerading user accesses a view.
|
||||
Only logs for authenticated, non-admin endpoints.
|
||||
"""
|
||||
if not request.is_masquerading:
|
||||
return None
|
||||
|
||||
# Skip logging for admin interface (too noisy)
|
||||
if request.path.startswith('/admin/'):
|
||||
return None
|
||||
|
||||
# Skip logging for static files and media
|
||||
if request.path.startswith('/static/') or request.path.startswith('/media/'):
|
||||
return None
|
||||
|
||||
# Build structured log entry
|
||||
log_entry = {
|
||||
'timestamp': timezone.now().isoformat(),
|
||||
'action': 'MASQUERADE_VIEW_ACCESS',
|
||||
'path': request.path,
|
||||
'method': request.method,
|
||||
'view_name': view_func.__name__ if view_func else 'Unknown',
|
||||
'apparent_user': request.user.email,
|
||||
'apparent_user_role': request.user.get_role_display(),
|
||||
'actual_user': request.actual_user.email if request.actual_user else 'Unknown',
|
||||
'actual_user_role': request.actual_user.get_role_display() if request.actual_user else 'Unknown',
|
||||
'ip_address': self._get_client_ip(request),
|
||||
'user_agent': request.META.get('HTTP_USER_AGENT', '')[:200],
|
||||
'tenant': request.user.tenant.name if request.user.tenant else 'Platform',
|
||||
}
|
||||
|
||||
# Log as structured JSON
|
||||
logger.info(
|
||||
f"Masquerade Access: {request.actual_user.email} as {request.user.email}",
|
||||
extra={'audit_data': log_entry}
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def process_response(self, request, response):
|
||||
"""
|
||||
Add audit headers to response when masquerading (for debugging).
|
||||
"""
|
||||
if hasattr(request, 'is_masquerading') and request.is_masquerading:
|
||||
# Add custom headers (visible in browser dev tools)
|
||||
response['X-SmoothSchedule-Masquerading'] = 'true'
|
||||
if request.actual_user:
|
||||
response['X-SmoothSchedule-Actual-User'] = request.actual_user.email
|
||||
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _get_client_ip(request):
|
||||
"""
|
||||
Extract client IP address from request, handling proxies.
|
||||
"""
|
||||
# Check for X-Forwarded-For header (from load balancers/proxies)
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
# Take the first IP (client IP, before proxies)
|
||||
ip = x_forwarded_for.split(',')[0].strip()
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
|
||||
return ip
|
||||
|
||||
|
||||
class MasqueradeEventLogger:
|
||||
"""
|
||||
Utility class for logging masquerade lifecycle events.
|
||||
Use this for logging hijack start/end events.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def log_hijack_start(hijacker, hijacked, request):
|
||||
"""
|
||||
Log when a hijack session starts.
|
||||
"""
|
||||
log_entry = {
|
||||
'timestamp': timezone.now().isoformat(),
|
||||
'action': 'HIJACK_START',
|
||||
'hijacker_id': hijacker.id,
|
||||
'hijacker_email': hijacker.email,
|
||||
'hijacker_role': hijacker.get_role_display(),
|
||||
'hijacked_id': hijacked.id,
|
||||
'hijacked_email': hijacked.email,
|
||||
'hijacked_role': hijacked.get_role_display(),
|
||||
'ip_address': request.META.get('REMOTE_ADDR'),
|
||||
'user_agent': request.META.get('HTTP_USER_AGENT', '')[:200],
|
||||
'session_key': request.session.session_key,
|
||||
}
|
||||
|
||||
logger.warning(
|
||||
f"HIJACK START: {hijacker.email} masquerading as {hijacked.email}",
|
||||
extra={'audit_data': log_entry}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log_hijack_end(hijacker, hijacked, request, duration_seconds=None):
|
||||
"""
|
||||
Log when a hijack session ends.
|
||||
"""
|
||||
log_entry = {
|
||||
'timestamp': timezone.now().isoformat(),
|
||||
'action': 'HIJACK_END',
|
||||
'hijacker_id': hijacker.id,
|
||||
'hijacker_email': hijacker.email,
|
||||
'hijacked_id': hijacked.id,
|
||||
'hijacked_email': hijacked.email,
|
||||
'duration_seconds': duration_seconds,
|
||||
'ip_address': request.META.get('REMOTE_ADDR'),
|
||||
'session_key': request.session.session_key,
|
||||
}
|
||||
|
||||
logger.warning(
|
||||
f"HIJACK END: {hijacker.email} stopped masquerading as {hijacked.email}",
|
||||
extra={'audit_data': log_entry}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log_hijack_denied(hijacker, hijacked, request, reason=''):
|
||||
"""
|
||||
Log when a hijack attempt is denied.
|
||||
"""
|
||||
log_entry = {
|
||||
'timestamp': timezone.now().isoformat(),
|
||||
'action': 'HIJACK_DENIED',
|
||||
'hijacker_id': hijacker.id,
|
||||
'hijacker_email': hijacker.email,
|
||||
'hijacker_role': hijacker.get_role_display(),
|
||||
'attempted_hijacked_id': hijacked.id,
|
||||
'attempted_hijacked_email': hijacked.email,
|
||||
'attempted_hijacked_role': hijacked.get_role_display(),
|
||||
'denial_reason': reason,
|
||||
'ip_address': request.META.get('REMOTE_ADDR'),
|
||||
'user_agent': request.META.get('HTTP_USER_AGENT', '')[:200],
|
||||
}
|
||||
|
||||
logger.error(
|
||||
f"HIJACK DENIED: {hijacker.email} attempted to masquerade as {hijacked.email} - {reason}",
|
||||
extra={'audit_data': log_entry}
|
||||
)
|
||||
72
smoothschedule/core/migrations/0001_initial.py
Normal file
72
smoothschedule/core/migrations/0001_initial.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-27 02:25
|
||||
|
||||
import django.db.models.deletion
|
||||
import django_tenants.postgresql_backend.base
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Tenant',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('schema_name', models.CharField(db_index=True, max_length=63, unique=True, validators=[django_tenants.postgresql_backend.base._check_schema_name])),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('created_on', models.DateField(auto_now_add=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('subscription_tier', models.CharField(choices=[('FREE', 'Free Trial'), ('STARTER', 'Starter'), ('PROFESSIONAL', 'Professional'), ('ENTERPRISE', 'Enterprise')], default='FREE', max_length=50)),
|
||||
('max_users', models.IntegerField(default=5)),
|
||||
('max_resources', models.IntegerField(default=10)),
|
||||
('contact_email', models.EmailField(blank=True, max_length=254)),
|
||||
('phone', models.CharField(blank=True, max_length=20)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Domain',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('domain', models.CharField(db_index=True, max_length=253, unique=True)),
|
||||
('is_primary', models.BooleanField(db_index=True, default=True)),
|
||||
('is_custom_domain', models.BooleanField(default=False, help_text='True if this is a custom domain (not a subdomain of smoothschedule.com)')),
|
||||
('route53_zone_id', models.CharField(blank=True, help_text='AWS Route53 Hosted Zone ID for this custom domain', max_length=100, null=True)),
|
||||
('route53_record_set_id', models.CharField(blank=True, help_text='Route53 Record Set ID for DNS verification', max_length=100, null=True)),
|
||||
('ssl_certificate_arn', models.CharField(blank=True, help_text='AWS ACM Certificate ARN for HTTPS', max_length=200, null=True)),
|
||||
('verified_at', models.DateTimeField(blank=True, help_text='When the custom domain was verified via DNS', null=True)),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='domains', to='core.tenant')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['domain'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PermissionGrant',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('action', models.CharField(help_text="Specific action or permission being granted (e.g., 'view_billing', 'edit_settings')", max_length=100)),
|
||||
('reason', models.TextField(blank=True, help_text='Justification for granting this permission (audit trail)')),
|
||||
('granted_at', models.DateTimeField(auto_now_add=True)),
|
||||
('expires_at', models.DateTimeField(help_text='When this permission grant expires')),
|
||||
('revoked_at', models.DateTimeField(blank=True, help_text='If manually revoked before expiration', null=True)),
|
||||
('ip_address', models.GenericIPAddressField(blank=True, help_text='IP address where grant was created', null=True)),
|
||||
('user_agent', models.TextField(blank=True, help_text='Browser/client user agent')),
|
||||
('grantee', models.ForeignKey(help_text='User who received the permission', on_delete=django.db.models.deletion.CASCADE, related_name='received_permissions', to=settings.AUTH_USER_MODEL)),
|
||||
('grantor', models.ForeignKey(help_text='User who granted the permission', on_delete=django.db.models.deletion.CASCADE, related_name='granted_permissions', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-granted_at'],
|
||||
'indexes': [models.Index(fields=['grantee', 'expires_at'], name='core_permis_grantee_560815_idx'), models.Index(fields=['grantor', 'granted_at'], name='core_permis_grantor_90dfdf_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
26
smoothschedule/core/migrations/0002_tierlimit.py
Normal file
26
smoothschedule/core/migrations/0002_tierlimit.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-27 04:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TierLimit',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('tier', models.CharField(choices=[('FREE', 'Free Trial'), ('STARTER', 'Starter'), ('PROFESSIONAL', 'Professional'), ('ENTERPRISE', 'Enterprise')], max_length=50)),
|
||||
('feature_code', models.CharField(help_text="Feature code (e.g., 'MAX_RESOURCES', 'MAX_USERS')", max_length=100)),
|
||||
('limit', models.IntegerField(default=0, help_text='Maximum allowed count for this feature')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['tier', 'feature_code'],
|
||||
'unique_together': {('tier', 'feature_code')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
smoothschedule/core/migrations/__init__.py
Normal file
0
smoothschedule/core/migrations/__init__.py
Normal file
271
smoothschedule/core/models.py
Normal file
271
smoothschedule/core/models.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
Smooth Schedule Core Models
|
||||
Multi-tenancy, Domain management, and Permission Grant system
|
||||
"""
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django_tenants.models import TenantMixin, DomainMixin
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class Tenant(TenantMixin):
|
||||
"""
|
||||
Tenant model for multi-schema architecture.
|
||||
Each tenant gets their own PostgreSQL schema for complete data isolation.
|
||||
"""
|
||||
name = models.CharField(max_length=100)
|
||||
created_on = models.DateField(auto_now_add=True)
|
||||
|
||||
# Subscription & billing
|
||||
is_active = models.BooleanField(default=True)
|
||||
subscription_tier = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('FREE', 'Free Trial'),
|
||||
('STARTER', 'Starter'),
|
||||
('PROFESSIONAL', 'Professional'),
|
||||
('ENTERPRISE', 'Enterprise'),
|
||||
],
|
||||
default='FREE'
|
||||
)
|
||||
|
||||
# Feature flags
|
||||
max_users = models.IntegerField(default=5)
|
||||
max_resources = models.IntegerField(default=10)
|
||||
|
||||
# Metadata
|
||||
contact_email = models.EmailField(blank=True)
|
||||
phone = models.CharField(max_length=20, blank=True)
|
||||
|
||||
# Auto-created fields from TenantMixin:
|
||||
# - schema_name (unique, indexed)
|
||||
# - auto_create_schema
|
||||
# - auto_drop_schema
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Domain(DomainMixin):
|
||||
"""
|
||||
Domain model for tenant routing.
|
||||
Supports both subdomain (tenant.chronoflow.com) and custom domains.
|
||||
"""
|
||||
# Inherited from DomainMixin:
|
||||
# - domain (unique, primary key)
|
||||
# - tenant (ForeignKey to Tenant)
|
||||
# - is_primary (boolean)
|
||||
|
||||
# Route53 integration fields for custom domains
|
||||
is_custom_domain = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True if this is a custom domain (not a subdomain of smoothschedule.com)"
|
||||
)
|
||||
|
||||
route53_zone_id = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="AWS Route53 Hosted Zone ID for this custom domain"
|
||||
)
|
||||
|
||||
route53_record_set_id = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Route53 Record Set ID for DNS verification"
|
||||
)
|
||||
|
||||
# SSL certificate management (for future AWS ACM integration)
|
||||
ssl_certificate_arn = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="AWS ACM Certificate ARN for HTTPS"
|
||||
)
|
||||
|
||||
verified_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When the custom domain was verified via DNS"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['domain']
|
||||
|
||||
def __str__(self):
|
||||
domain_type = "Custom" if self.is_custom_domain else "Subdomain"
|
||||
return f"{self.domain} ({domain_type})"
|
||||
|
||||
def is_verified(self):
|
||||
"""Check if custom domain is verified"""
|
||||
if not self.is_custom_domain:
|
||||
return True # Subdomains are always verified
|
||||
return self.verified_at is not None
|
||||
|
||||
|
||||
class PermissionGrant(models.Model):
|
||||
"""
|
||||
Time-limited permission grants (30-minute window).
|
||||
Used for temporary elevated access without permanently changing user permissions.
|
||||
|
||||
Example use cases:
|
||||
- Support agent needs temporary access to tenant data
|
||||
- Sales demo requiring elevated permissions
|
||||
- Cross-tenant operations during migrations
|
||||
"""
|
||||
grantor = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='granted_permissions',
|
||||
help_text="User who granted the permission"
|
||||
)
|
||||
|
||||
grantee = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='received_permissions',
|
||||
help_text="User who received the permission"
|
||||
)
|
||||
|
||||
action = models.CharField(
|
||||
max_length=100,
|
||||
help_text="Specific action or permission being granted (e.g., 'view_billing', 'edit_settings')"
|
||||
)
|
||||
|
||||
reason = models.TextField(
|
||||
blank=True,
|
||||
help_text="Justification for granting this permission (audit trail)"
|
||||
)
|
||||
|
||||
granted_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
expires_at = models.DateTimeField(
|
||||
help_text="When this permission grant expires"
|
||||
)
|
||||
|
||||
revoked_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="If manually revoked before expiration"
|
||||
)
|
||||
|
||||
# Audit metadata
|
||||
ip_address = models.GenericIPAddressField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="IP address where grant was created"
|
||||
)
|
||||
|
||||
user_agent = models.TextField(
|
||||
blank=True,
|
||||
help_text="Browser/client user agent"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-granted_at']
|
||||
indexes = [
|
||||
models.Index(fields=['grantee', 'expires_at']),
|
||||
models.Index(fields=['grantor', 'granted_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.grantor} → {self.grantee}: {self.action} (expires {self.expires_at})"
|
||||
|
||||
def is_active(self):
|
||||
"""
|
||||
Check if this permission grant is currently active.
|
||||
Returns False if expired or revoked.
|
||||
"""
|
||||
now = timezone.now()
|
||||
|
||||
# Check if revoked
|
||||
if self.revoked_at is not None:
|
||||
return False
|
||||
|
||||
# Check if expired
|
||||
if now >= self.expires_at:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def revoke(self):
|
||||
"""Manually revoke this permission grant"""
|
||||
if self.revoked_at is None:
|
||||
self.revoked_at = timezone.now()
|
||||
self.save(update_fields=['revoked_at'])
|
||||
|
||||
def time_remaining(self):
|
||||
"""Get timedelta of remaining active time (or None if expired/revoked)"""
|
||||
if not self.is_active():
|
||||
return None
|
||||
|
||||
now = timezone.now()
|
||||
return self.expires_at - now
|
||||
|
||||
@classmethod
|
||||
def create_grant(cls, grantor, grantee, action, reason="", duration_minutes=30, ip_address=None, user_agent=""):
|
||||
"""
|
||||
Factory method to create a time-limited permission grant.
|
||||
|
||||
Args:
|
||||
grantor: User granting the permission
|
||||
grantee: User receiving the permission
|
||||
action: Permission action string
|
||||
reason: Justification for audit trail
|
||||
duration_minutes: How long the grant is valid (default 30)
|
||||
ip_address: IP address of the request
|
||||
user_agent: Browser user agent
|
||||
|
||||
Returns:
|
||||
PermissionGrant instance
|
||||
"""
|
||||
now = timezone.now()
|
||||
expires_at = now + timedelta(minutes=duration_minutes)
|
||||
|
||||
return cls.objects.create(
|
||||
grantor=grantor,
|
||||
grantee=grantee,
|
||||
action=action,
|
||||
reason=reason,
|
||||
expires_at=expires_at,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
|
||||
|
||||
class TierLimit(models.Model):
|
||||
"""
|
||||
Defines resource limits for each subscription tier.
|
||||
Used by HasQuota permission to enforce hard blocks.
|
||||
"""
|
||||
tier = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('FREE', 'Free Trial'),
|
||||
('STARTER', 'Starter'),
|
||||
('PROFESSIONAL', 'Professional'),
|
||||
('ENTERPRISE', 'Enterprise'),
|
||||
]
|
||||
)
|
||||
|
||||
feature_code = models.CharField(
|
||||
max_length=100,
|
||||
help_text="Feature code (e.g., 'MAX_RESOURCES', 'MAX_USERS')"
|
||||
)
|
||||
|
||||
limit = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Maximum allowed count for this feature"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['tier', 'feature_code']
|
||||
ordering = ['tier', 'feature_code']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.tier} - {self.feature_code}: {self.limit}"
|
||||
|
||||
286
smoothschedule/core/permissions.py
Normal file
286
smoothschedule/core/permissions.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Smooth Schedule Hijack Permissions
|
||||
Implements the masquerading "Matrix" - strict rules for who can impersonate whom
|
||||
"""
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
|
||||
def can_hijack(hijacker, hijacked):
|
||||
"""
|
||||
Determine if hijacker (the admin) can masquerade as hijacked (target user).
|
||||
|
||||
The Matrix:
|
||||
┌──────────────────────┬─────────────────────────────────────────────────┐
|
||||
│ Hijacker Role │ Can Hijack │
|
||||
├──────────────────────┼─────────────────────────────────────────────────┤
|
||||
│ SUPERUSER │ Anyone (full god mode) │
|
||||
│ PLATFORM_SUPPORT │ TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF │
|
||||
│ PLATFORM_SALES │ Only users with is_temporary=True │
|
||||
│ TENANT_OWNER │ TENANT_STAFF in same tenant only │
|
||||
│ Others │ Nobody │
|
||||
└──────────────────────┴─────────────────────────────────────────────────┘
|
||||
|
||||
Args:
|
||||
hijacker: User attempting to impersonate (the admin)
|
||||
hijacked: User being impersonated (the target)
|
||||
|
||||
Returns:
|
||||
bool: True if hijack is allowed, False otherwise
|
||||
|
||||
Security Notes:
|
||||
- Never allow self-hijacking
|
||||
- Never allow hijacking of superusers (except by other superusers)
|
||||
- Always validate tenant boundaries for tenant-scoped roles
|
||||
- Log all hijack attempts (success and failure) for audit
|
||||
"""
|
||||
from users.models import User
|
||||
|
||||
# Safety check: can't hijack yourself
|
||||
if hijacker.id == hijacked.id:
|
||||
return False
|
||||
|
||||
# Safety check: only superusers can hijack other superusers
|
||||
if hijacked.role == User.Role.SUPERUSER and hijacker.role != User.Role.SUPERUSER:
|
||||
return False
|
||||
|
||||
# Rule 1: SUPERUSER can hijack anyone
|
||||
if hijacker.role == User.Role.SUPERUSER:
|
||||
return True
|
||||
|
||||
# Rule 2: PLATFORM_SUPPORT can hijack tenant users
|
||||
if hijacker.role == User.Role.PLATFORM_SUPPORT:
|
||||
return hijacked.role in [
|
||||
User.Role.TENANT_OWNER,
|
||||
User.Role.TENANT_MANAGER,
|
||||
User.Role.TENANT_STAFF,
|
||||
User.Role.CUSTOMER,
|
||||
]
|
||||
|
||||
# Rule 3: PLATFORM_SALES can only hijack temporary demo accounts
|
||||
if hijacker.role == User.Role.PLATFORM_SALES:
|
||||
return hijacked.is_temporary
|
||||
|
||||
# Rule 4: TENANT_OWNER can hijack staff within their own tenant
|
||||
if hijacker.role == User.Role.TENANT_OWNER:
|
||||
# Must be in same tenant
|
||||
if not hijacker.tenant or not hijacked.tenant:
|
||||
return False
|
||||
if hijacker.tenant.id != hijacked.tenant.id:
|
||||
return False
|
||||
|
||||
# Can only hijack staff and customers, not other owners/managers
|
||||
return hijacked.role in [
|
||||
User.Role.TENANT_STAFF,
|
||||
User.Role.CUSTOMER,
|
||||
]
|
||||
|
||||
# Default: deny
|
||||
return False
|
||||
|
||||
|
||||
def can_hijack_or_403(hijacker, hijacked):
|
||||
"""
|
||||
Same as can_hijack but raises PermissionDenied instead of returning False.
|
||||
Useful for views that want to use exception handling.
|
||||
|
||||
Args:
|
||||
hijacker: User attempting to impersonate
|
||||
hijacked: User being impersonated
|
||||
|
||||
Raises:
|
||||
PermissionDenied: If hijack is not allowed
|
||||
|
||||
Returns:
|
||||
bool: True if allowed (never returns False, raises instead)
|
||||
"""
|
||||
if not can_hijack(hijacker, hijacked):
|
||||
raise PermissionDenied(
|
||||
f"User {hijacker.email} ({hijacker.get_role_display()}) "
|
||||
f"is not authorized to masquerade as {hijacked.email} ({hijacked.get_role_display()})"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def get_hijackable_users(hijacker):
|
||||
"""
|
||||
Get queryset of all users that the hijacker can masquerade as.
|
||||
Useful for building "Masquerade as..." dropdowns in the UI.
|
||||
|
||||
Args:
|
||||
hijacker: User who wants to hijack
|
||||
|
||||
Returns:
|
||||
QuerySet: Users that can be hijacked by this user
|
||||
"""
|
||||
from users.models import User
|
||||
|
||||
# Start with all users except self
|
||||
qs = User.objects.exclude(id=hijacker.id)
|
||||
|
||||
# Apply filters based on hijacker role
|
||||
if hijacker.role == User.Role.SUPERUSER:
|
||||
# Superuser can hijack anyone
|
||||
return qs
|
||||
|
||||
elif hijacker.role == User.Role.PLATFORM_SUPPORT:
|
||||
# Can hijack all tenant-level users
|
||||
return qs.filter(role__in=[
|
||||
User.Role.TENANT_OWNER,
|
||||
User.Role.TENANT_MANAGER,
|
||||
User.Role.TENANT_STAFF,
|
||||
User.Role.CUSTOMER,
|
||||
])
|
||||
|
||||
elif hijacker.role == User.Role.PLATFORM_SALES:
|
||||
# Only temporary demo accounts
|
||||
return qs.filter(is_temporary=True)
|
||||
|
||||
elif hijacker.role == User.Role.TENANT_OWNER:
|
||||
# Only staff in same tenant
|
||||
if not hijacker.tenant:
|
||||
return qs.none()
|
||||
|
||||
return qs.filter(
|
||||
tenant=hijacker.tenant,
|
||||
role__in=[User.Role.TENANT_STAFF, User.Role.CUSTOMER]
|
||||
)
|
||||
|
||||
else:
|
||||
# No one else can hijack
|
||||
return qs.none()
|
||||
|
||||
|
||||
def validate_hijack_chain(request):
|
||||
"""
|
||||
Validate that hijack chains are not too deep.
|
||||
Prevents: Admin1 -> Admin2 -> Admin3 -> User scenarios.
|
||||
|
||||
Smooth Schedule Security Policy: Maximum hijack depth is 1.
|
||||
You cannot hijack while already hijacked.
|
||||
|
||||
Args:
|
||||
request: Django request object
|
||||
|
||||
Raises:
|
||||
PermissionDenied: If already in a hijack session
|
||||
|
||||
Returns:
|
||||
bool: True if allowed to start new hijack
|
||||
"""
|
||||
hijack_history = request.session.get('hijack_history', [])
|
||||
|
||||
if len(hijack_history) > 0:
|
||||
raise PermissionDenied(
|
||||
"Cannot start a new masquerade session while already masquerading. "
|
||||
"Please exit your current session first."
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# SaaS Quota Enforcement ("Hard Block" Logic)
|
||||
# ==============================================================================
|
||||
|
||||
def HasQuota(feature_code):
|
||||
"""
|
||||
Permission factory for SaaS tier limit enforcement.
|
||||
|
||||
Returns a DRF permission class that blocks write operations when
|
||||
tenant has exceeded their quota for a specific feature.
|
||||
|
||||
The "Hard Block": Prevents resource creation when tenant hits limit.
|
||||
|
||||
Usage:
|
||||
class ResourceViewSet(ModelViewSet):
|
||||
permission_classes = [IsAuthenticated, HasQuota('MAX_RESOURCES')]
|
||||
|
||||
Args:
|
||||
feature_code: TierLimit feature code (e.g., 'MAX_RESOURCES', 'MAX_USERS')
|
||||
|
||||
Returns:
|
||||
QuotaPermission class configured for the feature
|
||||
|
||||
How it Works:
|
||||
1. Read operations (GET/HEAD/OPTIONS) always allowed
|
||||
2. Write operations check current usage vs tier limit
|
||||
3. If usage >= limit, raises PermissionDenied (403)
|
||||
"""
|
||||
from rest_framework.permissions import BasePermission
|
||||
from django.apps import apps
|
||||
|
||||
class QuotaPermission(BasePermission):
|
||||
"""
|
||||
Dynamically generated permission class for quota checking.
|
||||
"""
|
||||
|
||||
# Map feature codes to model paths for usage counting
|
||||
# CRITICAL: This map must be populated for the permission to work
|
||||
USAGE_MAP = {
|
||||
'MAX_RESOURCES': 'schedule.Resource',
|
||||
'MAX_USERS': 'users.User',
|
||||
'MAX_EVENTS_PER_MONTH': 'schedule.Event',
|
||||
# Add more mappings as needed
|
||||
}
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""
|
||||
Check if tenant has quota for this operation.
|
||||
|
||||
Returns True for read operations, checks quota for writes.
|
||||
"""
|
||||
# Allow all read-only operations (GET, HEAD, OPTIONS)
|
||||
if request.method in ['GET', 'HEAD', 'OPTIONS']:
|
||||
return True
|
||||
|
||||
# Get tenant from request
|
||||
tenant = getattr(request, 'tenant', None)
|
||||
if not tenant:
|
||||
# No tenant in request - allow for public schema operations
|
||||
return True
|
||||
|
||||
# Get the model to check usage against
|
||||
model_path = self.USAGE_MAP.get(feature_code)
|
||||
if not model_path:
|
||||
# Feature not mapped - fail safe by allowing
|
||||
# (Production: you'd want to log this as a configuration error)
|
||||
return True
|
||||
|
||||
# Get the model class
|
||||
try:
|
||||
app_label, model_name = model_path.split('.')
|
||||
Model = apps.get_model(app_label, model_name)
|
||||
except (ValueError, LookupError):
|
||||
# Invalid model path - fail safe
|
||||
return True
|
||||
|
||||
# Get the tier limit for this tenant
|
||||
try:
|
||||
from core.models import TierLimit
|
||||
limit = TierLimit.objects.get(
|
||||
tier=tenant.subscription_tier,
|
||||
feature_code=feature_code
|
||||
).limit
|
||||
except TierLimit.DoesNotExist:
|
||||
# No limit defined - allow (unlimited)
|
||||
return True
|
||||
|
||||
# Count current usage
|
||||
# NOTE: django-tenants automatically scopes this query to tenant schema
|
||||
current_count = Model.objects.count()
|
||||
|
||||
# The "Hard Block": Enforce the limit
|
||||
if current_count >= limit:
|
||||
# Quota exceeded - deny the operation
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied(
|
||||
f"Quota exceeded: You have reached your plan limit of {limit} "
|
||||
f"{feature_code.replace('MAX_', '').lower().replace('_', ' ')}. "
|
||||
f"Please upgrade your subscription to add more."
|
||||
)
|
||||
|
||||
# Quota available - allow the operation
|
||||
return True
|
||||
|
||||
return QuotaPermission
|
||||
|
||||
40
smoothschedule/create_admin.py
Normal file
40
smoothschedule/create_admin.py
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python
|
||||
"""Create a platform admin user for testing."""
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
||||
django.setup()
|
||||
|
||||
from smoothschedule.users.models import User
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
# Create or get a superuser with platform admin role
|
||||
user, created = User.objects.get_or_create(
|
||||
username='admin',
|
||||
defaults={
|
||||
'email': 'admin@smoothschedule.com',
|
||||
'is_staff': True,
|
||||
'is_superuser': True,
|
||||
'role': User.Role.SUPERUSER,
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
user.set_password('admin123')
|
||||
user.save()
|
||||
print(f"Created new superuser: {user.username}")
|
||||
else:
|
||||
user.role = User.Role.SUPERUSER
|
||||
user.is_staff = True
|
||||
user.is_superuser = True
|
||||
user.set_password('admin123')
|
||||
user.save()
|
||||
print(f"Updated existing user: {user.username}")
|
||||
|
||||
# Create or get auth token
|
||||
token, _ = Token.objects.get_or_create(user=user)
|
||||
print(f"\nAuth token: {token.key}")
|
||||
print(f"User role: {user.role}")
|
||||
print(f"\nYou can now use this token to test the API:")
|
||||
print(f'curl -H "Authorization: Token {token.key}" http://lvh.me:8000/api/platform/businesses/')
|
||||
39
smoothschedule/create_default_tenant.py
Normal file
39
smoothschedule/create_default_tenant.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Create a default tenant for local development
|
||||
"""
|
||||
from core.models import Tenant, Domain
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
# Create default tenant for local development
|
||||
tenant, created = Tenant.objects.get_or_create(
|
||||
schema_name='public',
|
||||
defaults={
|
||||
'name': 'Default Tenant',
|
||||
'subscription_tier': 'PROFESSIONAL',
|
||||
'max_users': 100,
|
||||
'max_resources': 100,
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
print(f"Created tenant: {tenant.name}")
|
||||
else:
|
||||
print(f"Tenant already exists: {tenant.name}")
|
||||
|
||||
# Create domain for localhost
|
||||
domain, created = Domain.objects.get_or_create(
|
||||
domain='localhost',
|
||||
defaults={
|
||||
'tenant': tenant,
|
||||
'is_primary': True,
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
print(f"Created domain: {domain.domain}")
|
||||
else:
|
||||
print(f"Domain already exists: {domain.domain}")
|
||||
|
||||
print("Setup complete!")
|
||||
35
smoothschedule/debug_urls.py
Normal file
35
smoothschedule/debug_urls.py
Normal file
@@ -0,0 +1,35 @@
|
||||
|
||||
import os
|
||||
import django
|
||||
from django.conf import settings
|
||||
from django_tenants.utils import tenant_context
|
||||
from core.models import Tenant
|
||||
|
||||
# Setup Django
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
||||
django.setup()
|
||||
|
||||
from django.urls import resolve, reverse
|
||||
|
||||
print(f"ROOT_URLCONF: {settings.ROOT_URLCONF}")
|
||||
|
||||
try:
|
||||
tenant = Tenant.objects.get(schema_name='demo')
|
||||
print(f"Found tenant: {tenant}")
|
||||
|
||||
with tenant_context(tenant):
|
||||
print(f"Active schema: {tenant.schema_name}")
|
||||
try:
|
||||
match = resolve('/api/resources/')
|
||||
print(f"Resolved /api/resources/: {match}")
|
||||
except Exception as e:
|
||||
print(f"Failed to resolve /api/resources/: {e}")
|
||||
|
||||
try:
|
||||
match = resolve('/api/schedule/resources/')
|
||||
print(f"Resolved /api/schedule/resources/: {match}")
|
||||
except Exception as e:
|
||||
print(f"Failed to resolve /api/schedule/resources/: {e}")
|
||||
|
||||
except Tenant.DoesNotExist:
|
||||
print("Demo tenant not found")
|
||||
17
smoothschedule/docker-compose.docs.yml
Normal file
17
smoothschedule/docker-compose.docs.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
docs:
|
||||
image: smoothschedule_local_docs
|
||||
container_name: smoothschedule_local_docs
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./compose/local/docs/Dockerfile
|
||||
env_file:
|
||||
- ./.envs/.local/.django
|
||||
volumes:
|
||||
- /app/.venv
|
||||
- ./docs:/docs:z
|
||||
- ./config:/app/config:z
|
||||
- ./smoothschedule:/app/smoothschedule:z
|
||||
ports:
|
||||
- '9000:9000'
|
||||
command: /start-docs
|
||||
70
smoothschedule/docker-compose.local.yml
Normal file
70
smoothschedule/docker-compose.local.yml
Normal file
@@ -0,0 +1,70 @@
|
||||
volumes:
|
||||
smoothschedule_local_postgres_data: {}
|
||||
smoothschedule_local_postgres_data_backups: {}
|
||||
smoothschedule_local_redis_data: {}
|
||||
|
||||
services:
|
||||
django: &django
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./compose/local/django/Dockerfile
|
||||
image: smoothschedule_local_django
|
||||
container_name: smoothschedule_local_django
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
volumes:
|
||||
- /app/.venv
|
||||
- .:/app:z
|
||||
env_file:
|
||||
- ./.envs/.local/.django
|
||||
- ./.envs/.local/.postgres
|
||||
ports:
|
||||
- '8000:8000'
|
||||
command: /start
|
||||
|
||||
postgres:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./compose/production/postgres/Dockerfile
|
||||
image: smoothschedule_production_postgres
|
||||
container_name: smoothschedule_local_postgres
|
||||
volumes:
|
||||
- smoothschedule_local_postgres_data:/var/lib/postgresql/data
|
||||
- smoothschedule_local_postgres_data_backups:/backups
|
||||
env_file:
|
||||
- ./.envs/.local/.postgres
|
||||
|
||||
redis:
|
||||
image: docker.io/redis:7.2
|
||||
container_name: smoothschedule_local_redis
|
||||
volumes:
|
||||
- smoothschedule_local_redis_data:/data
|
||||
|
||||
celeryworker:
|
||||
<<: *django
|
||||
image: smoothschedule_local_celeryworker
|
||||
container_name: smoothschedule_local_celeryworker
|
||||
depends_on:
|
||||
- redis
|
||||
- postgres
|
||||
ports: []
|
||||
command: /start-celeryworker
|
||||
|
||||
celerybeat:
|
||||
<<: *django
|
||||
image: smoothschedule_local_celerybeat
|
||||
container_name: smoothschedule_local_celerybeat
|
||||
depends_on:
|
||||
- redis
|
||||
- postgres
|
||||
ports: []
|
||||
command: /start-celerybeat
|
||||
|
||||
flower:
|
||||
<<: *django
|
||||
image: smoothschedule_local_flower
|
||||
container_name: smoothschedule_local_flower
|
||||
ports:
|
||||
- '5555:5555'
|
||||
command: /start-flower
|
||||
79
smoothschedule/docker-compose.production.yml
Normal file
79
smoothschedule/docker-compose.production.yml
Normal file
@@ -0,0 +1,79 @@
|
||||
volumes:
|
||||
production_postgres_data: {}
|
||||
production_postgres_data_backups: {}
|
||||
production_traefik: {}
|
||||
|
||||
production_redis_data: {}
|
||||
|
||||
|
||||
|
||||
services:
|
||||
django: &django
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./compose/production/django/Dockerfile
|
||||
|
||||
image: smoothschedule_production_django
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
env_file:
|
||||
- ./.envs/.production/.django
|
||||
- ./.envs/.production/.postgres
|
||||
command: /start
|
||||
|
||||
postgres:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./compose/production/postgres/Dockerfile
|
||||
image: smoothschedule_production_postgres
|
||||
volumes:
|
||||
- production_postgres_data:/var/lib/postgresql/data
|
||||
- production_postgres_data_backups:/backups
|
||||
env_file:
|
||||
- ./.envs/.production/.postgres
|
||||
|
||||
traefik:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./compose/production/traefik/Dockerfile
|
||||
image: smoothschedule_production_traefik
|
||||
depends_on:
|
||||
- django
|
||||
volumes:
|
||||
- production_traefik:/etc/traefik/acme
|
||||
ports:
|
||||
- '0.0.0.0:80:80'
|
||||
- '0.0.0.0:443:443'
|
||||
- '0.0.0.0:5555:5555'
|
||||
|
||||
redis:
|
||||
image: docker.io/redis:7.2
|
||||
|
||||
volumes:
|
||||
- production_redis_data:/data
|
||||
|
||||
|
||||
celeryworker:
|
||||
<<: *django
|
||||
image: smoothschedule_production_celeryworker
|
||||
command: /start-celeryworker
|
||||
|
||||
celerybeat:
|
||||
<<: *django
|
||||
image: smoothschedule_production_celerybeat
|
||||
command: /start-celerybeat
|
||||
|
||||
flower:
|
||||
<<: *django
|
||||
image: smoothschedule_production_flower
|
||||
command: /start-flower
|
||||
|
||||
awscli:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./compose/production/aws/Dockerfile
|
||||
env_file:
|
||||
- ./.envs/.production/.django
|
||||
volumes:
|
||||
- production_postgres_data_backups:/backups:z
|
||||
29
smoothschedule/docs/Makefile
Normal file
29
smoothschedule/docs/Makefile
Normal file
@@ -0,0 +1,29 @@
|
||||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line, and also
|
||||
# from the environment for the first two.
|
||||
SPHINXOPTS ?=
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SOURCEDIR = .
|
||||
BUILDDIR = ./_build
|
||||
APP = /app
|
||||
|
||||
.PHONY: help livehtml apidocs Makefile
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -c .
|
||||
|
||||
# Build, watch and serve docs with live reload
|
||||
livehtml:
|
||||
sphinx-autobuild -b html --host 0.0.0.0 --port 9000 --watch $(APP) -c . $(SOURCEDIR) $(BUILDDIR)/html
|
||||
|
||||
# Outputs rst files from django application code
|
||||
apidocs:
|
||||
sphinx-apidoc -o $(SOURCEDIR)/api $(APP)
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -c .
|
||||
1
smoothschedule/docs/__init__.py
Normal file
1
smoothschedule/docs/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Included so that Django's startproject comment runs against the docs directory
|
||||
64
smoothschedule/docs/conf.py
Normal file
64
smoothschedule/docs/conf.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# ruff: noqa: ERA001, PTH100
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# This file only contains a selection of the most common options. For a full
|
||||
# list see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
# -- Path setup --------------------------------------------------------------
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import django
|
||||
|
||||
if os.getenv("READTHEDOCS", default="False") == "True":
|
||||
sys.path.insert(0, os.path.abspath(".."))
|
||||
os.environ["DJANGO_READ_DOT_ENV_FILE"] = "True"
|
||||
os.environ["USE_DOCKER"] = "no"
|
||||
else:
|
||||
sys.path.insert(0, os.path.abspath("/app"))
|
||||
os.environ["DATABASE_URL"] = "sqlite:///readthedocs.db"
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
||||
django.setup()
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = "Smooth Schedule"
|
||||
copyright = """2025, Smooth Schedule Team""" # noqa: A001
|
||||
author = "Smooth Schedule Team"
|
||||
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.napoleon",
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
# templates_path = ["_templates"]
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
# This pattern also affects html_static_path and html_extra_path.
|
||||
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
#
|
||||
html_theme = "alabaster"
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
# html_static_path = ["_static"]
|
||||
38
smoothschedule/docs/howto.rst
Normal file
38
smoothschedule/docs/howto.rst
Normal file
@@ -0,0 +1,38 @@
|
||||
How To - Project Documentation
|
||||
======================================================================
|
||||
|
||||
Get Started
|
||||
----------------------------------------------------------------------
|
||||
|
||||
Documentation can be written as rst files in `smoothschedule/docs`.
|
||||
|
||||
|
||||
To build and serve docs, use the commands::
|
||||
|
||||
docker compose -f docker-compose.docs.yml up
|
||||
|
||||
|
||||
|
||||
Changes to files in `docs/_source` will be picked up and reloaded automatically.
|
||||
|
||||
`Sphinx <https://www.sphinx-doc.org/>`_ is the tool used to build documentation.
|
||||
|
||||
Docstrings to Documentation
|
||||
----------------------------------------------------------------------
|
||||
|
||||
The sphinx extension `apidoc <https://www.sphinx-doc.org/en/master/man/sphinx-apidoc.html>`_ is used to automatically document code using signatures and docstrings.
|
||||
|
||||
Numpy or Google style docstrings will be picked up from project files and available for documentation. See the `Napoleon <https://sphinxcontrib-napoleon.readthedocs.io/en/latest/>`_ extension for details.
|
||||
|
||||
For an in-use example, see the `page source <_sources/users.rst.txt>`_ for :ref:`users`.
|
||||
|
||||
To compile all docstrings automatically into documentation source files, use the command:
|
||||
::
|
||||
|
||||
uv run make apidocs
|
||||
|
||||
|
||||
This can be done in the docker container:
|
||||
::
|
||||
|
||||
docker run --rm docs make apidocs
|
||||
23
smoothschedule/docs/index.rst
Normal file
23
smoothschedule/docs/index.rst
Normal file
@@ -0,0 +1,23 @@
|
||||
.. Smooth Schedule documentation master file, created by
|
||||
sphinx-quickstart.
|
||||
You can adapt this file completely to your liking, but it should at least
|
||||
contain the root `toctree` directive.
|
||||
|
||||
Welcome to Smooth Schedule's documentation!
|
||||
======================================================================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Contents:
|
||||
|
||||
howto
|
||||
users
|
||||
|
||||
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
46
smoothschedule/docs/make.bat
Normal file
46
smoothschedule/docs/make.bat
Normal file
@@ -0,0 +1,46 @@
|
||||
@ECHO OFF
|
||||
|
||||
pushd %~dp0
|
||||
|
||||
REM Command file for Sphinx documentation
|
||||
|
||||
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=sphinx-build -c .
|
||||
)
|
||||
set SOURCEDIR=_source
|
||||
set BUILDDIR=_build
|
||||
set APP=..\smoothschedule
|
||||
|
||||
if "%1" == "" goto help
|
||||
|
||||
%SPHINXBUILD% >NUL 2>NUL
|
||||
if errorlevel 9009 (
|
||||
echo.
|
||||
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||
echo.may add the Sphinx directory to PATH.
|
||||
echo.
|
||||
echo.Install sphinx-autobuild for live serving.
|
||||
echo.If you don't have Sphinx installed, grab it from
|
||||
echo.http://sphinx-doc.org/
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
%SPHINXBUILD% -b %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
goto end
|
||||
|
||||
:livehtml
|
||||
sphinx-autobuild -b html --open-browser -p 9000 --watch %APP% -c . %SOURCEDIR% %BUILDDIR%/html
|
||||
GOTO :EOF
|
||||
|
||||
:apidocs
|
||||
sphinx-apidoc -o %SOURCEDIR%/api %APP%
|
||||
GOTO :EOF
|
||||
|
||||
:help
|
||||
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
|
||||
:end
|
||||
popd
|
||||
15
smoothschedule/docs/users.rst
Normal file
15
smoothschedule/docs/users.rst
Normal file
@@ -0,0 +1,15 @@
|
||||
.. _users:
|
||||
|
||||
Users
|
||||
======================================================================
|
||||
|
||||
Starting a new project, it’s highly recommended to set up a custom user model,
|
||||
even if the default User model is sufficient for you.
|
||||
|
||||
This model behaves identically to the default user model,
|
||||
but you’ll be able to customize it in the future if the need arises.
|
||||
|
||||
.. automodule:: smoothschedule.users.models
|
||||
:members:
|
||||
:noindex:
|
||||
|
||||
38
smoothschedule/justfile
Normal file
38
smoothschedule/justfile
Normal file
@@ -0,0 +1,38 @@
|
||||
export COMPOSE_FILE := "docker-compose.local.yml"
|
||||
|
||||
## Just does not yet manage signals for subprocesses reliably, which can lead to unexpected behavior.
|
||||
## Exercise caution before expanding its usage in production environments.
|
||||
## For more information, see https://github.com/casey/just/issues/2473 .
|
||||
|
||||
|
||||
# Default command to list all available commands.
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# build: Build python image.
|
||||
build *args:
|
||||
@echo "Building python image..."
|
||||
@docker compose build {{args}}
|
||||
|
||||
# up: Start up containers.
|
||||
up:
|
||||
@echo "Starting up containers..."
|
||||
@docker compose up -d --remove-orphans
|
||||
|
||||
# down: Stop containers.
|
||||
down:
|
||||
@echo "Stopping containers..."
|
||||
@docker compose down
|
||||
|
||||
# prune: Remove containers and their volumes.
|
||||
prune *args:
|
||||
@echo "Killing containers and removing volumes..."
|
||||
@docker compose down -v {{args}}
|
||||
|
||||
# logs: View container logs
|
||||
logs *args:
|
||||
@docker compose logs -f {{args}}
|
||||
|
||||
# manage: Executes `manage.py` command.
|
||||
manage +args:
|
||||
@docker compose run --rm django python ./manage.py {{args}}
|
||||
32
smoothschedule/locale/README.md
Normal file
32
smoothschedule/locale/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Translations
|
||||
|
||||
Start by configuring the `LANGUAGES` settings in `base.py`, by uncommenting languages you are willing to support. Then, translation strings will be placed in this folder when running:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yml run --rm django python manage.py makemessages --all --no-location
|
||||
```
|
||||
|
||||
This should generate `django.po` (stands for Portable Object) files under each locale `<locale name>/LC_MESSAGES/django.po`. Each translatable string in the codebase is collected with its `msgid` and need to be translated as `msgstr`, for example:
|
||||
|
||||
```po
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
```
|
||||
|
||||
Once all translations are done, they need to be compiled into `.mo` files (stands for Machine Object), which are the actual binary files used by the application:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yml run --rm django python manage.py compilemessages
|
||||
```
|
||||
|
||||
Note that the `.po` files are NOT used by the application directly, so if the `.mo` files are out of date, the content won't appear as translated even if the `.po` files are up-to-date.
|
||||
|
||||
## Production
|
||||
|
||||
The production image runs `compilemessages` automatically at build time, so as long as your translated source files (PO) are up-to-date, you're good to go.
|
||||
|
||||
## Add a new language
|
||||
|
||||
1. Update the [`LANGUAGES` setting](https://docs.djangoproject.com/en/stable/ref/settings/#std-setting-LANGUAGES) to your project's base settings.
|
||||
2. Create the locale folder for the language next to this file, e.g. `fr_FR` for French. Make sure the case is correct.
|
||||
3. Run `makemessages` (as instructed above) to generate the PO files for the new language.
|
||||
12
smoothschedule/locale/en_US/LC_MESSAGES/django.po
Normal file
12
smoothschedule/locale/en_US/LC_MESSAGES/django.po
Normal file
@@ -0,0 +1,12 @@
|
||||
# Translations for the Smooth Schedule project
|
||||
# Copyright (C) 2025 Smooth Schedule Team
|
||||
# Smooth Schedule Team <admin@smoothschedule.com>, 2025.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: 0.1.0\n"
|
||||
"Language: en-US\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
335
smoothschedule/locale/fr_FR/LC_MESSAGES/django.po
Normal file
335
smoothschedule/locale/fr_FR/LC_MESSAGES/django.po
Normal file
@@ -0,0 +1,335 @@
|
||||
# Translations for the Smooth Schedule project
|
||||
# Copyright (C) 2025 Smooth Schedule Team
|
||||
# Smooth Schedule Team <admin@smoothschedule.com>, 2025.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: 0.1.0\n"
|
||||
"Language: fr-FR\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
#: smoothschedule/templates/account/account_inactive.html:5
|
||||
#: smoothschedule/templates/account/account_inactive.html:8
|
||||
msgid "Account Inactive"
|
||||
msgstr "Compte inactif"
|
||||
|
||||
#: smoothschedule/templates/account/account_inactive.html:10
|
||||
msgid "This account is inactive."
|
||||
msgstr "Ce compte est inactif."
|
||||
|
||||
#: smoothschedule/templates/account/email.html:7
|
||||
msgid "Account"
|
||||
msgstr "Compte"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:10
|
||||
msgid "E-mail Addresses"
|
||||
msgstr "Adresses e-mail"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:13
|
||||
msgid "The following e-mail addresses are associated with your account:"
|
||||
msgstr "Les adresses e-mail suivantes sont associées à votre compte :"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:27
|
||||
msgid "Verified"
|
||||
msgstr "Vérifié"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:29
|
||||
msgid "Unverified"
|
||||
msgstr "Non vérifié"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:31
|
||||
msgid "Primary"
|
||||
msgstr "Primaire"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:37
|
||||
msgid "Make Primary"
|
||||
msgstr "Changer Primaire"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:38
|
||||
msgid "Re-send Verification"
|
||||
msgstr "Renvoyer vérification"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:39
|
||||
msgid "Remove"
|
||||
msgstr "Supprimer"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:46
|
||||
msgid "Warning:"
|
||||
msgstr "Avertissement:"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:46
|
||||
msgid ""
|
||||
"You currently do not have any e-mail address set up. You should really add "
|
||||
"an e-mail address so you can receive notifications, reset your password, etc."
|
||||
msgstr ""
|
||||
"Vous n'avez actuellement aucune adresse e-mail configurée. Vous devriez ajouter "
|
||||
"une adresse e-mail pour reçevoir des notifications, réinitialiser votre mot "
|
||||
"de passe, etc."
|
||||
|
||||
#: smoothschedule/templates/account/email.html:51
|
||||
msgid "Add E-mail Address"
|
||||
msgstr "Ajouter une adresse e-mail"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:56
|
||||
msgid "Add E-mail"
|
||||
msgstr "Ajouter e-mail"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:66
|
||||
msgid "Do you really want to remove the selected e-mail address?"
|
||||
msgstr "Voulez-vous vraiment supprimer l'adresse e-mail sélectionnée ?"
|
||||
|
||||
#: smoothschedule/templates/account/email_confirm.html:6
|
||||
#: smoothschedule/templates/account/email_confirm.html:10
|
||||
msgid "Confirm E-mail Address"
|
||||
msgstr "Confirmez votre adresse email"
|
||||
|
||||
#: smoothschedule/templates/account/email_confirm.html:16
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Please confirm that <a href=\"mailto:%(email)s\">%(email)s</a> is an e-mail "
|
||||
"address for user %(user_display)s."
|
||||
msgstr ""
|
||||
"Veuillez confirmer que <a href=\"mailto:%(email)s\">%(email)s</a> est un e-mail "
|
||||
"adresse de l'utilisateur %(user_display)s."
|
||||
|
||||
#: smoothschedule/templates/account/email_confirm.html:20
|
||||
msgid "Confirm"
|
||||
msgstr "Confirm"
|
||||
|
||||
#: smoothschedule/templates/account/email_confirm.html:27
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This e-mail confirmation link expired or is invalid. Please <a href="
|
||||
"\"%(email_url)s\">issue a new e-mail confirmation request</a>."
|
||||
msgstr ""
|
||||
"Ce lien de confirmation par e-mail a expiré ou n'est pas valide. Veuillez"
|
||||
"<a href=\"%(email_url)s\">émettre une nouvelle demande de confirmation "
|
||||
"par e-mail</a>."
|
||||
|
||||
#: smoothschedule/templates/account/login.html:7
|
||||
#: smoothschedule/templates/account/login.html:11
|
||||
#: smoothschedule/templates/account/login.html:56
|
||||
#: smoothschedule/templates/base.html:72
|
||||
msgid "Sign In"
|
||||
msgstr "S'identifier"
|
||||
|
||||
#: smoothschedule/templates/account/login.html:17
|
||||
msgid "Please sign in with one of your existing third party accounts:"
|
||||
msgstr "Veuillez vous connecter avec l'un de vos comptes tiers existants :"
|
||||
|
||||
#: smoothschedule/templates/account/login.html:19
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Or, <a href=\"%(signup_url)s\">sign up</a> for a %(site_name)s account and "
|
||||
"sign in below:"
|
||||
msgstr ""
|
||||
"Ou, <a href=\"%(signup_url)s\">créez</a> un compte %(site_name)s et "
|
||||
"connectez-vous ci-dessous :"
|
||||
|
||||
#: smoothschedule/templates/account/login.html:32
|
||||
msgid "or"
|
||||
msgstr "ou"
|
||||
|
||||
#: smoothschedule/templates/account/login.html:41
|
||||
#, python-format
|
||||
msgid ""
|
||||
"If you have not created an account yet, then please <a href=\"%(signup_url)s"
|
||||
"\">sign up</a> first."
|
||||
msgstr ""
|
||||
"Si vous n'avez pas encore créé de compte, veuillez d'abord <a href=\"%(signup_url)s"
|
||||
"\">vous inscrire</a>."
|
||||
|
||||
#: smoothschedule/templates/account/login.html:55
|
||||
msgid "Forgot Password?"
|
||||
msgstr "Mot de passe oublié?"
|
||||
|
||||
#: smoothschedule/templates/account/logout.html:5
|
||||
#: smoothschedule/templates/account/logout.html:8
|
||||
#: smoothschedule/templates/account/logout.html:17
|
||||
#: smoothschedule/templates/base.html:61
|
||||
msgid "Sign Out"
|
||||
msgstr "Se déconnecter"
|
||||
|
||||
#: smoothschedule/templates/account/logout.html:10
|
||||
msgid "Are you sure you want to sign out?"
|
||||
msgstr "Êtes-vous certain de vouloir vous déconnecter?"
|
||||
|
||||
#: smoothschedule/templates/account/password_change.html:6
|
||||
#: smoothschedule/templates/account/password_change.html:9
|
||||
#: smoothschedule/templates/account/password_change.html:14
|
||||
#: smoothschedule/templates/account/password_reset_from_key.html:5
|
||||
#: smoothschedule/templates/account/password_reset_from_key.html:8
|
||||
#: smoothschedule/templates/account/password_reset_from_key_done.html:4
|
||||
#: smoothschedule/templates/account/password_reset_from_key_done.html:7
|
||||
msgid "Change Password"
|
||||
msgstr "Changer le mot de passe"
|
||||
|
||||
#: smoothschedule/templates/account/password_reset.html:7
|
||||
#: smoothschedule/templates/account/password_reset.html:11
|
||||
#: smoothschedule/templates/account/password_reset_done.html:6
|
||||
#: smoothschedule/templates/account/password_reset_done.html:9
|
||||
msgid "Password Reset"
|
||||
msgstr "Réinitialisation du mot de passe"
|
||||
|
||||
#: smoothschedule/templates/account/password_reset.html:16
|
||||
msgid ""
|
||||
"Forgotten your password? Enter your e-mail address below, and we'll send you "
|
||||
"an e-mail allowing you to reset it."
|
||||
msgstr ""
|
||||
"Mot de passe oublié? Entrez votre adresse e-mail ci-dessous, et nous vous "
|
||||
"enverrons un e-mail vous permettant de le réinitialiser."
|
||||
|
||||
#: smoothschedule/templates/account/password_reset.html:21
|
||||
msgid "Reset My Password"
|
||||
msgstr "Réinitialiser mon mot de passe"
|
||||
|
||||
#: smoothschedule/templates/account/password_reset.html:24
|
||||
msgid "Please contact us if you have any trouble resetting your password."
|
||||
msgstr ""
|
||||
"Veuillez nous contacter si vous rencontrez des difficultés pour réinitialiser"
|
||||
"votre mot de passe."
|
||||
|
||||
#: smoothschedule/templates/account/password_reset_done.html:15
|
||||
msgid ""
|
||||
"We have sent you an e-mail. Please contact us if you do not receive it "
|
||||
"within a few minutes."
|
||||
msgstr ""
|
||||
"Nous vous avons envoyé un e-mail. Veuillez nous contacter si vous ne le "
|
||||
"recevez pas d'ici quelques minutes."
|
||||
|
||||
#: smoothschedule/templates/account/password_reset_from_key.html:8
|
||||
msgid "Bad Token"
|
||||
msgstr "Token Invalide"
|
||||
|
||||
#: smoothschedule/templates/account/password_reset_from_key.html:12
|
||||
#, python-format
|
||||
msgid ""
|
||||
"The password reset link was invalid, possibly because it has already been "
|
||||
"used. Please request a <a href=\"%(passwd_reset_url)s\">new password reset</"
|
||||
"a>."
|
||||
msgstr ""
|
||||
"Le lien de réinitialisation du mot de passe n'était pas valide, peut-être parce "
|
||||
"qu'il a déjà été utilisé. Veuillez faire une <a href=\"%(passwd_reset_url)s\"> "
|
||||
"nouvelle demande de réinitialisation de mot de passe</a>."
|
||||
|
||||
#: smoothschedule/templates/account/password_reset_from_key.html:18
|
||||
msgid "change password"
|
||||
msgstr "changer le mot de passe"
|
||||
|
||||
#: smoothschedule/templates/account/password_reset_from_key.html:21
|
||||
#: smoothschedule/templates/account/password_reset_from_key_done.html:8
|
||||
msgid "Your password is now changed."
|
||||
msgstr "Votre mot de passe est maintenant modifié."
|
||||
|
||||
#: smoothschedule/templates/account/password_set.html:6
|
||||
#: smoothschedule/templates/account/password_set.html:9
|
||||
#: smoothschedule/templates/account/password_set.html:14
|
||||
msgid "Set Password"
|
||||
msgstr "Définir le mot de passe"
|
||||
|
||||
#: smoothschedule/templates/account/signup.html:6
|
||||
msgid "Signup"
|
||||
msgstr "S'inscrire"
|
||||
|
||||
#: smoothschedule/templates/account/signup.html:9
|
||||
#: smoothschedule/templates/account/signup.html:19
|
||||
#: smoothschedule/templates/base.html:67
|
||||
msgid "Sign Up"
|
||||
msgstr "S'inscrire"
|
||||
|
||||
#: smoothschedule/templates/account/signup.html:11
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Already have an account? Then please <a href=\"%(login_url)s\">sign in</a>."
|
||||
msgstr ""
|
||||
"Vous avez déjà un compte? Alors veuillez <a href=\"%(login_url)s\">vous connecter</a>."
|
||||
|
||||
#: smoothschedule/templates/account/signup_closed.html:5
|
||||
#: smoothschedule/templates/account/signup_closed.html:8
|
||||
msgid "Sign Up Closed"
|
||||
msgstr "Inscriptions closes"
|
||||
|
||||
#: smoothschedule/templates/account/signup_closed.html:10
|
||||
msgid "We are sorry, but the sign up is currently closed."
|
||||
msgstr "Désolé, mais l'inscription est actuellement fermée."
|
||||
|
||||
#: smoothschedule/templates/account/verification_sent.html:5
|
||||
#: smoothschedule/templates/account/verification_sent.html:8
|
||||
#: smoothschedule/templates/account/verified_email_required.html:5
|
||||
#: smoothschedule/templates/account/verified_email_required.html:8
|
||||
msgid "Verify Your E-mail Address"
|
||||
msgstr "Vérifiez votre adresse e-mail"
|
||||
|
||||
#: smoothschedule/templates/account/verification_sent.html:10
|
||||
msgid ""
|
||||
"We have sent an e-mail to you for verification. Follow the link provided to "
|
||||
"finalize the signup process. Please contact us if you do not receive it "
|
||||
"within a few minutes."
|
||||
msgstr "Nous vous avons envoyé un e-mail pour vérification. Suivez le lien fourni "
|
||||
"pour finalisez le processus d'inscription. Veuillez nous contacter si vous ne le "
|
||||
"recevez pas d'ici quelques minutes."
|
||||
|
||||
#: smoothschedule/templates/account/verified_email_required.html:12
|
||||
msgid ""
|
||||
"This part of the site requires us to verify that\n"
|
||||
"you are who you claim to be. For this purpose, we require that you\n"
|
||||
"verify ownership of your e-mail address. "
|
||||
msgstr ""
|
||||
"Cette partie du site nous oblige à vérifier que\n"
|
||||
"vous êtes qui vous prétendez être. Nous vous demandons donc de\n"
|
||||
"vérifier la propriété de votre adresse e-mail."
|
||||
|
||||
#: smoothschedule/templates/account/verified_email_required.html:16
|
||||
msgid ""
|
||||
"We have sent an e-mail to you for\n"
|
||||
"verification. Please click on the link inside this e-mail. Please\n"
|
||||
"contact us if you do not receive it within a few minutes."
|
||||
msgstr ""
|
||||
"Nous vous avons envoyé un e-mail pour\n"
|
||||
"vérification. Veuillez cliquer sur le lien contenu dans cet e-mail. Veuillez nous\n"
|
||||
"contacter si vous ne le recevez pas d'ici quelques minutes."
|
||||
|
||||
#: smoothschedule/templates/account/verified_email_required.html:20
|
||||
#, python-format
|
||||
msgid ""
|
||||
"<strong>Note:</strong> you can still <a href=\"%(email_url)s\">change your e-"
|
||||
"mail address</a>."
|
||||
msgstr ""
|
||||
"<strong>Remarque :</strong> vous pouvez toujours <a href=\"%(email_url)s\">changer votre e-"
|
||||
"adresse e-mail</a>."
|
||||
|
||||
#: smoothschedule/templates/base.html:57
|
||||
msgid "My Profile"
|
||||
msgstr "Mon Profil"
|
||||
|
||||
#: smoothschedule/users/admin.py:17
|
||||
msgid "Personal info"
|
||||
msgstr "Personal info"
|
||||
|
||||
#: smoothschedule/users/admin.py:19
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
|
||||
#: smoothschedule/users/admin.py:30
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: smoothschedule/users/apps.py:7
|
||||
msgid "Users"
|
||||
msgstr "Utilisateurs"
|
||||
|
||||
#: smoothschedule/users/forms.py:24
|
||||
#: smoothschedule/users/tests/test_forms.py:36
|
||||
msgid "This username has already been taken."
|
||||
msgstr "Ce nom d'utilisateur est déjà pris."
|
||||
|
||||
#: smoothschedule/users/models.py:15
|
||||
msgid "Name of User"
|
||||
msgstr "Nom de l'utilisateur"
|
||||
|
||||
#: smoothschedule/users/views.py:23
|
||||
msgid "Information successfully updated"
|
||||
msgstr "Informations mises à jour avec succès"
|
||||
315
smoothschedule/locale/pt_BR/LC_MESSAGES/django.po
Normal file
315
smoothschedule/locale/pt_BR/LC_MESSAGES/django.po
Normal file
@@ -0,0 +1,315 @@
|
||||
# Translations for the Smooth Schedule project
|
||||
# Copyright (C) 2025 Smooth Schedule Team
|
||||
# Smooth Schedule Team <admin@smoothschedule.com>, 2025.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: 0.1.0\n"
|
||||
"Language: pt-BR\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
#: smoothschedule/templates/account/account_inactive.html:5
|
||||
#: smoothschedule/templates/account/account_inactive.html:8
|
||||
msgid "Account Inactive"
|
||||
msgstr "Conta Inativa"
|
||||
|
||||
#: smoothschedule/templates/account/account_inactive.html:10
|
||||
msgid "This account is inactive."
|
||||
msgstr "Esta conta está inativa."
|
||||
|
||||
#: smoothschedule/templates/account/email.html:7
|
||||
msgid "Account"
|
||||
msgstr "Conta"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:10
|
||||
msgid "E-mail Addresses"
|
||||
msgstr "Endereços de E-mail"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:13
|
||||
msgid "The following e-mail addresses are associated with your account:"
|
||||
msgstr "Os seguintes endereços de e-mail estão associados à sua conta:"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:27
|
||||
msgid "Verified"
|
||||
msgstr "Verificado"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:29
|
||||
msgid "Unverified"
|
||||
msgstr "Não verificado"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:31
|
||||
msgid "Primary"
|
||||
msgstr "Primário"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:37
|
||||
msgid "Make Primary"
|
||||
msgstr "Tornar Primário"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:38
|
||||
msgid "Re-send Verification"
|
||||
msgstr "Reenviar verificação"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:39
|
||||
msgid "Remove"
|
||||
msgstr "Remover"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:46
|
||||
msgid "Warning:"
|
||||
msgstr "Aviso:"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:46
|
||||
msgid ""
|
||||
"You currently do not have any e-mail address set up. You should really add "
|
||||
"an e-mail address so you can receive notifications, reset your password, etc."
|
||||
msgstr ""
|
||||
"No momento, você não tem nenhum endereço de e-mail configurado. Você "
|
||||
"realmente deve adicionar um endereço de e-mail para receber notificações, "
|
||||
"redefinir sua senha etc."
|
||||
|
||||
#: smoothschedule/templates/account/email.html:51
|
||||
msgid "Add E-mail Address"
|
||||
msgstr "Adicionar Endereço de E-mail"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:56
|
||||
msgid "Add E-mail"
|
||||
msgstr "Adicionar E-mail"
|
||||
|
||||
#: smoothschedule/templates/account/email.html:66
|
||||
msgid "Do you really want to remove the selected e-mail address?"
|
||||
msgstr "Você realmente deseja remover o endereço de e-mail selecionado?"
|
||||
|
||||
#: smoothschedule/templates/account/email_confirm.html:6
|
||||
#: smoothschedule/templates/account/email_confirm.html:10
|
||||
msgid "Confirm E-mail Address"
|
||||
msgstr "Confirme o endereço de e-mail"
|
||||
|
||||
#: smoothschedule/templates/account/email_confirm.html:16
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Please confirm that <a href=\"mailto:%(email)s\">%(email)s</a> is an e-mail "
|
||||
"address for user %(user_display)s."
|
||||
msgstr ""
|
||||
"Confirme se <a href=\"mailto:%(email)s\">%(email)s</a> é um endereço de "
|
||||
"e-mail do usuário %(user_display)s."
|
||||
|
||||
#: smoothschedule/templates/account/email_confirm.html:20
|
||||
msgid "Confirm"
|
||||
msgstr "Confirmar"
|
||||
|
||||
#: smoothschedule/templates/account/email_confirm.html:27
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This e-mail confirmation link expired or is invalid. Please <a href="
|
||||
"\"%(email_url)s\">issue a new e-mail confirmation request</a>."
|
||||
msgstr "Este link de confirmação de e-mail expirou ou é inválido. "
|
||||
"Por favor, <a href=\"%(email_url)s\">emita um novo pedido de confirmação por e-mail</a>."
|
||||
|
||||
#: smoothschedule/templates/account/login.html:7
|
||||
#: smoothschedule/templates/account/login.html:11
|
||||
#: smoothschedule/templates/account/login.html:56
|
||||
#: smoothschedule/templates/base.html:72
|
||||
msgid "Sign In"
|
||||
msgstr "Entrar"
|
||||
|
||||
#: smoothschedule/templates/account/login.html:17
|
||||
msgid "Please sign in with one of your existing third party accounts:"
|
||||
msgstr "Faça login com uma de suas contas de terceiros existentes:"
|
||||
|
||||
#: smoothschedule/templates/account/login.html:19
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Or, <a href=\"%(signup_url)s\">sign up</a> for a %(site_name)s account and "
|
||||
"sign in below:"
|
||||
msgstr "Ou, <a href=\"%(signup_url)s\">cadastre-se</a> para uma conta em %(site_name)s e entre abaixo:"
|
||||
|
||||
#: smoothschedule/templates/account/login.html:32
|
||||
msgid "or"
|
||||
msgstr "ou"
|
||||
|
||||
#: smoothschedule/templates/account/login.html:41
|
||||
#, python-format
|
||||
msgid ""
|
||||
"If you have not created an account yet, then please <a href=\"%(signup_url)s"
|
||||
"\">sign up</a> first."
|
||||
msgstr "Se você ainda não criou uma conta, <a href=\"%(signup_url)s"
|
||||
"\">registre-se primeiro</a>."
|
||||
|
||||
#: smoothschedule/templates/account/login.html:55
|
||||
msgid "Forgot Password?"
|
||||
msgstr "Esqueceu sua senha?"
|
||||
|
||||
#: smoothschedule/templates/account/logout.html:5
|
||||
#: smoothschedule/templates/account/logout.html:8
|
||||
#: smoothschedule/templates/account/logout.html:17
|
||||
#: smoothschedule/templates/base.html:61
|
||||
msgid "Sign Out"
|
||||
msgstr "Sair"
|
||||
|
||||
#: smoothschedule/templates/account/logout.html:10
|
||||
msgid "Are you sure you want to sign out?"
|
||||
msgstr "Você tem certeza que deseja sair?"
|
||||
|
||||
#: smoothschedule/templates/account/password_change.html:6
|
||||
#: smoothschedule/templates/account/password_change.html:9
|
||||
#: smoothschedule/templates/account/password_change.html:14
|
||||
#: smoothschedule/templates/account/password_reset_from_key.html:5
|
||||
#: smoothschedule/templates/account/password_reset_from_key.html:8
|
||||
#: smoothschedule/templates/account/password_reset_from_key_done.html:4
|
||||
#: smoothschedule/templates/account/password_reset_from_key_done.html:7
|
||||
msgid "Change Password"
|
||||
msgstr "Alterar Senha"
|
||||
|
||||
#: smoothschedule/templates/account/password_reset.html:7
|
||||
#: smoothschedule/templates/account/password_reset.html:11
|
||||
#: smoothschedule/templates/account/password_reset_done.html:6
|
||||
#: smoothschedule/templates/account/password_reset_done.html:9
|
||||
msgid "Password Reset"
|
||||
msgstr "Redefinição de senha"
|
||||
|
||||
#: smoothschedule/templates/account/password_reset.html:16
|
||||
msgid ""
|
||||
"Forgotten your password? Enter your e-mail address below, and we'll send you "
|
||||
"an e-mail allowing you to reset it."
|
||||
msgstr "Esqueceu sua senha? Digite seu endereço de e-mail abaixo e enviaremos um e-mail permitindo que você o redefina."
|
||||
|
||||
#: smoothschedule/templates/account/password_reset.html:21
|
||||
msgid "Reset My Password"
|
||||
msgstr "Redefinir minha senha"
|
||||
|
||||
#: smoothschedule/templates/account/password_reset.html:24
|
||||
msgid "Please contact us if you have any trouble resetting your password."
|
||||
msgstr "Entre em contato conosco se tiver algum problema para redefinir sua senha."
|
||||
|
||||
#: smoothschedule/templates/account/password_reset_done.html:15
|
||||
msgid ""
|
||||
"We have sent you an e-mail. Please contact us if you do not receive it "
|
||||
"within a few minutes."
|
||||
msgstr "Enviamos um e-mail para você. Entre em contato conosco se você não recebê-lo dentro de alguns minutos."
|
||||
|
||||
#: smoothschedule/templates/account/password_reset_from_key.html:8
|
||||
msgid "Bad Token"
|
||||
msgstr "Token Inválido"
|
||||
|
||||
#: smoothschedule/templates/account/password_reset_from_key.html:12
|
||||
#, python-format
|
||||
msgid ""
|
||||
"The password reset link was invalid, possibly because it has already been "
|
||||
"used. Please request a <a href=\"%(passwd_reset_url)s\">new password reset</"
|
||||
"a>."
|
||||
msgstr "O link de redefinição de senha era inválido, possivelmente porque já foi usado. "
|
||||
"<a href=\"%(passwd_reset_url)s\">Solicite uma nova redefinição de senha</a>."
|
||||
|
||||
#: smoothschedule/templates/account/password_reset_from_key.html:18
|
||||
msgid "change password"
|
||||
msgstr "alterar senha"
|
||||
|
||||
#: smoothschedule/templates/account/password_reset_from_key.html:21
|
||||
#: smoothschedule/templates/account/password_reset_from_key_done.html:8
|
||||
msgid "Your password is now changed."
|
||||
msgstr "Sua senha agora foi alterada."
|
||||
|
||||
#: smoothschedule/templates/account/password_set.html:6
|
||||
#: smoothschedule/templates/account/password_set.html:9
|
||||
#: smoothschedule/templates/account/password_set.html:14
|
||||
msgid "Set Password"
|
||||
msgstr "Definir Senha"
|
||||
|
||||
#: smoothschedule/templates/account/signup.html:6
|
||||
msgid "Signup"
|
||||
msgstr "Cadastro"
|
||||
|
||||
#: smoothschedule/templates/account/signup.html:9
|
||||
#: smoothschedule/templates/account/signup.html:19
|
||||
#: smoothschedule/templates/base.html:67
|
||||
msgid "Sign Up"
|
||||
msgstr "Cadastro"
|
||||
|
||||
#: smoothschedule/templates/account/signup.html:11
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Already have an account? Then please <a href=\"%(login_url)s\">sign in</a>."
|
||||
msgstr "já tem uma conta? Então, por favor, faça <a href=\"%(login_url)s\">login</a>."
|
||||
|
||||
#: smoothschedule/templates/account/signup_closed.html:5
|
||||
#: smoothschedule/templates/account/signup_closed.html:8
|
||||
msgid "Sign Up Closed"
|
||||
msgstr "Inscrições encerradas"
|
||||
|
||||
#: smoothschedule/templates/account/signup_closed.html:10
|
||||
msgid "We are sorry, but the sign up is currently closed."
|
||||
msgstr "Lamentamos, mas as inscrições estão encerradas no momento."
|
||||
|
||||
#: smoothschedule/templates/account/verification_sent.html:5
|
||||
#: smoothschedule/templates/account/verification_sent.html:8
|
||||
#: smoothschedule/templates/account/verified_email_required.html:5
|
||||
#: smoothschedule/templates/account/verified_email_required.html:8
|
||||
msgid "Verify Your E-mail Address"
|
||||
msgstr "Verifique seu endereço de e-mail"
|
||||
|
||||
#: smoothschedule/templates/account/verification_sent.html:10
|
||||
msgid ""
|
||||
"We have sent an e-mail to you for verification. Follow the link provided to "
|
||||
"finalize the signup process. Please contact us if you do not receive it "
|
||||
"within a few minutes."
|
||||
msgstr "Enviamos um e-mail para você para verificação. Siga o link fornecido para finalizar o processo de inscrição. Entre em contato conosco se você não recebê-lo dentro de alguns minutos."
|
||||
|
||||
#: smoothschedule/templates/account/verified_email_required.html:12
|
||||
msgid ""
|
||||
"This part of the site requires us to verify that\n"
|
||||
"you are who you claim to be. For this purpose, we require that you\n"
|
||||
"verify ownership of your e-mail address. "
|
||||
msgstr "Esta parte do site exige que verifiquemos se você é quem afirma ser.\n"
|
||||
"Para esse fim, exigimos que você verifique a propriedade\n"
|
||||
"do seu endereço de e-mail."
|
||||
|
||||
#: smoothschedule/templates/account/verified_email_required.html:16
|
||||
msgid ""
|
||||
"We have sent an e-mail to you for\n"
|
||||
"verification. Please click on the link inside this e-mail. Please\n"
|
||||
"contact us if you do not receive it within a few minutes."
|
||||
msgstr "Enviamos um e-mail para você para verificação.\n"
|
||||
"Por favor, clique no link dentro deste e-mail.\n"
|
||||
"Entre em contato conosco se você não recebê-lo dentro de alguns minutos."
|
||||
|
||||
#: smoothschedule/templates/account/verified_email_required.html:20
|
||||
#, python-format
|
||||
msgid ""
|
||||
"<strong>Note:</strong> you can still <a href=\"%(email_url)s\">change your e-"
|
||||
"mail address</a>."
|
||||
msgstr "<strong>Nota</strong>: você ainda pode <a href=\"%(email_url)s\">alterar seu endereço de e-mail</a>."
|
||||
|
||||
#: smoothschedule/templates/base.html:57
|
||||
msgid "My Profile"
|
||||
msgstr "Meu perfil"
|
||||
|
||||
#: smoothschedule/users/admin.py:17
|
||||
msgid "Personal info"
|
||||
msgstr "Informação pessoal"
|
||||
|
||||
#: smoothschedule/users/admin.py:19
|
||||
msgid "Permissions"
|
||||
msgstr "Permissões"
|
||||
|
||||
#: smoothschedule/users/admin.py:30
|
||||
msgid "Important dates"
|
||||
msgstr "Datas importantes"
|
||||
|
||||
#: smoothschedule/users/apps.py:7
|
||||
msgid "Users"
|
||||
msgstr "Usuários"
|
||||
|
||||
#: smoothschedule/users/forms.py:24
|
||||
#: smoothschedule/users/tests/test_forms.py:36
|
||||
msgid "This username has already been taken."
|
||||
msgstr "Este nome de usuário já foi usado."
|
||||
|
||||
#: smoothschedule/users/models.py:15
|
||||
msgid "Name of User"
|
||||
msgstr "Nome do Usuário"
|
||||
|
||||
#: smoothschedule/users/views.py:23
|
||||
msgid "Information successfully updated"
|
||||
msgstr "Informação atualizada com sucesso"
|
||||
30
smoothschedule/manage.py
Executable file
30
smoothschedule/manage.py
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
||||
|
||||
try:
|
||||
from django.core.management import execute_from_command_line # noqa: PLC0415
|
||||
except ImportError as exc:
|
||||
raise ImportError( # noqa: TRY003
|
||||
"Couldn't import Django. Are you sure it's installed and " # noqa: EM101
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?",
|
||||
) from exc
|
||||
|
||||
# This allows easy placement of apps within the interior
|
||||
# smoothschedule directory.
|
||||
current_path = Path(__file__).parent.resolve()
|
||||
sys.path.append(str(current_path / "smoothschedule"))
|
||||
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
25
smoothschedule/merge_production_dotenvs_in_dotenv.py
Normal file
25
smoothschedule/merge_production_dotenvs_in_dotenv.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).parent.resolve()
|
||||
PRODUCTION_DOTENVS_DIR = BASE_DIR / ".envs" / ".production"
|
||||
PRODUCTION_DOTENV_FILES = [
|
||||
PRODUCTION_DOTENVS_DIR / ".django",
|
||||
PRODUCTION_DOTENVS_DIR / ".postgres",
|
||||
]
|
||||
DOTENV_FILE = BASE_DIR / ".env"
|
||||
|
||||
|
||||
def merge(
|
||||
output_file: Path,
|
||||
files_to_merge: Sequence[Path],
|
||||
) -> None:
|
||||
merged_content = ""
|
||||
for merge_file in files_to_merge:
|
||||
merged_content += merge_file.read_text()
|
||||
merged_content += "\n"
|
||||
output_file.write_text(merged_content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
merge(DOTENV_FILE, PRODUCTION_DOTENV_FILES)
|
||||
360
smoothschedule/package-lock.json
generated
Normal file
360
smoothschedule/package-lock.json
generated
Normal file
@@ -0,0 +1,360 @@
|
||||
{
|
||||
"name": "smoothschedule",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"axios": "^1.13.2",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.11",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.11.tgz",
|
||||
"integrity": "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.90.11",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.11.tgz",
|
||||
"integrity": "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
||||
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
smoothschedule/package.json
Normal file
9
smoothschedule/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"axios": "^1.13.2",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
}
|
||||
}
|
||||
0
smoothschedule/payments/__init__.py
Normal file
0
smoothschedule/payments/__init__.py
Normal file
3
smoothschedule/payments/admin.py
Normal file
3
smoothschedule/payments/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
smoothschedule/payments/apps.py
Normal file
6
smoothschedule/payments/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PaymentsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'payments'
|
||||
40
smoothschedule/payments/migrations/0001_initial.py
Normal file
40
smoothschedule/payments/migrations/0001_initial.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-27 04:43
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from decimal import Decimal
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TransactionLink',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('payment_intent_id', models.CharField(db_index=True, help_text='Stripe PaymentIntent ID', max_length=255, unique=True)),
|
||||
('amount', models.DecimalField(decimal_places=2, help_text='Total amount in tenant currency', max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))])),
|
||||
('application_fee_amount', models.DecimalField(decimal_places=2, help_text='Platform fee amount (e.g., 5% of total)', max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.00'))])),
|
||||
('currency', models.CharField(default='USD', help_text='ISO 4217 currency code', max_length=3)),
|
||||
('status', models.CharField(choices=[('PENDING', 'Pending'), ('PROCESSING', 'Processing'), ('SUCCEEDED', 'Succeeded'), ('FAILED', 'Failed'), ('REFUNDED', 'Refunded'), ('CANCELED', 'Canceled')], db_index=True, default='PENDING', max_length=20)),
|
||||
('payment_method_id', models.CharField(blank=True, help_text='Stripe PaymentMethod ID', max_length=255)),
|
||||
('locale', models.CharField(default='en', help_text='Language preference for receipts (en/es/fr/de)', max_length=10)),
|
||||
('error_message', models.TextField(blank=True, help_text='Error details if payment failed')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('completed_at', models.DateTimeField(blank=True, help_text='When payment succeeded', null=True)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='schedule.event')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['status', 'created_at'], name='payments_tr_status_2bf70c_idx'), models.Index(fields=['event', 'status'], name='payments_tr_event_i_de0135_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user