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:
poduck
2025-11-27 01:43:20 -05:00
commit 2e111364a2
567 changed files with 96410 additions and 0 deletions

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

View 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 containers 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"
}

View File

@@ -0,0 +1,12 @@
.editorconfig
.gitattributes
.github
.gitignore
.gitlab-ci.yml
.idea
.pre-commit-config.yaml
.readthedocs.yml
.travis.yml
venv
.git
.envs/

View 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

View 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

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

@@ -0,0 +1 @@
* text=auto

70
smoothschedule/.github/dependabot.yml vendored Normal file
View 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
View 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
View 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/

View 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

View File

@@ -0,0 +1 @@
3.13

View 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

View File

@@ -0,0 +1 @@
Smooth Schedule Team

9
smoothschedule/LICENSE Normal file
View 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.

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

@@ -0,0 +1,88 @@
# Smooth Schedule
Multi-Tenant SaaS Resource Orchestration Platform
[![Built with Cookiecutter Django](https://img.shields.io/badge/built%20with-Cookiecutter%20Django-ff69b4.svg?logo=cookiecutter)](https://github.com/cookiecutter/cookiecutter-django/)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](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).

View 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! 🚀**

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CommunicationConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'communication'

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

View 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

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

View File

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

View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

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

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

View 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}\""

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

View 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

View 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

View File

@@ -0,0 +1,7 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
exec make livehtml

View 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

View 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}."

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

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

View File

@@ -0,0 +1,8 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
exec celery -A config.celery_app beat -l INFO

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

View File

@@ -0,0 +1,8 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
exec celery -A config.celery_app worker -l INFO

View 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 "$@"

View 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

View 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

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
BACKUP_DIR_PATH='/backups'
BACKUP_FILE_PREFIX='backup'

View File

@@ -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
}

View File

@@ -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: ${@}"
}

View File

@@ -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
}

View File

@@ -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}'."

View File

@@ -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}"

View File

@@ -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."

View File

@@ -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."

View 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

View 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

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

View 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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,2 @@
# Core app initialization
default_app_config = 'core.apps.CoreConfig'

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

View 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

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

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

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

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

View 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

View 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/')

View 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!")

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

View 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

View 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

View 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

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

View File

@@ -0,0 +1 @@
# Included so that Django's startproject comment runs against the docs directory

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

View 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

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

View 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

View File

@@ -0,0 +1,15 @@
.. _users:
Users
======================================================================
Starting a new project, its 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 youll be able to customize it in the future if the need arises.
.. automodule:: smoothschedule.users.models
:members:
:noindex:

38
smoothschedule/justfile Normal file
View 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}}

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

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

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

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

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

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

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class PaymentsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'payments'

View 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