initial commit

This commit is contained in:
RentFreeMedia 2022-04-06 00:51:03 -05:00
parent a30f7d4a9a
commit 1c6ed0206e
316 changed files with 24223 additions and 0 deletions

416
.gitignore vendored Normal file
View File

@ -0,0 +1,416 @@
# Created by https://www.gitignore.io, modified for use with CodeRed CMS.
#######################################
### Editors
#######################################
### Sublime ###
*.sublime-project
*.sublime-workspace
### Emacs ###
# -*- mode: gitignore; -*-
*~
\#*\#
/.emacs.desktop
/.emacs.desktop.lock
*.elc
auto-save-list
tramp
.\#*
# Org-mode
.org-id-locations
*_archive
# flymake-mode
*_flymake.*
# eshell files
/eshell/history
/eshell/lastdir
# elpa packages
/elpa/
# reftex files
*.rel
# Flycheck
flycheck_*.el
# server auth directory
/server/
# projectiles files
.projectile
# directory configuration
.dir-locals.el
# network security
/network-security.data
### KomodoEdit ###
*.komodoproject
.komodotools
### PyCharm ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Sonarlint plugin
.idea/sonarlint
### SublimeText ###
# 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
### TextMate ###
*.tmproj
*.tmproject
tmtags
### Vim ###
# Swap
[._]*.s[a-v][a-z]
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]
# Session
Session.vim
# Temporary
.netrwhist
# Auto-generated tag files
tags
# Persistent undo
[._]*.un~
### VisualStudioCode ###
.vscode/
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
#######################################
### Django/Python Stack
#######################################
### Django ###
*.log
*.pot
*.pyc
__pycache__/
local_settings.py
db.sqlite3
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
# in your Git repository. Update and uncomment the following line accordingly.
# <django-project-name>/staticfiles/
### Django.Python Stack ###
# Byte-compiled / optimized / DLL files
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# 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/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
siteenv/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
### OSX ###
# 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
#######################################
### Operating Systems
#######################################
### Windows ###
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
#######################################
### CMS
#######################################
#### CMS defaults ###
# Cache
cache/
# File uploads from forms
protected/
# if you want to store original uploaded media files in version control,
# replace "media/" with "media/images/"
media/
#media/images/
# development static folder
/rentfreemedia/static
# development images folder
original_images/
images/
# development bin folders
.django/
sqlite3.db

89
README.md Normal file
View File

@ -0,0 +1,89 @@
## Rent Free Media
RentFree Media is a media distribution frameowrk built on Django and Wagtail. With it you can publish either public or premium / subscription-based content, as services like Patreon, Apple Podcasts, and Substack do, for example.
### Summary of Features
* Your media distribution tools also become your brand's website. Media objects can also be embedded on your website's pages if you like
* Click-able block-level CSS styling: add CSS classes to individual template blocks without getting into the code for minor style adjustments
* The base templates are plain ole HTML and CSS. No javascript required to customize your site design, unless you want there to be.
* Base templates are based on Bootstrap 5. You can customize your whole site design with a custom header, custom footer, and bootstrap.css modifications.
* Dismissable content walls / "paywalls" if you choose to employ them, defined at the individual page level.
* Full text site-wide search, enabled by default
* Customize ***anything*** by user tier. Subscription tier filters are included out of the box, and custom tier filters can be defined in python code and mixed, matched, or combined in any way you see fit.
* Premium authenticated RSS feeds if you are publishing articles as one would do on Substack, or podcasts / video casts as one would do on Patreon, with secure links to paid subscription content.
* RSS feeds for podcasts and video casts are configurable for most use cases. Define serial or episode-type feeds. Selectively include your promos of paid episodes... or not. Host your public episodes remotely... or not. Include your public feed combined with your paid feed for paying users... or not.
* Write your notes and / or articles in WYSIWYG rich text or Markdown, your choice. Our customized Markdown library produces Chicago-style footnotes which work in iTunes.
* Premium downloads are audit-able and those audits action-able. Revoke a publicly posted premium link based on download stats with a single click in the admin panel.
* Rule-based email marketing tools, send templated email to your users by any user data you can define, without writing any code. 'Unsubscribe' links are handled automatically
* Stripe integration for subscription payments tied to premium content, including Stripe features like promo / coupon codes
* AJAX user comments, along with moderation tools, by whatever rules you choose. Host comments in your own database for only paying users, or only signed-up users, or everyone in the world.
* Professional content collaboration tools. You can have writers who need an editor's permission to publish, and editors who need an admin's permission to publish... or none of the above... or some of the above... configure your permissions how you like them.
* 2 factor authentication, available to all users and enforceable on anyone with admin access if you choose.
* Google analytics integration, down to the link-level. Define tracked links and buttons right in the page editor, no need to write code.
* JSON+LD SEO schema integration done right, out of the box, automatically. Enable and define the settings and they "just work."
* A cache based on [wagtail-cache](https://docs.coderedcorp.com/wagtail-cache/) with support for all Django cache backends. Use the local disk, or Redis, or Postgres, or Memcached if you prefer. All unauthenticated requests are cached out of the box, so Google, Apple, and anonymous users won't beat up your database.
Particular thanks not only to the Wagtail core developers and the developers of all of the third party libraries we use, but also specifically to [CodeRedCorp](https://www.coderedcorp.com) for open-sourcing their Wagtail projects, it is from their examples that most of the base-level page design of this project is derived. And also particularly to [Kalob Taulien](https://github.com/KalobTaulien) for his wonderful Wagtail development tutorials.
### Things you will need
1. A web host (virtual server or bare metal)
2. A Stripe account, for payment processing
3. An email service for invoices, user registration confirmation, and other such typical things
4. A storage service such as Digital Ocean Spaces, AWS S3, Backblaze, etc for storing your content
5. A laptop / desktop to run the deployment scripts on
That's it! After filling in the blanks you will be *your own* premium media distribution service, without the (egregious) fees of the above mentioned publishing services.
### Deployment
Serving content to paying customers is not trivial to do securely and robustly. Your own Nginx installation is required to do this, as each request for premium content must first be authenticated, and then fetched from storage and routed back to the user. We cache media files on the server and thus the storage service is "just storage" for the Nginx reverse proxy in front of Django to serve end users through. Media files are cached by Nginx on the server's local disk to minimize traffic between the front end and the back end.
Ansible scripts are provided in the ansible folder for automated deployment to Digital Ocean, which as a cloud service is particularly well suited to host this project because of their generous download bandwidth pricing.
Let's consider the math in terms of a Digital Ocean deployment:
Presume that you have 20,000 paying customers who download your weekly (4 times a month) premium-user podcast which weighs in at 100mb for a one hour long MP3 file. Presume also that on average, each of your 20,000 paying customers downloads the episodes on three different devices.
20,000 x 3 x 4 = 240,000 downloads a month
240,000 x 100mb = 24,000,000 megabytes per month downloaded
24,000,000 mb / 1024 = 23,437 gigabytes
23,437 x $0.01 per gigabyte = $234.38
Even if we don't manage to convert the world with this project, we would hope to impress upon people that serving media is not worth 10% or 18% or 25% or 30% of your gross receipts, as other media distribution "services" seem to think by virtue of their pricing. The cloud service seems to think that it's worth $0.01 per gigabyte, and you should be looking to pay accordingly for this sort of thing.
### License
AGPL, because you are free to use this code as you see fit to publish your own content. Or even provide custom code based on this to others for a fee, if you are a developer and wish to work as a media hosting consultancy, for example, provided that you also release the source code you have added or changed. What you're not free to do is use this repository's original code as a basis for a closed-source "service"... like Patreon or Substack. The point of all this is to have less of them, not enable more of them.
### Contributing and Local Development
PRs are welcome! Please discuss new features you would like to see in the [discussions](https://github.com/RentFreeMedia/rentfreemedia/discussions) area so that we can keep the issues forum prioritized for bug reports.
While this is a project released by two people and largely written by one guy in his spare time, I don't want to be inaccesible or standoff-ish to users. It is our hope that this project grows and thrives in spirit of open source, and users not only break free from their corporate publishers but also help others do the same. As Wagtail is a CMS that sits on top of, in front of, Django... feature proposals should integrate with Wagtail. While it's possible to do anything in code, doing it with future maintainability in mind is also a big consideration. Would-be contributors and custom solution developers would be wise to not only thoroughly read the [Wagtail docs](https://docs.wagtail.org), but also read the Wagtail code itself.
All that said, the code should work with the Django development server, with some caveats:
* Premium media will not 'play' directly without Nginx to respond to the X-Sendfile request. You'll see 200 response codes for them in the console / logs after they successfully authenticate, though.
* There are some complex queries in the premium media RSS feeds that only work with PostgreSQL. As of this writing SQLite and MySQL do not support `distinct('field_name')` and thus will not work with this distribution in production. There is an error check against the payment app `views.py` in dev mode that will allow SQLite to work in dev mode, but with some caveats. In short, you must use Postgres in production and the "subscribe" page in dev mode may have duplicate entries on SQLite.
* The code should run fine on Linux and Mac (as well as any other BSD Unix) but I don't test against Windows, so let us know if you have any Windows issues / solutions.
Otherwise, to run the project locally:
1. Download and unzip the repo. The "main" branch should always be stable, the "dev" branch should be the most recent.
2. Edit `env` in the root of the rentfree directory and provide the required settings, then save the edited file as `.env`. Remote storage options are not required for development mode, it will serve the media files and static files from your local machine. At minimum, specify email server info, stripe account sandbox public/private key and webhook secret, the base_url of 127.0.0.1, and the human readable site name.
3. Make a virtual environment (`python3 -m venv ~/rentfreelibs`)
4. Activate the virtual environment (`~/rentfreelibs/bin/activate`)
5. Edit `manage.py` and set the settings target to "dev" instead of "prod"
6. Edit `website/wsgi.py` and set the settings target to "dev" instead of "prod"
7. `pip install -r requirements.txt`
8. `pip install django-debug-toolbar`
9. `python3 manage.py makemigrations && python3 manage.py migrate`
10. `python3 manage.py createsuperuser`
11. `python3 manage.py runserver`
You should now be up and running on http://127.0.0.1:8000

View File

@ -0,0 +1,225 @@
- name: Gathering facts...
ansible.builtin.setup:
register: rentfreedb_facts
delay: 5
retries: 3
until: rentfreedb_facts.ansible_facts
- name: Install PostgreSQL, s3cmd, ssmtp, logwatch, and various dependencies...
ansible.builtin.apt:
name: "{{ item.value }}"
update_cache: True
delay: 5
retries: 3
register: rentfreedb_install
until: rentfreedb_install.failed == False
loop:
- { value: "postgresql" }
- { value: "s3cmd" }
- { value: "python3-psycopg2" }
- { value: "logwatch" }
- { value: "ssmtp" }
- name: Get installed PostgreSQL server version...
ansible.builtin.shell: "/bin/ls /etc/postgresql | awk '{print $1}'"
delay: 5
retries: 3
register: ansible_pgsqlver
until: ansible_pgsqlver.failed == False
- name: Get time zone...
ansible.builtin.command: "cat /etc/timezone"
delay: 5
retries: 3
register: ans_timezone
until: ans_timezone.failed == False
- name: Detected values are...
ansible.builtin.debug:
msg: "Server time zone: {{ ans_timezone.stdout }}, number of CPUs: {{ ansible_processor_vcpus }}, Memory in megabytes: {{ ansible_memtotal_mb }}, IP address: {{ ansible_default_ipv4.address }}, PostgreSQL version: {{ ansible_pgsqlver.stdout }}"
- name: Set PostgreSQL variables if number of server CPUs is more than 8...
ansible.builtin.include_vars: vars/large.yml
when: ansible_processor_vcpus >= 8
- name: Set PostgreSQL variables if number of server CPUs is between 4 and 8...
ansible.builtin.include_vars: vars/medium.yml
when: ansible_processor_vcpus >= 4 and ansible_processor_vcpus < 8
- name: Set PostgreSQL variables if number of server CPUs is less than 4...
ansible.builtin.include_vars: vars/small.yml
when: ansible_processor_vcpus < 4
- name: Set PostgreSQL config variables...
community.general.ini_file:
dest: "/etc/postgresql/{{ ansible_pgsqlver.stdout }}/main/postgresql.conf"
section: null
option: "{{ item.option }}"
value: "{{ item.value }}"
backup: "{{ true if index == 0 else false }}"
create: False
loop:
- { option: 'max_worker_processes', value: '{{ max_parallel_worker_cpus }}' }
- { option: 'max_parallel_workers', value: '{{ max_parallel_worker_cpus }}' }
- { option: 'max_parallel_workers_per_gather', value: '{{ max_parallel_gather_cpus }}' }
- { option: 'max_connections', value: '100' }
- { option: 'shared_buffers', value: '{{ (ansible_memtotal_mb|int * 0.25) | round | int }}MB' }
- { option: 'effective_cache_size', value: '{{ (ansible_memtotal_mb|int * 0.75) | round | int }}MB' }
- { option: 'maintenance_work_mem', value: '{{ ((ansible_memtotal_mb|int / 16) * 1024) | round | int }}kB' }
- { option: 'checkpoint_completion_target', value: '0.9' }
- { option: 'wal_buffers', value: '16MB' }
- { option: 'default_statistics_target', value: '100' }
- { option: 'random_page_cost', value: '1.1' }
- { option: 'effective_io_concurrency', value: '200' }
- { option: 'min_wal_size', value: '1GB' }
- { option: 'max_wal_size', value: '{{ (ansible_memtotal_mb|int / 1024) | round | int }}GB' }
- { option: 'work_mem', value: '{{ pgsql_work_mem }}kB' }
- { option: 'listen_addresses', value: "'localhost, {{ ansible_private_ip_addr }}'"}
loop_control:
index_var: index
register: postgresql_conf
delay: 5
retries: 3
until: postgresql_conf.failed == False
- name: Set max_parallel_maintenance_workers for PostgreSQL if server v11 or v12...
community.general.ini_file:
dest: "/etc/postgresql/{{ ansible_pgsqlver.stdout }}/main/postgresql.conf"
option: max_parallel_maintenance_workers
section: null
value: "{{ max_parallel_gather_cpus }}"
create: False
when: (ansible_pgsqlver.stdout == "11") or
(ansible_pgsqlver.stdout == "12") or
(ansible_pgsqlver.stdout == "13") or
(ansible_pgsqlver.stdout == "14")
register: postgresql_cpu_conf
delay: 5
retries: 3
until: postgresql_cpu_conf.failed == False
- name: Add VPC connection for PostgreSQL...
ansible.builtin.replace:
path: /etc/postgresql/{{ ansible_pgsqlver.stdout }}/main/pg_hba.conf
after: '# TYPE'
before: '# Allow replication'
regexp: '^(local\s*?)(all\s*?)(all\s*)(peer)$'
replace: 'host webdb webuser 192.168.1.0/24 md5'
backup: yes
register: postgresql_vpc_conf
delay: 5
retries: 3
until: postgresql_vpc_conf.failed == False
- name: Unset PostgreSQL parallel processing settings for servers with only one CPU, if server v11 or v12...
ansible.builtin.replace:
path: "/etc/postgresql/{{ ansible_pgsqlver.stdout }}/main/postgresql.conf"
after: "Asynchronous Behavior"
regexp: "{{ item.regexp }}"
replace: "{{ item.replace }}"
loop:
- { regexp: '^.*?max_worker_processes.*?$', replace: '#max_worker_processes = 8'}
- { regexp: '^.*?max_parallel_workers_per_gather.*?$', replace: '#max_parallel_workers_per_gather = 2'}
- { regexp: '^.*?max_parallel_workers.*?$', replace: '#max_parallel_workers = 8'}
- { regexp: '^.*?max_parallel_maintenance_workers.*?$', replace: '#max_parallel_maintenance_workers = 2'}
when: ansible_processor_vcpus < 2
register: postgresql_one_cpu_twelve_conf
delay: 5
retries: 3
until: postgresql_one_cpu_twelve_conf.failed == False
- name: Create required PostgreSQL users with a random password...
vars:
webdb_pass: "{{ lookup('password', '~/ansible-webuser-database-password.txt length=32 chars=ascii_letters,digits') }}"
become: True
become_user: postgres
community.general.postgresql_user:
login_unix_socket: /var/run/postgresql/
db: "{{ item.name }}"
name: "{{ item.user }}"
password: "{{ item.password }}"
priv: "{{ item.privs }}"
role_attr_flags: "{{ item.flags }}"
loop:
- { name: 'postgres', user: 'webuser', password: "{{ webdb_pass }}", privs: "CONNECT", flags: "NOINHERIT" }
register: pgsql_user
delay: 5
retries: 3
until: pgsql_user.failed == False
no_log: True
- name: Create PostgreSQL website database...
become: True
become_user: postgres
community.general.postgresql_db:
login_unix_socket: /var/run/postgresql/
name: webdb
encoding: UTF-8
owner: webuser
template: "template0"
register: webdb_create
delay: 5
retries: 3
until: webdb_create.failed == False
- name: Restart PostgreSQL...
ansible.builtin.systemd:
name: postgresql
state: restarted
delay: 5
retries: 3
register: postgresql_restart
until: postgresql_restart.failed == False
- name: Create s3cmd config file for root user on database droplet for database backups...
ansible.builtin.template:
src: templates/s3cfg.j2
dest: /root/.s3cfg
owner: root
group: sudo
mode: 0600
register: postgresql_s3cmd_setup
delay: 5
retries: 3
until: postgresql_s3cmd_setup.failed == False
no_log: True
- name: Create daily database backup script on database droplet...
ansible.builtin.template:
src: templates/pg_backup.j2
dest: /usr/local/bin/pg_backup
owner: root
group: root
mode: 0750
register: postgresql_backup_script
delay: 5
retries: 3
until: postgresql_backup_script.failed == False
no_log: True
- name: Create daily database backup cron job on database droplet...
ansible.builtin.cron:
name: database_backup
minute: "5"
hour: "6"
user: root
job: "/usr/local/bin/pg_backup >/dev/null 2>&1"
register: postgresql_cron_backup
delay: 5
retries: 3
until: postgresql_cron_backup.failed == False
no_log: True
- name: Set up Logwatch cron job...
ansible.builtin.lineinfile:
dest: /etc/cron.daily/00logwatch
regexp: "^/usr/sbin/logwatch"
line: "/usr/sbin/logwatch --mailto {{ ansible_email_addr }}"
state: present
create: True
register: logwatch_config
delay: 5
retries: 3
until: logwatch_config.failed == False
no_log: True

View File

@ -0,0 +1,12 @@
#!/bin/sh
/usr/bin/su postgres -c "/usr/bin/pg_dump webdb | gzip -9 > /var/lib/postgresql/pgsql_backup.$(date +"%a").sql.gz"
/usr/bin/s3cmd del s3://{{ ansible_db_backup_bucket }}/pgsql_backup.$(date +"%a").sql.gz
/usr/bin/s3cmd put /var/lib/postgresql/pgsql_backup.$(date +"%a").sql.gz s3://{{ ansible_db_backup_bucket }}/pgsql_backup.$(date +"%a").sql.gz
su postgres -c "/bin/rm -f /var/lib/postgresql/pgsql_backup.$(date +"%a").sql.gz"
exit 0

View File

@ -0,0 +1,77 @@
[default]
access_key = {{ ansible_do_spaces_accesskey }}
access_token =
add_encoding_exts =
add_headers =
bucket_location = US
ca_certs_file =
cache_file =
check_ssl_certificate = True
check_ssl_hostname = True
cloudfront_host = cloudfront.amazonaws.com
content_disposition =
content_type =
default_mime_type = binary/octet-stream
delay_updates = False
delete_after = False
delete_after_fetch = False
delete_removed = False
dry_run = False
enable_multipart = True
encoding = UTF-8
encrypt = False
expiry_date =
expiry_days =
expiry_prefix =
follow_symlinks = False
force = False
get_continue = False
gpg_command = /usr/bin/gpg
gpg_decrypt = %(gpg_command)s -d --verbose --no-use-agent --batch --yes --passphrase-fd %(passphrase_fd)s -o %(output_file)s %(input_file)s
gpg_encrypt = %(gpg_command)s -c --verbose --no-use-agent --batch --yes --passphrase-fd %(passphrase_fd)s -o %(output_file)s %(input_file)s
gpg_passphrase =
guess_mime_type = True
host_base = {{ ansible_do_region }}.digitaloceanspaces.com
host_bucket = %(bucket)s.{{ ansible_do_region }}.digitaloceanspaces.com
human_readable_sizes = False
invalidate_default_index_on_cf = False
invalidate_default_index_root_on_cf = True
invalidate_on_cf = False
kms_key =
limit = -1
limitrate = 0
list_md5 = False
log_target_prefix =
long_listing = False
max_delete = -1
mime_type =
multipart_chunk_size_mb = 15
multipart_max_chunks = 10000
preserve_attrs = True
progress_meter = True
proxy_host =
proxy_port = 0
put_continue = False
recursive = False
recv_chunk = 65536
reduced_redundancy = False
requester_pays = False
restore_days = 1
restore_priority = Standard
secret_key = {{ ansible_do_spaces_secretkey }}
send_chunk = 65536
server_side_encryption = False
signature_v2 = False
signurl_use_https = False
simpledb_host = sdb.amazonaws.com
skip_existing = False
socket_timeout = 300
stats = False
stop_on_error = False
storage_class =
throttle_max = 100
upload_id =
urlencoding_mode = normal
use_http_expect = False
use_https = True
use_mime_magic = True

View File

@ -0,0 +1,3 @@
max_parallel_worker_cpus: "{{ ansible_processor_vcpus }}"
max_parallel_gather_cpus: "4"
pgsql_work_mem: "{{ (((ansible_memtotal_mb|int * 1024) / (ansible_processor_vcpus|int * 100 )) / ansible_processor_vcpus|int) | round | int }}"

View File

@ -0,0 +1,3 @@
max_parallel_worker_cpus: "{{ ansible_processor_vcpus|int | round | int }}"
max_parallel_gather_cpus: "{{ max_parallel_worker_cpus|int | round | int }}"
pgsql_work_mem: "{{ (((ansible_memtotal_mb|int * 1024) / (ansible_processor_vcpus|int * 100 )) / ansible_processor_vcpus|int) | round | int }}"

View File

@ -0,0 +1,3 @@
max_parallel_worker_cpus: "{{ ansible_processor_vcpus }}"
max_parallel_gather_cpus: "1"
pgsql_work_mem: "{{ (((ansible_memtotal_mb|int * 1024) / (ansible_processor_vcpus|int * 100 )) / ansible_processor_vcpus|int) | round | int }}"

View File

@ -0,0 +1,135 @@
- name: Enable rentfree gunicorn systemd service...
ansible.builtin.command: "/usr/bin/systemctl enable gunicorn.service --user"
become: True
become_user: rentfree
args:
chdir: /home/rentfree/.config/systemd/user
environment:
XDG_RUNTIME_DIR: "/run/user/1000"
register: rentfree_systemd_enable
retries: 3
delay: 5
until: rentfree_systemd_enable.failed == False
- name: Snapshot the database droplet in case we need to restore it in the future...
community.digitalocean.digital_ocean_snapshot:
oauth_token: "{{ ansible_do_apikey }}"
snapshot_name: "{{ ansible_dbserver_name }}-installed"
state: present
snapshot_type: droplet
droplet_id: "{{ ansible_do_dbdroplet_id }}"
register: postgresql_server_snapshot
delay: 5
retries: 3
until: postgresql_server_snapshot.failed == False
no_log: True
- name: Snapshot the webserver droplet in case we need to restore it in the future...
community.digitalocean.digital_ocean_snapshot:
oauth_token: "{{ ansible_do_apikey }}"
snapshot_name: "{{ ansible_do_webserver_name }}-installed"
state: present
snapshot_type: droplet
droplet_id: "{{ ansible_do_webdroplet_id }}"
register: postgresql_server_snapshot
delay: 5
retries: 3
until: postgresql_server_snapshot.failed == False
no_log: True
- name: Create a firewall and put our database in it, only allow incoming traffic from the VPC...
community.digitalocean.digital_ocean_firewall:
oauth_token: "{{ ansible_do_apikey }}"
name: "{{ ansible_dbserver_name }}-firewall"
state: present
inbound_rules:
- protocol: "tcp"
ports: "22"
sources:
addresses: ["192.168.1.0/24"]
droplet_ids: ["{{ ansible_do_dbdroplet_id }}"]
- protocol: "tcp"
ports: "5432"
sources:
addresses: ["192.168.1.0/24"]
droplet_ids: ["{{ ansible_do_dbdroplet_id }}"]
outbound_rules:
- protocol: "tcp"
ports: "1-65535"
destinations:
addresses: ["0.0.0.0/0", "::/0"]
- protocol: "udp"
ports: "1-65535"
destinations:
addresses: ["0.0.0.0/0", "::/0"]
- protocol: "icmp"
ports: "1-65535"
destinations:
addresses: ["0.0.0.0/0", "::/0"]
droplet_ids: ["{{ ansible_do_dbdroplet_id }}"]
register: rentfree_dodbfirewall
delay: 5
retries: 3
until: rentfree_dodbfirewall.failed == False
no_log: True
- name: Create another firewall and put our web server in it, only allow incoming traffic from 80, 22, and 443...
community.digitalocean.digital_ocean_firewall:
oauth_token: "{{ ansible_do_apikey }}"
name: "{{ ansible_do_webserver_name }}-firewall"
state: present
inbound_rules:
- protocol: "tcp"
ports: "22"
sources:
addresses: ["0.0.0.0/0", "::/0"]
droplet_ids: ["{{ ansible_do_webdroplet_id }}"]
- protocol: "tcp"
ports: "80"
sources:
addresses: ["0.0.0.0/0", "::/0"]
droplet_ids: ["{{ ansible_do_webdroplet_id }}"]
- protocol: "tcp"
ports: "443"
sources:
addresses: ["0.0.0.0/0", "::/0"]
droplet_ids: ["{{ ansible_do_webdroplet_id }}"]
outbound_rules:
- protocol: "tcp"
ports: "1-65535"
destinations:
addresses: ["0.0.0.0/0", "::/0"]
- protocol: "udp"
ports: "1-65535"
destinations:
addresses: ["0.0.0.0/0", "::/0"]
- protocol: "icmp"
ports: "1-65535"
destinations:
addresses: ["0.0.0.0/0", "::/0"]
droplet_ids: ["{{ ansible_do_webdroplet_id }}"]
register: rentfree_dowebfirewall
delay: 5
retries: 3
until: rentfree_dowebfirewall.failed == False
no_log: True
- name: Issue SSL certificates for our domain name with Certbot...
ansible.builtin.command:
cmd: "/snap/bin/certbot certonly -d {{ ansible_do_hostname }} -d *.{{ ansible_do_hostname }} --non-interactive"
register: certbot_issue
retries: 3
delay: 5
until: certbot_issue.failed == False
when: certbot_check.stat.exists == False
- name: Restart Nginx...
ansible.builtin.systemd:
name: nginx
state: restarted
register: nginx_restart
delay: 5
retries: 3
until: nginx_restart.failed == False
when: certbot_issue.changed == True

View File

@ -0,0 +1,70 @@
# .bash_profile
# Get the aliases and functions
if [ -f ~/.bashrc ]; then
. ~/.bashrc
fi
# User specific environment and startup programs
PATH=$PATH:$HOME/bin
export PATH
# Change the window title of X terminals
case ${TERM} in
xterm*|rxvt*|Eterm|aterm|kterm|gnome*|interix)
PROMPT_COMMAND='echo -ne "\033]0;${USER}@${HOSTNAME%%.*}:${PWD/$HOME/~}\007"'
;;
screen)
PROMPT_COMMAND='echo -ne "\033_${USER}@${HOSTNAME%%.*}:${PWD/$HOME/~}\033\\"'
;;
esac
use_color=true
# Set colorful PS1 only on colorful terminals.
# dircolors --print-database uses its own built-in database
# instead of using /etc/DIR_COLORS. Try to use the external file
# first to take advantage of user additions. Use internal bash
# globbing instead of external grep binary.
safe_term=${TERM//[^[:alnum:]]/?} # sanitize TERM
match_lhs=""
[[ -f ~/.dir_colors ]] && match_lhs="${match_lhs}$(<~/.dir_colors)"
[[ -f /etc/DIR_COLORS ]] && match_lhs="${match_lhs}$(</etc/DIR_COLORS)"
[[ -z ${match_lhs} ]] \
&& type -P dircolors >/dev/null \
&& match_lhs=$(dircolors --print-database)
[[ $'\n'${match_lhs} == *$'\n'"TERM "${safe_term}* ]] && use_color=true
if ${use_color} ; then
# Enable colors for ls, etc. Prefer ~/.dir_colors #64489
if type -P dircolors >/dev/null ; then
if [[ -f ~/.dir_colors ]] ; then
eval $(dircolors -b ~/.dir_colors)
elif [[ -f /etc/DIR_COLORS ]] ; then
eval $(dircolors -b /etc/DIR_COLORS)
fi
fi
if [[ ${EUID} == 0 ]] ; then
PS1='\[\033[01;31m\]\h\[\033[01;34m\] \W \$\[\033[00m\] '
else
PS1='\[\033[01;32m\]\u@\h\[\033[01;34m\] \w \$\[\033[00m\] '
fi
CLICOLOR="YES"; export CLICOLOR
LSCOLORS="ExGxFxdxCxDxDxhbadExEx"; export LSCOLORS
#alias ls='ls --color=auto'
#alias grep='grep --colour=auto'
else
if [[ ${EUID} == 0 ]] ; then
# show root@ when we don't have colors
PS1='\u@\h \W \$ '
else
PS1='\u@\h \w \$ '
fi
fi
# Try to keep environment pollution down, EPA loves us.
unset use_color safe_term match_lhs

View File

@ -0,0 +1,8 @@
key-type = ecdsa
elliptic-curve = secp384r1
email = {{ ansible_email_addr }}
authenticator = dns-digitalocean
dns-digitalocean-credentials = /etc/letsencrypt/digital_ocean.ini
agree-tos = true
post-hook = /usr/sbin/service nginx restart
renew-hook = /usr/sbin/service nginx restart

View File

@ -0,0 +1 @@
dns_digitalocean_token = {{ ansible_do_apikey }}

View File

@ -0,0 +1 @@
{{ ansible_do_hostname }}

View File

@ -0,0 +1,2 @@
127.0.0.1 {{ domain_name }} {{ host_name }} localhost localhost.localdomain localhost4 localhost4.localdomain4
::1 {{ domain_name }} {{ host_name }} localhost localhost.localdomain localhost6 localhost6.localdomain6

View File

@ -0,0 +1,23 @@
/home/rentfree/logs/access.log {
daily
rotate 7
missingok
notifempty
create 0644 rentfree rentfree
postrotate
/usr/bin/kill -HUP 'cat /home/rentfree/run/gunicorn.pid'
endscript
nocompress
}
/home/rentfree/logs/error.log {
daily
rotate 7
missingok
notifempty
create 0644 rentfree rentfree
postrotate
/usr/bin/kill -HUP 'cat /home/rentfree/run/gunicorn.pid'
endscript
nocompress
}

View File

@ -0,0 +1,5 @@
add_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload;";
add_header X-Permitted-Cross-Domain-Policies none always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Frame-Options "SAMEORIGIN" always;

View File

@ -0,0 +1,35 @@
user www-data www-data;
worker_processes auto;
pid /var/run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;
events {
worker_connections 1024;
multi_accept on;
use epoll;
}
http {
server_names_hash_bucket_size 64;
proxy_cache_path /var/cache/nginx keys_zone=media-files:4m max_size=10g inactive=1440m use_temp_path=off;
set_real_ip_from {{ ansible_private_ip_addr }};
real_ip_header X-Forwarded-For;
real_ip_recursive on;
include /etc/nginx/mime.types;
include /etc/nginx/proxy.conf;
include /etc/nginx/optimization.conf;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log warn;
sendfile on;
send_timeout 3600;
tcp_nopush on;
tcp_nodelay on;
open_file_cache max=500 inactive=10m;
open_file_cache_errors on;
keepalive_timeout 65;
reset_timedout_connection on;
server_tokens off;
resolver 127.0.0.53 valid=30s;
resolver_timeout 5s;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*.conf;
}

View File

@ -0,0 +1,7 @@
gzip on;
gzip_vary on;
gzip_comp_level 4;
gzip_min_length 256;
gzip_proxied expired no-cache no-store private no_last_modified no_etag auth;
gzip_types application/atom+xml application/javascript application/json application/ld+json application/manifest+json application/rss+xml application/vnd.geo+json application/vnd.ms-fontobject application/x-font-ttf application/x-web-app-manifest+json application/xhtml+xml application/xml font/opentype image/bmp image/svg+xml image/x-icon text/cache-manifest text/css text/plain text/vcard text/vnd.rim.location.xloc text/vtt text/x-component text/x-cross-domain-policy;
gzip_disable "MSIE [1-6]\.";

View File

@ -0,0 +1,11 @@
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Protocol $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Server $host;
proxy_connect_timeout 75s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_redirect off;

View File

@ -0,0 +1,52 @@
server {
server_name {{ ansible_do_hostname }};
listen 80 default_server;
listen [::]:80 default_server;
location / {
return 301 https://$host$request_uri;
}
}
server {
server_name {{ ansible_do_hostname }};
listen 443 ssl http2 proxy_protocol default_server;
listen [::]:443 ssl http2 proxy_protocol default_server;
client_max_body_size 300M;
include /etc/nginx/ssl.conf;
include /etc/nginx/header.conf;
location / {
proxy_force_ranges on;
proxy_read_timeout 300s;
proxy_pass http://127.0.0.1:8787;
}
location /favicon.ico {
access_log off; log_not_found off;
}
location ~ ^/media_download/(.*?)/(.*?)/(.*) {
internal;
resolver 8.8.8.8 1.1.1.1 9.9.9.9 127.0.0.1 ipv6=off;
set $download_protocol $1;
set $download_host $2;
set $download_path $3;
set $download_url $download_protocol://$download_host/$download_path;
proxy_set_header Host $download_host;
proxy_set_header Authorization '';
proxy_set_header Cookie '';
proxy_hide_header x-amz-request-id;
proxy_hide_header x-amz-id-2;
proxy_cache media-files;
proxy_cache_key $scheme$proxy_host$download_path;
proxy_cache_valid 1440m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_revalidate on;
proxy_ignore_headers Set-Cookie;
add_header X-Cache-Status $upstream_cache_status;
proxy_pass $download_url$is_args$args;
proxy_intercept_errors on;
error_page 301 302 307 = @handle_redirect;
}
location @handle_redirect {
resolver 8.8.8.8 1.1.1.1 9.9.9.9 127.0.0.1 ipv6=off;
set $saved_redirect_location '$upstream_http_location';
proxy_pass $saved_redirect_location;
}
}

View File

@ -0,0 +1,11 @@
ssl_certificate /etc/letsencrypt/live/{{ ansible_do_hostname }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ ansible_do_hostname }}/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/{{ ansible_do_hostname }}/chain.pem;
ssl_dhparam /etc/ssl/dhparam.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_ecdh_curve X448:secp521r1:secp384r1:prime256v1;
ssl_prefer_server_ciphers on;

View File

@ -0,0 +1,14 @@
[databases]
{{ ansible_dbserver_name }} = host={{ ansible_dbserver_host }} port=5432 dbname=webdb user=webuser password={{ webdb_pass }}
[pgbouncer]
listen_addr = *
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 100
default_pool_size = 20
unix_socket_dir = /run/postgresql
logfile = /var/log/postgresql/pgbouncer.log
pidfile = /var/run/postgresql/pgbouncer.pid

View File

@ -0,0 +1 @@
"pgbuser" "{{ pgbdb_pass }}"

View File

@ -0,0 +1,38 @@
DJANGO_SUPERUSER_EMAIL={{ ansible_email_addr }}
DJANGO_SUPERUSER_PASSWORD={{ ansible_email_pass }}
DJANGO_SUPERUSER_IS_ACTIVE=True
DOSECRET_KEY={{ lookup('password', '~/ansible-django-secret-key.txt length=50 chars=ascii_letters,digits,punctuation') }}
DOEMAIL_HOST={{ smtp_srv }}
DOEMAIL_PORT={{ smtp_port }}
DOEMAIL_USER={{ smtp_user }}
DOEMAIL_PASS={{ smtp_pass }}
DOEMAIL_TLS=True
DOEMAIL_ADMIN=Admin
DOEMAIL_ADDR={{ ansible_email_addr }}
DOBASE_URL={{ ansible_do_hostname }}
DOACCESS_KEY_ID={{ ansbile_do_s3_accesskey }}
DOSECRET_ACCESS_KEY={{ ansible_do_s3_secretkey }}
DOPUB_BUCKET={{ ansible_do_pub_bucket }}
DOPRIV_BUCKET={{ ansible_do_priv_bucket }}
DODEFAULT_ACL=private
DOPROVIDER_URL=digitaloceanspaces.com
DOCDN_URL={{ ansible_do_cdn_hostname }}
DOAWS_REGION={{ ansible_do_region }}
DOAWS_SIGVER=s3
DOBUCKET_ZIP=True
DOTIME_ZONE=America/Chicago
DOSITE_NAME={{ anislbe_do_sitename }}
DOPROJECT_NAME=website
DODB_NAME={{ ansible_dbserver_name }}
DODB_USER=pgbuser
DODB_PASS={{ lookup('password', '~/ansible-pgbuser-database-password.txt length=32 chars=ascii_letters,digits') }}
DODB_HOST=
DODB_PORT=6432
DOTIME_ZONES=US/Eastern,US/Central,US/Mountain,US/Arizona,US/Pacific,US/Hawaii,UTC
DOSTRIPE_TESTPUB={{ stripe_test_publickey.user_input }}
DOSTRIPE_TESTKEY={{ stripe_test_secretkey.user_input }}
DOSTRIPE_LIVEPUB=
DOSTRIPE_LIVEKEY=
DOSTRIPE_LIVE=False
DOSTRIPE_WHKEY={{ stripe_test_whkey.user_input }}
DOBSTRAP_URL=

View File

@ -0,0 +1,7 @@
# sSMTP aliases
#
# Format: local_account:outgoing_address:mailhub
#
# Example: root:your_login@your.domain:mailhub.your.domain[:port]
# where [:port] is an optional port number that defaults to 25.
root:{{ ansible_email_addr }}

View File

@ -0,0 +1,28 @@
#
# Config file for sSMTP sendmail
#
# The person who gets all mail for userids < 1000
# Make this empty to disable rewriting.
root={{ ansible_email_addr }}
# The place where the mail goes. The actual machine name is required no
# MX records are consulted. Commonly mailhosts are named mail.domain.com
mailhub={{ smtp_srv }}:{{ smtp_port }}
# Where will the mail seem to come from?
rewriteDomain={{ ansible_maildomain }}
# The full hostname
hostname={{ ansible_do_hostname }}
# Are users allowed to set their own From: address?
# YES - Allow the user to specify their own From: address
# NO - Use the system generated From: address
FromLineOverride=NO
AuthUser={{ smtp_user }}
AuthPass={{ smtp_pass }}
AuthMethod=LOGIN
TLS_CA_FILE=/etc/ssl/certs/ca-certificates.crt
UseTLS={{ smtp_tls}}
UseSTARTTLS={{ smtp_starttls }}

View File

@ -0,0 +1,7 @@
smtp_srv: "smtp.aol.com"
smtp_port: "587"
smtp_tls: "Yes"
smtp_starttls: "Yes"
smtp_from: "{{ ansible_email_addr }}"
smtp_user: "{{ ansible_email_addr }}"
smtp_pass: "{{ ansible_email_pass }}"

View File

@ -0,0 +1,7 @@
smtp_srv: "smtp.gmail.com"
smtp_port: "587"
smtp_tls: "Yes"
smtp_starttls: "Yes"
smtp_from: "{{ ansible_email_addr }}"
smtp_user: "{{ ansible_email_addr }}"
smtp_pass: "{{ ansible_email_pass }}"

View File

@ -0,0 +1,7 @@
smtp_srv: "smtp.live.com"
smtp_port: "587"
smtp_tls: "Yes"
smtp_starttls: "Yes"
smtp_from: "{{ ansible_email_addr }}"
smtp_user: "{{ ansible_email_addr }}"
smtp_pass: "{{ ansible_email_pass }}"

View File

@ -0,0 +1,7 @@
smtp_srv: "smtp.mail.me.com"
smtp_port: "587"
smtp_tls: "Yes"
smtp_starttls: "Yes"
smtp_from: "{{ ansible_email_addr }}"
smtp_user: "{{ ansible_email_addr }}"
smtp_pass: "{{ ansible_email_pass }}"

View File

@ -0,0 +1,7 @@
smtp_srv: "smtp.mail.me.com"
smtp_port: "587"
smtp_tls: "Yes"
smtp_starttls: "Yes"
smtp_from: "{{ ansible_email_addr }}"
smtp_user: "{{ ansible_email_addr }}"
smtp_pass: "{{ ansible_email_pass }}"

View File

@ -0,0 +1,7 @@
smtp_srv: "smtp.mail.me.com"
smtp_port: "587"
smtp_tls: "Yes"
smtp_starttls: "Yes"
smtp_from: "{{ ansible_email_addr }}"
smtp_user: "{{ ansible_email_addr }}"
smtp_pass: "{{ ansible_email_pass }}"

View File

@ -0,0 +1,7 @@
smtp_tls: "Yes"
smtp_starttls: "Yes"
smtp_srv: "{{ rentfree_smtp.user_input }}"
smtp_port: "{{ rentfree_port.user_input }}"
smtp_from: "{{ ansible_email_addr }}"
smtp_user: "{{ rentfree_smtp_user.user_input }}"
smtp_pass: "{{ ansible_email_pass }}"

View File

@ -0,0 +1,7 @@
smtp_srv: "smtp-mail.outlook.com"
smtp_port: "587"
smtp_tls: "Yes"
smtp_starttls: "Yes"
smtp_from: "{{ ansible_email_addr }}"
smtp_user: "{{ ansible_email_addr }}"
smtp_pass: "{{ ansible_email_pass }}"

View File

@ -0,0 +1,7 @@
smtp_srv: "smtp.mail.yahoo.com"
smtp_port: "587"
smtp_tls: "Yes"
smtp_starttls: "Yes"
smtp_from: "{{ ansible_email_addr }}"
smtp_user: "{{ ansible_email_addr }}"
smtp_pass: "{{ ansible_email_pass }}"

View File

@ -0,0 +1,20 @@
[Unit]
Description = gunicorn
Wants=network-online.target
After=network-online.target
[Service]
PermissionsStartOnly = true
PIDFile = %h/run/gunicorn.pid
Restart=on-failure
RestartSec=2
WorkingDirectory = %h/rentfree
ExecStartPre = /usr/bin/mkdir %h/run
ExecStart = %h/.local/bin/gunicorn website.wsgi --timeout 300 --workers {{ ((ansible_processor_vcpus|int * 2) + 1) }} -b 127.0.0.1:8787 --proxy-protocol --pid %h/gunicorn.pid --capture-output --access-logfile %h/logs/access.log --error-logfile %h/logs/error.log
ExecReload = /usr/bin/kill -s HUP $MAINPID
ExecStop = /usr/bin/kill -s TERM $MAINPID
ExecStopPost = /usr/bin/rm -rf %h/run
PrivateTmp = true
[Install]
WantedBy = default.target

View File

@ -0,0 +1,3 @@
max_parallel_worker_cpus: "{{ (ansible_processor_vcpus|int - 2) | int }}"
max_parallel_gather_cpus: "4"
pgsql_work_mem: "{{ (((((ansible_memtotal_mb|int * 256) / 100) * ansible_processor_vcpus|int) * (ansible_memtotal_mb|int / 1024)) | round | int }}"

View File

@ -0,0 +1,3 @@
max_parallel_worker_cpus: "{{ (ansible_processor_vcpus|int / 2) | round | int }}"
max_parallel_gather_cpus: "{{ (max_parallel_worker_cpus|int / 2) | round | int }}"
pgsql_work_mem: "{{ (((((ansible_memtotal_mb|int * 256) / 100) * ansible_processor_vcpus|int) * (ansible_memtotal_mb|int / 1024)) | round | int }}"

View File

@ -0,0 +1,3 @@
max_parallel_worker_cpus: "2"
max_parallel_gather_cpus: "1"
pgsql_work_mem: "{{ (((((ansible_memtotal_mb|int * 256) / 100) * ansible_processor_vcpus|int) * (ansible_memtotal_mb|int / 1024)) | round | int }}"

View File

@ -0,0 +1,768 @@
- name: Gathering facts...
ansible.builtin.setup:
register: rentfreeweb_facts
delay: 5
retries: 3
until: rentfreeweb_facts.ansible_facts
- name: Check to see if dhparam.pem has been generated...
ansible.builtin.stat:
path: /etc/ssl/dhparam.pem
register: dhparam_present
- ansible.builtin.debug:
msg: |
"This package generates a new 2048 bit SSL key to make website SSL keys with for increased security. This process can take a long time, so the task will run asynchronously and wait for itself to complete before finishing the server configuration by generating new SSL keys. If the process fails, you will have an invalid (and insecure) /etc/ssl/dhparam.pem file on your server. Delete it manually before running this installation script again, by logging into your web server's console and typing the command 'rm -f /etc/ssl/dhparam.pem'"
register: dhparam_info
when: dhparam_present.stat.exists == False
- name: sleep for 10 seconds to let the user read the above message...
ansible.builtin.wait_for:
timeout: 10
delegate_to: localhost
register: dhparam_message_status
when: dhparam_present.stat.exists == False
- name: Generate new dhparam key asynchronously...
community.crypto.openssl_dhparam:
path: /etc/ssl/dhparam.pem
force: False
size: 2048
mode: 0644
owner: root
register: dhparam_status
async: 86400
poll: 0
delay: 5
retries: 3
until: dhparam_status.failed == False
when: dhparam_present.stat.exists == False
no_log: True
- name: Update remote server hosts file...
vars:
domain_name: "{{ ansible_do_hostname }}"
host_name: "{{ ansible_do_hostname | regex_replace('\\..*?$') }}"
ansible.builtin.template:
src: templates/etc/etc_hosts.j2
dest: /etc/hosts
backup: True
register: hosts_result
delay: 5
retries: 3
until: hosts_result.failed == False
- name: Update remote server hostname file...
ansible.builtin.template:
src: templates/etc/etc_hostname.j2
dest: /etc/hostname
backup: True
register: hostname_result
delay: 5
retries: 3
until: hostname_result.failed == False
- name: Install Nginx, PGBouncer, ssmtp, logwatch and various dependencies...
ansible.builtin.apt:
name: "{{ item.value }}"
update_cache: True
register: webserver_install
delay: 5
retries: 3
until: webserver_install.failed == False
loop:
- { value: "nginx" }
- { value: "pgbouncer" }
- { value: "ssmtp" }
- { value: "logwatch" }
- { value: "python3-pip" }
- { value: "python3-cryptography" }
- { value: "python3-pillow" }
- { value: "python3-pygments" }
- { value: "python3-psycopg2" }
- { value: "python3-lxml" }
- { value: "python3-cffi" }
- { value: "python3-boto3" }
- name: Install Certbot...
community.general.snap:
classic: True
name: certbot
register: install_certbot
delay: 5
retries: 3
until: install_certbot.failed == False
- name: Authorize Certbot snap to run as root...
ansible.builtin.command:
cmd: "/usr/bin/snap set certbot trust-plugin-with-root=ok"
register: authorize_certbot
delay: 5
retries: 3
until: authorize_certbot.failed == False
- name: Install Certbot DNS plugin for Digital Ocean...
community.general.snap:
name: certbot-dns-digitalocean
register: install_certbot_dns
delay: 5
retries: 3
until: install_certbot_dns.failed == False
- name: Prompt for shell password for the rentfree user...
ansible.builtin.pause:
prompt: "\nEnter a password for admin actions on the server's terminal.\n
This can be a simple password that you will remember, it is only\n
used as an extra security check when performing administrative\n
tasks via the command line.\n\n"
echo: True
register: rentfree_shellpass
no_log: True
- name: Create website user...
ansible.builtin.user:
append: True
name: rentfree
shell: /bin/bash
groups: ["sudo", "postgres"]
uid: 1000
password: "{{ rentfree_shellpass.user_input | password_hash('sha512') }}"
update_password: on_create
register: create_user_rentfree
delay: 5
retries: 3
until: create_user_rentfree.failed == False
no_log: True
- name: Create website user local bin directory...
ansible.builtin.file:
path: /home/rentfree/.local/bin
state: directory
owner: rentfree
group: rentfree
mode: 0750
register: rentfree_bindir
delay: 5
retries: 3
until: rentfree_bindir.failed == False
- name: Create website user ssh config directory...
ansible.builtin.file:
path: /home/rentfree/.ssh
state: directory
owner: rentfree
group: rentfree
mode: 0700
register: rentfree_sshdir
delay: 5
retries: 3
until: rentfree_sshdir.failed == False
- name: Create rentfree log file directory...
ansible.builtin.file:
path: /home/rentfree/logs
state: directory
owner: rentfree
group: rentfree
mode: 0750
register: rentfree_logdir
delay: 5
retries: 3
until: rentfree_logdir.failed == False
- name: Create rentfree logrotate rules...
ansible.builtin.template:
src: templates/logrotate/gunicorn.j2
dest: /etc/logrotate.d/gunicorn
owner: root
group: root
mode: 0644
register: rentfree_logrotate
delay: 5
retries: 3
until: rentfree_logrotate.failed == False
- name: Set authorized key for user rentfree user...
vars:
pubkey: "{{ ansible_do_hostname.split('.', 1)[0] }}.pub"
ansible.posix.authorized_key:
user: rentfree
state: present
key: "{{ lookup('file', lookup('env','HOME') + '/.ssh/' + pubkey) }}"
register: rentfree_authorizessh
delay: 5
retries: 3
until: rentfree_authorizessh.failed == False
no_log: True
- name: Add rentfree host info to my local SSH configuration...
become: False
local_action: ansible.builtin.shell echo "\nHost rentfree\n hostname {{ ansible_host }}\n user rentfree\n identityfile {{ ansible_ssh_private_key_file }}\n" >> ~/.ssh/config
register: rentfree_authorized_key
- name: Enable long-running services for website user...
ansible.builtin.shell: "/bin/loginctl enable-linger rentfree"
register: loginctl_linger
delay: 5
retries: 3
until: loginctl_linger.failed == False
- name: Check to see if SSL certificates for our domain already exist...
ansible.builtin.stat:
path: "/etc/letsencrypt/live/{{ ansible_do_hostname }}"
register: certbot_check
check_mode: True
delay: 5
retries: 3
until: certbot_check.failed == False
- name: Create letsencrypt directory if it doesn't exist...
ansible.builtin.file:
path: /etc/letsencrypt
state: directory
owner: root
group: sudo
mode: 0644
register: certbot_etcfolder
delay: 5
retries: 3
until: certbot_etcfolder.failed == False
when: certbot_check.stat.exists == False
- name: Create Certbot ini file...
ansible.builtin.template:
src: templates/certbot/certbot_ini.j2
dest: /etc/letsencrypt/cli.ini
owner: root
group: sudo
mode: 0600
register: certbot_ini
delay: 5
retries: 3
until: certbot_ini.failed == False
when: certbot_check.stat.exists == False
no_log: True
- name: Create Certbot ini file for Digital Ocean DNS authentication...
ansible.builtin.template:
src: templates/certbot/digital_ocean_ini.j2
dest: /etc/letsencrypt/digital_ocean.ini
owner: root
group: sudo
mode: 0600
register: certbot_digital_ocean_ini
delay: 5
retries: 3
until: certbot_digital_ocean_ini.failed == False
when: certbot_check.stat.exists == False
no_log: True
- name: Set Nginx configuration...
ansible.builtin.template:
src: "{{ item.source }}"
dest: "{{ item.dest }}"
backup: "{{ true if index == 0 else false }}"
loop:
- { source: 'templates/nginx/nginx_main_conf.j2', dest: '/etc/nginx/nginx.conf' }
- { source: 'templates/nginx/header_conf.j2', dest: '/etc/nginx/header.conf' }
- { source: 'templates/nginx/proxy_conf.j2', dest: '/etc/nginx/proxy.conf' }
- { source: 'templates/nginx/optimization_conf.j2', dest: '/etc/nginx/optimization.conf' }
- { source: 'templates/nginx/ssl_conf.j2', dest: '/etc/nginx/ssl.conf' }
- { source: 'templates/nginx/sites-available/main_site_conf.j2', dest: '/etc/nginx/sites-available/main_site.conf' }
register: nginx_init
delay: 5
retries: 3
until: nginx_init.failed == False
loop_control:
index_var: index
- name: Create Nginx cache directory...
ansible.builtin.file:
path: /var/cache/nginx
state: directory
owner: www-data
group: www-data
mode: 0644
register: nginx_cache
delay: 5
retries: 3
until: nginx_cache.failed == False
- set_fact:
ansible_maildomain: "{{ ansible_email_addr | regex_replace('[A-Za-z0-9._%+-]+@') }}"
- name: Prompt for SMTP username if provided email address requires it...
ansible.builtin.pause:
prompt: "\nEnter your mail account's SMTP username\n
ex: AKIA123456789ABC if you use AWS SES\n
to send email.\n\n"
echo: True
register: rentfree_smtp_user
when: (ansible_maildomain != "gmail.com") and
(ansible_maildomain != "yahoo.com") and
(ansible_maildomain != "hotmail.com") and
(ansible_maildomain != "outlook.com") and
(ansible_maildomain != "aol.com") and
(ansible_maildomain != "mac.com") and
(ansible_maildomain != "me.com") and
(ansible_maildomain != "icloud.com")
- name: Prompt for SMTP address if provided email address requires it...
ansible.builtin.pause:
prompt: "\nEnter your mail account's SMTP server address\n
ex: email-smtp.us-east-1.amazonaws.com\n\n"
echo: True
register: rentfree_smtp
when: (ansible_maildomain != "gmail.com") and
(ansible_maildomain != "yahoo.com") and
(ansible_maildomain != "hotmail.com") and
(ansible_maildomain != "outlook.com") and
(ansible_maildomain != "aol.com") and
(ansible_maildomain != "mac.com") and
(ansible_maildomain != "me.com") and
(ansible_maildomain != "icloud.com")
- name: Prompt for SMTP port if provided email address requires it...
ansible.builtin.pause:
prompt: "\nEnter your mail account's SMTP port\n
ex: 587\n\n"
echo: True
register: rentfree_port
when: (ansible_maildomain != "gmail.com") and
(ansible_maildomain != "yahoo.com") and
(ansible_maildomain != "hotmail.com") and
(ansible_maildomain != "outlook.com") and
(ansible_maildomain != "aol.com") and
(ansible_maildomain != "mac.com") and
(ansible_maildomain != "me.com") and
(ansible_maildomain != "icloud.com")
- name: Set SMTP variables for admin email (if hotmail, aol, gmail, etc)...
include_vars: "templates/ssmtp/vars/{{ ansible_maildomain }}.yml"
when: (ansible_maildomain == "gmail.com") or
(ansible_maildomain == "yahoo.com") or
(ansible_maildomain == "hotmail.com") or
(ansible_maildomain == "outlook.com") or
(ansible_maildomain == "aol.com") or
(ansible_maildomain == "mac.com") or
(ansible_maildomain == "me.com") or
(ansible_maildomain == "icloud.com")
- name: Set SMTP variables for admin email (if custom/other domain)...
include_vars: "templates/ssmtp/vars/other.com.yml"
when: (ansible_maildomain != "gmail.com") and
(ansible_maildomain != "yahoo.com") and
(ansible_maildomain != "hotmail.com") and
(ansible_maildomain != "outlook.com") and
(ansible_maildomain != "aol.com") and
(ansible_maildomain != "mac.com") and
(ansible_maildomain != "me.com") and
(ansible_maildomain != "icloud.com")
- name: Set up ssmtp config from template...
ansible.builtin.template:
src: templates/ssmtp/ssmtp_conf.j2
dest: /etc/ssmtp/ssmtp.conf
backup: True
mode: 0640
owner: root
no_log: True
register: ssmtp_template
retries: 3
delay: 5
until: ssmtp_template.failed == False
- name: Add email aliases for root and default...
ansible.builtin.template:
src: templates/ssmtp/revaliases.j2
dest: /etc/ssmtp/revaliases
backup: True
mode: 0640
owner: root
no_log: True
retries: 3
delay: 5
register: ssmtp_aliases
until: ssmtp_aliases.failed == False
- name: Set up Logwatch cron job...
ansible.builtin.lineinfile:
dest: /etc/cron.daily/00logwatch
regexp: "^/usr/sbin/logwatch"
line: "/usr/sbin/logwatch --output mail --mailto {{ ansible_email_addr }}"
state: present
create: True
no_log: True
- name: Wait until the dhparam key is computed before continuing...
ansible.builtin.async_status:
jid: "{{ dhparam_status.ansible_job_id }}"
register: dhparam_result
delay: 30
retries: 2880
until: dhparam_result.finished
when: dhparam_status.changed == True
- name: Set up PGBouncer config from template...
vars:
webdb_pass: "{{ lookup('password', '~/ansible-webuser-database-password.txt length=32 chars=ascii_letters,digits') }}"
ansible.builtin.template:
src: templates/pgbouncer/pgbouncer_ini.j2
dest: /etc/pgbouncer/pgbouncer.ini
backup: True
mode: 0640
owner: postgres
no_log: True
register: pgbouncer_ini_template
retries: 3
delay: 5
until: pgbouncer_ini_template.failed == False
- name: Set up PGBouncer user config from template...
vars:
pgbdb_pass: "{{ lookup('password', '~/ansible-pgbuser-database-password.txt length=32 chars=ascii_letters,digits') }}"
ansible.builtin.template:
src: templates/pgbouncer/userlist_txt.j2
dest: /etc/pgbouncer/userlist.txt
backup: True
mode: 0640
owner: postgres
no_log: True
retries: 3
delay: 5
register: pgbouncer_user_template
until: pgbouncer_user_template.failed == False
- name: Symlink main site config from Nginx sites-available directory into the sites-enabled directory...
ansible.builtin.file:
src: /etc/nginx/sites-available/main_site.conf
dest: /etc/nginx/sites-enabled/main_site.conf
owner: root
group: root
state: link
register: nginx_sites_enabled
- name: Add webserver host info to my local SSH configuration...
become: False
local_action: ansible.builtin.shell echo "\nHost {{ ansible_do_webserver_name }}\n hostname {{ ansible_host }}\n user root\n identityfile {{ ansible_ssh_private_key_file }}\n" >> ~/.ssh/config
register: rentfree_dosshwebconfig
- name: Add database server host info to my local SSH configuration...
become: False
local_action: ansible.builtin.shell echo "\nHost {{ ansible_dbserver_name }}\n hostname {{ ansible_dbserver_host }}\n user root\n port 22\n identityfile {{ ansible_ssh_private_key_file }}\n ProxyCommand ssh -q -W %h:%p {{ ansible_do_webserver_name }}" >> ~/.ssh/config
register: rentfree_dosshwebconfig
- name: Sync outgoing mail aliases across the two servers...
ansible.builtin.template:
src: templates/ssmtp/revaliases.j2
dest: /etc/ssmtp/revaliases
backup: True
mode: 0640
owner: root
delegate_to: rentfree-db
no_log: True
retries: 3
delay: 5
register: ssmtp_aliases_db
until: ssmtp_aliases_db.failed == False
- name: Sync outgoing mail configs across the two servers...
ansible.builtin.template:
src: templates/ssmtp/ssmtp_conf.j2
dest: /etc/ssmtp/ssmtp.conf
backup: True
mode: 0640
owner: root
delegate_to: rentfree-db
no_log: True
retries: 3
delay: 5
register: ssmtp_config_db
until: ssmtp_config_db.failed == False
- name: Check to see if rentfree files exist...
ansible.builtin.stat:
path: /home/rentfree/rentfreemedia.tar.gz
retries: 3
delay: 5
register: rentfree_build_files
until: rentfree_build_files.failed == False
- name: Get rentfree build files from github...
ansible.builtin.uri:
dest: /home/rentfree/rentfreemedia.tar.gz
url: https://github.com/rentfreemedia/rentfreemedia/tarball/main
become: True
become_user: rentfree
retries: 3
delay: 5
register: rentfree_build_download
until: rentfree_build_download.failed == False
when: rentfree_build_files.stat.exists == False
- name: Unzip rentfree build files...
ansible.builtin.unarchive:
remote_src: True
src: /home/rentfree/rentfreemedia.tar.gz
dest: /home/rentfree
extra_opts:
- --strip-components=1
become: True
become_user: rentfree
retries: 3
delay: 5
register: rentfree_build_unzip
until: rentfree_build_unzip.failed == False
when: rentfree_build_files.stat.exists == False
- name: Remove default site from Nginx...
ansible.builtin.file:
path: /etc/nginx/sites-enabled/default
state: absent
retries: 3
delay: 5
register: rentfree_remove_nginx_default
until: rentfree_remove_nginx_default.failed == False
- name: Remove dummy env file from the server...
ansible.builtin.file:
path: /home/rentfree/rentfree/env
state: absent
retries: 3
delay: 5
register: rentfree_remove_env
until: rentfree_remove_env.failed == False
- name: Remove the ansible folder from the files unzipped from the rentfree repo...
ansible.builtin.command: "/bin/rm -rf /home/rentfree/ansible/"
become: True
become_user: rentfree
args:
chdir: /home/rentfree
retries: 3
delay: 5
register: rm_ansible_files
until: rm_ansible_files.failed == False
- name: Enter Stripe public key...
ansible.builtin.pause:
prompt: "\nI need your Stripe sandbox (testing mode) public key for the\n
initial database migration. Stop here and set up a Stripe account\n
if you haven't done so yet. When you're ready, enter the key here.\n
It's the one that looks like:\n\n
'pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'\n\n
Enter your Stripe test public key:\n\n"
echo: True
register: stripe_test_publickey
no_log: True
- name: Enter Stripe secret key...
ansible.builtin.pause:
prompt: "\nI also need your Stripe sandbox (testing mode) secret key for the\n
initial database migration. Enter the key here. It's the one that looks like:\n\n
'sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'\n\n
Enter your Stripe test secret key:\n\n"
echo: True
register: stripe_test_secretkey
no_log: True
- name: Enter Stripe webhook secret...
ansible.builtin.pause:
prompt: "\nI also need your Stripe sandbox (testing mode) webhook secret\n
for the initial database migration. It's the one that looks like:\n\n
'whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'\n\n
Enter your Stripe webhook secret:\n\n"
echo: True
register: stripe_test_whkey
no_log: True
- name: Create Django settings .env file on the web server...
ansible.builtin.template:
src: templates/rentfree/env.j2
dest: /home/rentfree/rentfree/.env
backup: True
mode: 0640
owner: rentfree
group: rentfree
register: rentfree_dotenv
delay: 5
retries: 3
until: rentfree_dotenv.failed == False
no_log: True
- name: Create website user systemd unit directory...
ansible.builtin.file:
path: /home/rentfree/.config/systemd/user
state: directory
owner: rentfree
group: rentfree
mode: 0750
retries: 3
delay: 5
register: user_systemd
until: user_systemd.failed == False
- name: Install rentfree required libraries...
ansible.builtin.command: "/usr/bin/pip3 install -r /home/rentfree/rentfree/requirements_ansible.txt --user"
become: True
become_user: rentfree
args:
chdir: /home/rentfree/rentfree
retries: 3
delay: 5
register: pip_install
until: pip_install.failed == False
- name: Collect static files...
ansible.builtin.command: "/usr/bin/python3 manage.py collectstatic --noinput"
become: True
become_user: rentfree
args:
chdir: /home/rentfree/rentfree
retries: 3
delay: 5
register: django_collectstatic
until: django_collectstatic.failed == False
no_log: True
- name: Restart PGBouncer with new config...
ansible.builtin.systemd:
name: pgbouncer
state: restarted
register: pgbouncer_restart
delay: 5
retries: 3
until: pgbouncer_restart.failed == False
- name: Create initial rentfree database migrations...
ansible.builtin.command: "/usr/bin/python3 manage.py makemigrations --noinput"
become: True
become_user: rentfree
args:
chdir: /home/rentfree/rentfree
retries: 3
delay: 5
register: django_make_migrate
until: django_make_migrate.failed == False
- name: Create initial rentfree database data...
ansible.builtin.command: "/usr/bin/python3 manage.py migrate --noinput"
become: True
become_user: rentfree
args:
chdir: /home/rentfree/rentfree
retries: 3
delay: 5
register: django_migrate
until: django_migrate.failed == False
- name: Create superuser for rentfree database...
ansible.builtin.command: "/usr/bin/python3 manage.py createsuperuser --noinput"
become: True
become_user: rentfree
args:
chdir: /home/rentfree/rentfree
retries: 3
delay: 5
register: django_createadmin
until: django_createadmin.failed == False
no_log: True
- name: Update indexes for rentfree database...
ansible.builtin.command: "/usr/bin/python3 manage.py update_index"
become: True
become_user: rentfree
args:
chdir: /home/rentfree/rentfree
retries: 3
delay: 5
register: django_indexes
until: django_indexes.failed == False
- name: Create PATH entry in rentfree user crontab...
ansible.builtin.cron:
name: PATH
env: yes
job: /home/rentfree/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
- name: Create a cron job to send rentfree queued email every minute...
ansible.builtin.cron:
name: rentfree_mail
user: rentfree
job: "cd $HOME/rentfree && /usr/bin/python3 manage.py send_queued_mail > /dev/null 2>&1"
retries: 3
delay: 5
register: create_mail_cron
until: create_mail_cron.failed == False
- name: Create a cron job to publish scheduled pages every hour...
ansible.builtin.cron:
name: rentfree_publish
user: rentfree
job: "cd $HOME/rentfree && /usr/bin/python3 manage.py publish_scheduled_pages > /dev/null 2>&1"
minute: 7
retries: 3
delay: 5
register: create_publish_cron
until: create_publish_cron.failed == False
- name: Create a cron job to send rentfree drip mail once a week...
ansible.builtin.cron:
name: rentfree_dripmail
user: rentfree
job: "cd $HOME/rentfree && /usr/bin/python3 manage.py send_drips > /dev/null 2>&1"
weekday: 4
hour: 16
retries: 3
delay: 5
register: create_mail_cron
until: create_mail_cron.failed == False
- name: Create a cron job to rebuild the rentfree search index once a month...
ansible.builtin.cron:
name: rentfree_index
user: rentfree
job: "cd $HOME/rentfree && /usr/bin/python3 manage.py update_index > /dev/null 2>&1"
day: 5
hour: 7
retries: 3
delay: 5
register: create_mail_cron
until: create_mail_cron.failed == False
- name: Create a cron job to purge the 30 day sent email queue every night...
ansible.builtin.cron:
name: rentfree_clean_mail
user: rentfree
job: "cd $HOME/rentfree && /usr/bin/python3 manage.py cleanup_mail -d 30 --delete-attachments > /dev/null 2>&1"
hour: 1
minute: 9
retries: 3
delay: 5
register: create_clean_mail_cron
until: create_clean_mail_cron.failed == False
- name: Enable systemd tempfiles for rentfree user...
ansible.builtin.command: "/usr/bin/systemctl --user enable systemd-tmpfiles-setup.service systemd-tmpfiles-clean.timer"
become: True
become_user: rentfree
args:
chdir: /home/rentfree
environment:
XDG_RUNTIME_DIR: "/run/user/1000"
register: rentfree_systemd_temp
retries: 3
delay: 5
until: rentfree_systemd_temp.failed == False
- name: Create rentfree gunicorn server systemd unit file...
ansible.builtin.template:
src: templates/systemd/gunicorn_service.j2
dest: /home/rentfree/.config/systemd/user/gunicorn.service
backup: True
mode: 0644
owner: rentfree
retries: 3
delay: 5
register: rentfree_gunicorn_unit
until: rentfree_gunicorn_unit.failed == False

669
ansible/main.yml Normal file
View File

@ -0,0 +1,669 @@
---
- hosts: all
remote_user: rentfree
vars:
ansible_python_interpreter: /usr/bin/python3
ansible_ssh_private_key_file: /Users/robertclayton/.ssh/rentfree_rsa
connection: local
gather_facts: False
tasks:
- name: I need your domain name...
ansible.builtin.pause:
prompt: "\nEnter your domain name, without https or www\n
ex: mypodcast.com\n"
echo: True
register: rentfree_host
- name: Tell me an address for server notification emails...
ansible.builtin.pause:
prompt: "\nEnter the email address that will receive servermonitoring\n
notifications. If not a Gmail, Yahoo, Outlook, AOL, or iCloud\n
address I'll ask you for the server connection info later.\n"
echo: True
register: rentfree_email
no_log: True
- name: Tell me the server notification email address password...
ansible.builtin.pause:
prompt: "\nSet up an 'app password' if your email has multi-factor authentication, and enter it here\n"
echo: True
register: rentfree_emailpass
no_log: True
- name: What region do you want your servers to be in? i.e. physically closest to their customers...
ansible.builtin.pause:
prompt: "\nI need to know what region you want your\n
servers to be in, should be the one closest to\n
your customer base. Options include New York - USA,\n
Amsterdam - EU, San Francisco - USA, Singapore,\n
London - UK, Frankfurt - EU, Toronto - CA, and\n
Bangalore - India. If there are multiple datacenters\n
for a region you choose, you can just pick one at random\n\n.
Enter one of the corresponding codes in the list below:\n\n
NYC1\n
NYC2\n
NYC3\n
SFO1\n
SFO2\n
SGP1\n
LON1\n
FRA1\n
TOR1\n
BLR1\n\n"
echo: True
register: rentfree_doregion
- name: Tell me your Digital Ocean API key (the longest one)...
ansible.builtin.pause:
prompt: "\nEnter your Digital Ocean API key to set up Rentfree on your Digital Ocean account:\n\n"
echo: True
register: rentfree_doapikey
no_log: True
- name: Tell me your Digital Ocean Spaces access key...
ansible.builtin.pause:
prompt: "\nEnter your Digital Ocean Spaces access key (the shorter one in all caps):\n\n"
echo: True
register: rentfree_doaccess
no_log: True
- name: Tell me your Digital Ocean Spaces secret key...
ansible.builtin.pause:
prompt: "\nEnter your Digital Ocean Spaces secret key (the longer one with special characters):\n\n"
echo: True
register: rentfree_dosecret
no_log: True
- name: Print return information from the previous task
ansible.builtin.pause:
prompt: "\nOkay, setting up for hostname = {{ rentfree_host.user_input }},\n
Server Admin Email = {{ rentfree_email.user_input }}\n
Server Region Name = {{ rentfree_doregion.user_input }}\n\n\n
If this is NOT WHAT YOU WANT, press ctrl-c and enter 'A' for abort, otherwise press enter to continue\n"
echo: False
no_log: True
- name: Continuing with installation of Rentfree on your new server, don't close this window until the process completes.
ansible.builtin.pause:
seconds: 5
- name: "Creating new project namespace on Digital Ocean..."
community.digitalocean.digital_ocean_project:
oauth_token: "{{ rentfree_doapikey.user_input }}"
name: "{{ rentfree_host.user_input }}"
state: "present"
description: "Rentfree Site - {{ rentfree_host.user_input }}"
purpose: "Web Application"
environment: "Production"
delegate_to: localhost
register: rentfree_doproj
delay: 5
retries: 3
until: rentfree_doproj.failed == False
no_log: True
- name: Create a domain and associate it with the new project namespace...
community.digitalocean.digital_ocean_domain:
oauth_token: "{{ rentfree_doapikey.user_input }}"
state: present
name: "{{ rentfree_host.user_input }}"
ip: 127.0.0.1
project: "{{ rentfree_doproj.data.project.name }}"
delegate_to: localhost
register: rentfree_dohost
delay: 5
retries: 3
until: rentfree_dohost.failed == False
no_log: True
- name: Create storage bucket for static files in Digital Ocean Spaces...
amazon.aws.s3_bucket:
aws_access_key: "{{ rentfree_doaccess.user_input }}"
aws_secret_key: "{{ rentfree_dosecret.user_input }}"
name: "{{ rentfree_host.user_input.split('.', 1)[0] }}-pub"
s3_url: "https://{{ rentfree_doregion.user_input|lower }}.digitaloceanspaces.com"
state: present
delete_public_access: False
register: rentfree_dopubbucket
delay: 5
retries: 3
until: rentfree_dopubbucket.failed == False
no_log: True
- name: Create storage bucket for private media files in Digital Ocean Spaces...
amazon.aws.s3_bucket:
aws_access_key: "{{ rentfree_doaccess.user_input }}"
aws_secret_key: "{{ rentfree_dosecret.user_input }}"
name: "{{ rentfree_host.user_input.split('.', 1)[0] }}-priv"
s3_url: "https://{{ rentfree_doregion.user_input|lower }}.digitaloceanspaces.com"
state: present
delete_public_access: True
register: rentfree_doprivbucket
delay: 5
retries: 3
until: rentfree_doprivbucket.failed == False
no_log: True
- name: Create private storage bucket for database backups in Digital Ocean Spaces...
amazon.aws.s3_bucket:
aws_access_key: "{{ rentfree_doaccess.user_input }}"
aws_secret_key: "{{ rentfree_dosecret.user_input }}"
name: "{{ rentfree_host.user_input.split('.', 1)[0] }}-db-backups"
s3_url: "https://{{ rentfree_doregion.user_input|lower }}.digitaloceanspaces.com"
state: present
delete_public_access: True
register: rentfree_dodbbucket
delay: 5
retries: 3
until: rentfree_dodbbucket.failed == False
no_log: True
- name: Assign Spaces we created to our newly created project...
uri:
url: "https://api.digitalocean.com/v2/projects/{{ rentfree_doproj.data.project.id }}/resources"
headers:
Authorization: "Bearer {{ rentfree_doapikey.user_input }}"
Accept: 'application/json'
body_format: json
body:
"resources": [
"do:space:{{ rentfree_host.user_input.split('.', 1)[0] }}-priv",
"do:space:{{ rentfree_host.user_input.split('.', 1)[0] }}-pub",
"do:space:{{ rentfree_host.user_input.split('.', 1)[0] }}-db-backups"
]
validate_certs: no
follow_redirects: all
return_content: yes
status_code: 200
method: POST
register: rentfree_dospaceassign
delay: 5
retries: 3
until: rentfree_dospaceassign.failed == False
no_log: True
- name: Create an SSL certificate for the public spaces bucket that will hold static media and images...
uri:
url: "https://api.digitalocean.com/v2/certificates"
headers:
Authorization: "Bearer {{ rentfree_doapikey.user_input }}"
Accept: 'application/json'
body_format: json
body:
"name": "{{ rentfree_host.user_input.split('.', 1)[0] }}-cdn"
"type": "lets_encrypt"
"dns_names": [
"cdn.{{ rentfree_host.user_input }}"
]
validate_certs: no
follow_redirects: all
return_content: yes
status_code: 202
method: POST
register: rentfree_docdnssl
delay: 5
retries: 3
until: rentfree_docdnssl.failed == False
no_log: True
- name: Create a CNAME DNS record to redirect www to the root domain...
community.digitalocean.digital_ocean_domain_record:
oauth_token: "{{ rentfree_doapikey.user_input }}"
state: present
domain: "{{ rentfree_host.user_input }}"
type: CNAME
name: "www"
data: "{{ rentfree_host.user_input }}"
delegate_to: localhost
register: rentfree_docnamerecord
delay: 5
retries: 3
until: rentfree_docnamerecord.failed == False
no_log: True
- name: Create a CAA record for Letsencrypt on the newly created DNS record...
community.digitalocean.digital_ocean_domain_record:
oauth_token: "{{ rentfree_doapikey.user_input }}"
state: present
domain: "{{ rentfree_host.user_input }}"
type: CAA
tag: issue
name: "@"
data: "letsencrypt.org"
flags: 0
delegate_to: localhost
register: rentfree_docaarecord
delay: 5
retries: 3
until: rentfree_docaarecord.failed == False
when: rentfree_docnamerecord.changed == True
no_log: True
- name: Get Spaces CDN certificate ID...
community.digitalocean.digital_ocean_certificate_info:
oauth_token: "{{ rentfree_doapikey.user_input }}"
register: rentfree_docertid
delay: 30
retries: 3
until: rentfree_docertid.data[0].state == 'verified'
no_log: True
- name: Create Spaces CDN for the public bucket...
community.digitalocean.digital_ocean_cdn_endpoints:
oauth_token: "{{ rentfree_doapikey.user_input }}"
certificate_id: "{{ rentfree_docertid.data[0].id }}"
state: present
origin: "{{ rentfree_host.user_input.split('.', 1)[0]}}-pub.{{ rentfree_doregion.user_input|lower }}.digitaloceanspaces.com"
custom_domain: "cdn.{{ rentfree_host.user_input }}"
ttl: 3600
delegate_to: localhost
register: rentfree_dospacecdn
no_log: True
- name: Create a VPC for the ancillary back end services to communicate securely...
community.digitalocean.digital_ocean_vpc:
oauth_token: "{{ rentfree_doapikey.user_input }}"
region: "{{ rentfree_doregion.user_input|lower }}"
name: "{{ rentfree_host.user_input.split('.', 1)[0]}}-vpc"
default: True
ip_range: 192.168.1.0/24
register: rentfree_domakevpc
delay: 5
retries: 3
until: rentfree_domakevpc.failed == False
no_log: True
- name: Create a SSH key pair for connecting to the servers we are going to create...
community.crypto.openssh_keypair:
path: "~/.ssh/{{ rentfree_host.user_input.split('.', 1)[0] }}"
delegate_to: localhost
register: rentfree_dosshkeypair
- name: "Send SSH public key to Digital Ocean for use on the servers we will create..."
community.digitalocean.digital_ocean_sshkey:
oauth_token: "{{ rentfree_doapikey.user_input }}"
name: "{{ rentfree_host.user_input.split('.', 1)[0] }}"
ssh_pub_key: "{{ rentfree_dosshkeypair.public_key }}"
state: present
register: rentfree_dosshpubkey
delay: 5
retries: 3
until: rentfree_dosshpubkey.failed == False
- name: "It's time to choose your database server size..."
ansible.builtin.pause:
prompt: "\n It's now time to decide how much server performance you want. You should\n
familiarize yourself with them before proceeding, you will be billed for what\n
you choose by Digital Ocean. Server specifications and prices are available at\n\n\n
https://slugs.do-api.dev\n\n\n
The 'slug' is what you'll need to enter for the server size you want.\n\n
For now I need to know what size server you want for your database.\n\n
Please enter the 'slug' for the database server size you want below:\n\n"
echo: True
register: rentfree_dodbsize
- name: Make sure the database droplet doesn't already exist...
community.digitalocean.digital_ocean_droplet_info:
oauth_token: "{{ rentfree_doapikey.user_input }}"
name: "{{ rentfree_host.user_input.split('.', 1)[0] }}-db"
failed_when:
- result is not undefined
register: rentfree_dodbdropletcheck
delay: 5
retries: 3
until: rentfree_dodbdropletcheck.failed == False
- name: "Creating new database server from the specifications you chose in the previous step..."
community.digitalocean.digital_ocean_droplet:
state: present
oauth_token: "{{ rentfree_doapikey.user_input }}"
name: "{{ rentfree_host.user_input.split('.', 1)[0] }}-db"
size: "{{ rentfree_dodbsize.user_input }}"
region: "{{ rentfree_doregion.user_input|lower }}"
image: ubuntu-20-04-x64
wait_timeout: 500
ssh_keys: ["{{ rentfree_dosshpubkey.data.ssh_key.id }}"]
monitoring: True
register: rentfree_dodbdroplet
when: (rentfree_dodbsize.user_input == 's-1vcpu-1gb') or
(rentfree_dodbsize.user_input == 's-1vcpu-1gb-amd') or
(rentfree_dodbsize.user_input == 's-1vcpu-1gb-intel') or
(rentfree_dodbsize.user_input == 's-1vcpu-2gb') or
(rentfree_dodbsize.user_input == 's-1vcpu-2gb-amd') or
(rentfree_dodbsize.user_input == 's-1vcpu-2gb-intel') or
(rentfree_dodbsize.user_input == 's-2vcpu-2gb') or
(rentfree_dodbsize.user_input == 's-2vcpu-2gb-amd') or
(rentfree_dodbsize.user_input == 's-2vcpu-2gb-intel') or
(rentfree_dodbsize.user_input == 's-2vcpu-4gb') or
(rentfree_dodbsize.user_input == 's-2vcpu-4gb-amd') or
(rentfree_dodbsize.user_input == 's-2vcpu-4gb-intel') or
(rentfree_dodbsize.user_input == 's-4vcpu-8gb') or
(rentfree_dodbsize.user_input == 'c-2') or
(rentfree_dodbsize.user_input == 'c2-2vcpu-4gb') or
(rentfree_dodbsize.user_input == 's-4vcpu-8gb-amd') or
(rentfree_dodbsize.user_input == 's-4vcpu-8gb-intel') or
(rentfree_dodbsize.user_input == 'g-2vcpu-8gb') or
(rentfree_dodbsize.user_input == 'gd-2vcpu-8gb') or
(rentfree_dodbsize.user_input == 's-8vcpu-16gb') or
(rentfree_dodbsize.user_input == 'm-2vcpu-16gb') or
(rentfree_dodbsize.user_input == 'c-4') or
(rentfree_dodbsize.user_input == 'c2-4vcpu-8gb') or
(rentfree_dodbsize.user_input == 's-8vcpu-16gb-amd') or
(rentfree_dodbsize.user_input == 's-8vcpu-16gb-intel') or
(rentfree_dodbsize.user_input == 'm3-2vcpu-16gb') or
(rentfree_dodbsize.user_input == 'g-4vcpu-16gb') or
(rentfree_dodbsize.user_input == 'so-2vcpu-16gb') or
(rentfree_dodbsize.user_input == 'm6-2vcpu-16gb') or
(rentfree_dodbsize.user_input == 'gd-4vcpu-16gb') or
(rentfree_dodbsize.user_input == 'so1_5-2vcpu-16gb') or
(rentfree_dodbsize.user_input == 'm-4vcpu-32gb') or
(rentfree_dodbsize.user_input == 'c-8') or
(rentfree_dodbsize.user_input == 'c2-8vcpu-16gb') or
(rentfree_dodbsize.user_input == 'm3-4vcpu-32gb') or
(rentfree_dodbsize.user_input == 'g-8vcpu-32gb') or
(rentfree_dodbsize.user_input == 'so-4vcpu-32gb') or
(rentfree_dodbsize.user_input == 'm6-4vcpu-32gb') or
(rentfree_dodbsize.user_input == 'gd-8vcpu-32gb') or
(rentfree_dodbsize.user_input == 'so1_5-4vcpu-32gb') or
(rentfree_dodbsize.user_input == 'm-8vcpu-64gb') or
(rentfree_dodbsize.user_input == 'c-16') or
(rentfree_dodbsize.user_input == 'c2-16vcpu-32gb') or
(rentfree_dodbsize.user_input == 'm3-8vcpu-64gb') or
(rentfree_dodbsize.user_input == 'g-16vcpu-64gb') or
(rentfree_dodbsize.user_input == 'so-8vcpu-64gb') or
(rentfree_dodbsize.user_input == 'm6-8vcpu-64gb') or
(rentfree_dodbsize.user_input == 'gd-16vcpu-64gb') or
(rentfree_dodbsize.user_input == 'so1_5-8vcpu-64gb') or
(rentfree_dodbsize.user_input == 'm-16vcpu-128gb') or
(rentfree_dodbsize.user_input == 'c-32') or
(rentfree_dodbsize.user_input == 'c2-32vcpu-64gb') or
(rentfree_dodbsize.user_input == 'm3-16vcpu-128gb') or
(rentfree_dodbsize.user_input == 'm-24vcpu-192gb') or
(rentfree_dodbsize.user_input == 'g-32vcpu-128gb') or
(rentfree_dodbsize.user_input == 'so-16vcpu-128gb') or
(rentfree_dodbsize.user_input == 'm6-16vcpu-128gb') or
(rentfree_dodbsize.user_input == 'gd-32vcpu-128gb') or
(rentfree_dodbsize.user_input == 'm3-24vcpu-192gb') or
(rentfree_dodbsize.user_input == 'g-40vcpu-160gb') or
(rentfree_dodbsize.user_input == 'so1_5-16vcpu-128gb') or
(rentfree_dodbsize.user_input == 'm-32vcpu-256gb') or
(rentfree_dodbsize.user_input == 'gd-40vcpu-160gb') or
(rentfree_dodbsize.user_input == 'so-24vcpu-192gb') or
(rentfree_dodbsize.user_input == 'm6-24vcpu-192gb') or
(rentfree_dodbsize.user_input == 'm3-32vcpu-256gb') or
(rentfree_dodbsize.user_input == 'so1_5-24vcpu-192gb') or
(rentfree_dodbsize.user_input == 'so-32vcpu-256gb') or
(rentfree_dodbsize.user_input == 'm6-32vcpu-256gb') or
(rentfree_dodbsize.user_input == 'so1_5-32vcpu-256gb')
delay: 5
retries: 3
until: rentfree_dodbdroplet.failed == False
no_log: True
- name: Assign database droplet we created to our project...
uri:
url: "https://api.digitalocean.com/v2/projects/{{ rentfree_doproj.data.project.id }}/resources"
headers:
Authorization: "Bearer {{ rentfree_doapikey.user_input }}"
Accept: 'application/json'
body_format: json
body:
"resources": [
"do:droplet:{{ rentfree_dodbdroplet.data.droplet.id }}"
]
validate_certs: no
follow_redirects: all
return_content: yes
status_code: 200
method: POST
register: rentfree_dodbassign
delay: 5
retries: 3
until: rentfree_dodbassign.failed == False
no_log: True
- name: "It's time to choose your web server size..."
ansible.builtin.pause:
prompt: "\n It's now time to decide how much server performance you want. This step\n
is the same as the one prior for the database, you will be billed for what\n
you choose by Digital Ocean. Server specifications and prices are available at\n\n\n
https://slugs.do-api.dev\n\n\n
The 'slug' is what you'll need to enter for the server size you want.\n\n
Again, I need to know what size server you want for your web site.\n\n
Please enter the 'slug' for the web server size you want below:\n\n"
echo: True
register: rentfree_dowebsize
delay: 5
retries: 3
until: rentfree_dowebsize.failed == False
- name: Make sure the web droplet doesn't already exist...
community.digitalocean.digital_ocean_droplet_info:
oauth_token: "{{ rentfree_doapikey.user_input }}"
name: "{{ rentfree_host.user_input.split('.', 1)[0] }}-web"
failed_when:
- result is not undefined
register: rentfree_dowebdropletcheck
delay: 5
retries: 3
until: rentfree_dowebdropletcheck.failed == False
- name: "Creating new web server from the specifications you chose in the previous step..."
community.digitalocean.digital_ocean_droplet:
state: present
oauth_token: "{{ rentfree_doapikey.user_input }}"
name: "{{ rentfree_host.user_input.split('.', 1)[0] }}-web"
size: "{{ rentfree_dodbsize.user_input }}"
region: "{{ rentfree_doregion.user_input|lower }}"
image: ubuntu-20-04-x64
wait_timeout: 500
ssh_keys: ["{{ rentfree_dosshpubkey.data.ssh_key.id }}"]
monitoring: True
register: rentfree_dowebdroplet
when: (rentfree_dowebsize.user_input == 's-1vcpu-1gb') or
(rentfree_dowebsize.user_input == 's-1vcpu-1gb-amd') or
(rentfree_dowebsize.user_input == 's-1vcpu-1gb-intel') or
(rentfree_dowebsize.user_input == 's-1vcpu-2gb') or
(rentfree_dowebsize.user_input == 's-1vcpu-2gb-amd') or
(rentfree_dowebsize.user_input == 's-1vcpu-2gb-intel') or
(rentfree_dowebsize.user_input == 's-2vcpu-2gb') or
(rentfree_dowebsize.user_input == 's-2vcpu-2gb-amd') or
(rentfree_dowebsize.user_input == 's-2vcpu-2gb-intel') or
(rentfree_dowebsize.user_input == 's-2vcpu-4gb') or
(rentfree_dowebsize.user_input == 's-2vcpu-4gb-amd') or
(rentfree_dowebsize.user_input == 's-2vcpu-4gb-intel') or
(rentfree_dowebsize.user_input == 's-4vcpu-8gb') or
(rentfree_dowebsize.user_input == 'c-2') or
(rentfree_dowebsize.user_input == 'c2-2vcpu-4gb') or
(rentfree_dowebsize.user_input == 's-4vcpu-8gb-amd') or
(rentfree_dowebsize.user_input == 's-4vcpu-8gb-intel') or
(rentfree_dowebsize.user_input == 'g-2vcpu-8gb') or
(rentfree_dowebsize.user_input == 'gd-2vcpu-8gb') or
(rentfree_dowebsize.user_input == 's-8vcpu-16gb') or
(rentfree_dowebsize.user_input == 'm-2vcpu-16gb') or
(rentfree_dowebsize.user_input == 'c-4') or
(rentfree_dowebsize.user_input == 'c2-4vcpu-8gb') or
(rentfree_dowebsize.user_input == 's-8vcpu-16gb-amd') or
(rentfree_dowebsize.user_input == 's-8vcpu-16gb-intel') or
(rentfree_dowebsize.user_input == 'm3-2vcpu-16gb') or
(rentfree_dowebsize.user_input == 'g-4vcpu-16gb') or
(rentfree_dowebsize.user_input == 'so-2vcpu-16gb') or
(rentfree_dowebsize.user_input == 'm6-2vcpu-16gb') or
(rentfree_dowebsize.user_input == 'gd-4vcpu-16gb') or
(rentfree_dowebsize.user_input == 'so1_5-2vcpu-16gb') or
(rentfree_dowebsize.user_input == 'm-4vcpu-32gb') or
(rentfree_dowebsize.user_input == 'c-8') or
(rentfree_dowebsize.user_input == 'c2-8vcpu-16gb') or
(rentfree_dowebsize.user_input == 'm3-4vcpu-32gb') or
(rentfree_dowebsize.user_input == 'g-8vcpu-32gb') or
(rentfree_dowebsize.user_input == 'so-4vcpu-32gb') or
(rentfree_dowebsize.user_input == 'm6-4vcpu-32gb') or
(rentfree_dowebsize.user_input == 'gd-8vcpu-32gb') or
(rentfree_dowebsize.user_input == 'so1_5-4vcpu-32gb') or
(rentfree_dowebsize.user_input == 'm-8vcpu-64gb') or
(rentfree_dowebsize.user_input == 'c-16') or
(rentfree_dowebsize.user_input == 'c2-16vcpu-32gb') or
(rentfree_dowebsize.user_input == 'm3-8vcpu-64gb') or
(rentfree_dowebsize.user_input == 'g-16vcpu-64gb') or
(rentfree_dowebsize.user_input == 'so-8vcpu-64gb') or
(rentfree_dowebsize.user_input == 'm6-8vcpu-64gb') or
(rentfree_dowebsize.user_input == 'gd-16vcpu-64gb') or
(rentfree_dowebsize.user_input == 'so1_5-8vcpu-64gb') or
(rentfree_dowebsize.user_input == 'm-16vcpu-128gb') or
(rentfree_dowebsize.user_input == 'c-32') or
(rentfree_dowebsize.user_input == 'c2-32vcpu-64gb') or
(rentfree_dowebsize.user_input == 'm3-16vcpu-128gb') or
(rentfree_dowebsize.user_input == 'm-24vcpu-192gb') or
(rentfree_dowebsize.user_input == 'g-32vcpu-128gb') or
(rentfree_dowebsize.user_input == 'so-16vcpu-128gb') or
(rentfree_dowebsize.user_input == 'm6-16vcpu-128gb') or
(rentfree_dowebsize.user_input == 'gd-32vcpu-128gb') or
(rentfree_dowebsize.user_input == 'm3-24vcpu-192gb') or
(rentfree_dowebsize.user_input == 'g-40vcpu-160gb') or
(rentfree_dowebsize.user_input == 'so1_5-16vcpu-128gb') or
(rentfree_dowebsize.user_input == 'm-32vcpu-256gb') or
(rentfree_dowebsize.user_input == 'gd-40vcpu-160gb') or
(rentfree_dowebsize.user_input == 'so-24vcpu-192gb') or
(rentfree_dowebsize.user_input == 'm6-24vcpu-192gb') or
(rentfree_dowebsize.user_input == 'm3-32vcpu-256gb') or
(rentfree_dowebsize.user_input == 'so1_5-24vcpu-192gb') or
(rentfree_dowebsize.user_input == 'so-32vcpu-256gb') or
(rentfree_dowebsize.user_input == 'm6-32vcpu-256gb') or
(rentfree_dowebsize.user_input == 'so1_5-32vcpu-256gb')
delay: 5
retries: 3
until: rentfree_dowebdroplet.failed == False
no_log: True
- name: Assign web server droplet we created to our project...
uri:
url: "https://api.digitalocean.com/v2/projects/{{ rentfree_doproj.data.project.id }}/resources"
headers:
Authorization: "Bearer {{ rentfree_doapikey.user_input }}"
Accept: 'application/json'
body_format: json
body:
"resources": [
"do:droplet:{{ rentfree_dowebdroplet.data.droplet.id }}"
]
validate_certs: no
follow_redirects: all
return_content: yes
status_code: 200
method: POST
register: rentfree_dowebassign
delay: 5
retries: 3
until: rentfree_dowebassign.failed == False
no_log: True
- name: Get database droplet network info...
community.digitalocean.digital_ocean_droplet_info:
oauth_token: "{{ rentfree_doapikey.user_input }}"
name: "{{ rentfree_host.user_input.split('.', 1)[0] }}-db"
failed_when:
- result is not undefined
register: rentfree_dodbdropletinfo
delay: 5
retries: 3
until: rentfree_dodbdropletinfo.failed == False
- name: Get webserver droplet network info...
community.digitalocean.digital_ocean_droplet_info:
oauth_token: "{{ rentfree_doapikey.user_input }}"
name: "{{ rentfree_host.user_input.split('.', 1)[0] }}-web"
failed_when:
- result is not undefined
register: rentfree_dowebdropletinfo
delay: 5
retries: 3
until: rentfree_dowebdropletinfo.failed == False
no_log: True
- name: Associate newly created droplet with the DNS A record for the specified domain...
community.digitalocean.digital_ocean_domain_record:
oauth_token: "{{ rentfree_doapikey.user_input }}"
state: present
domain: "{{ rentfree_host.user_input }}"
type: A
name: "@"
data: "{{ rentfree_dowebdropletinfo.data[0].networks.v4[0].ip_address if rentfree_dowebdropletinfo.data[0].networks.v4[0].type == 'public' else rentfree_dowebdropletinfo.data[0].networks.v4[1].ip_address }}"
force_update: True
delegate_to: localhost
register: rentfree_doarecord
delay: 5
retries: 3
until: rentfree_doarecord.failed == False
no_log: True
- name: Add Ansible host info for the database server...
local_action:
module: ansible.builtin.add_host
hostname: rentfree-db
ansible_host: "{{ rentfree_dodbdropletinfo.data[0].networks.v4[0].ip_address if rentfree_dodbdropletinfo.data[0].networks.v4[0].type == 'public' else rentfree_dodbdropletinfo.data[0].networks.v4[1].ip_address }}"
ansible_user: root
ansible_email_addr: "{{ rentfree_email.user_input }}"
ansible_ssh_private_key_file: "~/.ssh/{{ rentfree_host.user_input.split('.', 1)[0] }}"
ansible_ssh_public_key_file: "~/.ssh/{{ rentfree_host.user_input.split('.', 1)[0] }}.pub"
ansible_private_ip_addr: "{{ rentfree_dodbdropletinfo.data[0].networks.v4[0].ip_address if rentfree_dodbdropletinfo.data[0].networks.v4[0].type == 'private' else rentfree_dodbdropletinfo.data[0].networks.v4[1].ip_address }}"
ansible_db_backup_bucket: "{{ rentfree_host.user_input.split('.', 1)[0] }}-db-backups"
ansible_dbserver_name: "{{ rentfree_host.user_input.split('.', 1)[0] }}-db"
ansible_do_region: "{{ rentfree_doregion.user_input|lower }}"
ansible_do_spaces_accesskey: "{{ rentfree_doaccess.user_input }}"
ansible_do_spaces_secretkey: "{{ rentfree_dosecret.user_input }}"
ansible_do_apikey: "{{ rentfree_doapikey.user_input }}"
ansible_do_dbdroplet_id: "{{ rentfree_dodbdroplet.data.droplet.id }}"
no_log: True
- name: Add Ansible host info for the web server...
local_action:
module: ansible.builtin.add_host
hostname: rentfree-web
ansible_host: "{{ rentfree_dowebdropletinfo.data[0].networks.v4[0].ip_address if rentfree_dowebdropletinfo.data[0].networks.v4[0].type == 'public' else rentfree_dowebdropletinfo.data[0].networks.v4[1].ip_address }}"
ansible_user: root
ansible_email_addr: "{{ rentfree_email.user_input }}"
ansible_email_pass: "{{ rentfree_emailpass.user_input }}"
ansible_ssh_private_key_file: "~/.ssh/{{ rentfree_host.user_input.split('.', 1)[0] }}"
ansible_ssh_public_key_file: "~/.ssh/{{ rentfree_host.user_input.split('.', 1)[0] }}.pub"
ansible_private_ip_addr: "{{ rentfree_dowebdropletinfo.data[0].networks.v4[0].ip_address if rentfree_dowebdropletinfo.data[0].networks.v4[0].type == 'private' else rentfree_dowebdropletinfo.data[0].networks.v4[1].ip_address }}"
ansible_dbserver_host: "{{ rentfree_dodbdropletinfo.data[0].networks.v4[0].ip_address if rentfree_dodbdropletinfo.data[0].networks.v4[0].type == 'private' else rentfree_dodbdropletinfo.data[0].networks.v4[1].ip_address }}"
ansible_dbserver_name: "{{ rentfree_host.user_input.split('.', 1)[0] }}-db"
ansible_do_apikey: "{{ rentfree_doapikey.user_input }}"
ansible_do_webdroplet_id: "{{ rentfree_dowebdroplet.data.droplet.id }}"
ansible_do_hostname: "{{ rentfree_host.user_input }}"
ansible_do_webserver_name: "{{ rentfree_host.user_input.split('.', 1)[0] }}-web"
ansible_do_dbdroplet_id: "{{ rentfree_dodbdroplet.data.droplet.id }}"
ansbile_do_s3_accesskey: "{{ rentfree_doaccess.user_input }}"
ansible_do_s3_secretkey: "{{ rentfree_dosecret.user_input }}"
ansible_do_priv_bucket: "{{ rentfree_host.user_input.split('.', 1)[0] }}-priv"
ansible_do_pub_bucket: "{{ rentfree_host.user_input.split('.', 1)[0] }}-pub"
ansible_do_cdn_hostname: "cdn.{{ rentfree_host.user_input }}"
ansible_do_region: "{{ rentfree_doregion.user_input|lower }}"
anislbe_do_sitename: "{{ rentfree_host.user_input.split('.', 1)[0]|title }}"
no_log: True
- name: Configure the new database droplet...
hosts: rentfree-db
gather_facts: False
tasks:
- name: Configure the database server...
import_tasks: includes/dbserver/postgresql.yml
- name: Configure the new webserver droplet...
hosts: rentfree-web
gather_facts: False
tasks:
- name: Configure the webserver...
import_tasks: includes/webserver/webserver.yml
- name: Take snapshots of droplets and set up firewalls...
hosts: rentfree-web
gather_facts: False
tasks:
- import_tasks: includes/snapshots_firewalls.yml

50
docs/create_forms.md Normal file
View File

@ -0,0 +1,50 @@
# Forms
Rent Free Media contains two custom form options, which have varying degrees of complexity. In both cases, the form submissions are stored as dynamic data in the admin that can be exported as a CSV (spreadsheet). If you need forms that store data in pre-set database tables, you would need to follow the Django form methods which are well documented in the Django docs. Rent Free Media forms are meant to be dynamic and createable in the CMS admin.
You can create a form page anywhere under the "Home" page of your site, and also embed a form in any other page as a "page preview block" in a streamfield if you want a more complex page layout with a form embedded in it.
## Success Page
Before creating forms, you should create a "success" page of type "generic page" somewhere below your home page, to direct users to after they have submitted the form. You probably also would want a button on that page (or perhaps a button in your footer for said page) that gives the user the option to return "home" or to another page.
## Basic Forms
The basic form type has simple fields that you can specify in the CMS admin upon creation. Submissions will show up in a `Forms` menu in the CMS admin that appears when there are form submissions to display.
To create fields for your form, simply add multiple fields specifying the options for each, in the same manner that you would add multiple authors or contributors to a content page.
You may also define confirmation emails that are sent upon successful form submission at the bottom of the `Basic Form` editor if you so choose. The fields are just like the fields used to create an email in the `Send Email` portion of this guide.
## Complex Forms
Rent Free Media also supports "dynamic" forms, which is to say... forms that change conditionally based on user input and have multiple pages of form steps.
To create a complex form choose that type when creating a form, and start by defining a form "step" each of which will be a "page" in the form process.
The editor for a complex form is a streamfield like the page editor, you add form fields that you wish the user to fill in just like you would add streamfield blocks to a page.
The complex part of a complex form becomes apparent when you open the "settings" button of a form step. Here, you can specify a field to be conditionally shown or hidden based on a prior form input.
For example, let's create a form with three text fields.
* Label: Sky (default value: "blue")
* Label: Grass (default value: "green")
* Label: Correct (default value: "good job!")
Give your form page a name at the top of the editor, and choose a success page.
Next, click the settings button on "grass" and give the field a CSS ID of `grass`
Then, click the settings button on the "correct" field, and give the field a `condition trigger ID` of "grass" and a `condition trigger value` of "green" and then preview the form.
You'll see all three fields rendered because the default values are "correct" but change the grass field to anything except green, and press tab as if you were a user filling out the form. You'll notice that the "correct" box disappeared because the value of "grass" is no longer "green" which you specified as a condition for the field's existence.
Using this logic you can build many complex multi-step forms to collect conditional user data.
## Embedding a Form
As mentioned above, you aren't limited to putting forms on pages alone without other content. To embed a form in another page, publish a form page and then edit the streamfield of another page, and select the "page preview" block where you want the form to appear, and select the form page as the source for the page preview block.
By this method, you can embed forms in other pages throughout the site rather than having them stand alone as pages of their own.

View File

@ -0,0 +1,47 @@
# HTML Templates
Rent Free Media includes Bootstrap templates by default, and includes a means of customizing templates selectively in the database, so that you may retain a "stock" installation for easy upgrades while also changing templates you wish to change.
Storing templates in the database also makes migration from server to server or replication in the case of server redundancy easier.
In `websites / settings / prod.py` in the `TEMPLATES` section of the Django settings, you'll notice `dbtemplates.loader.Loader` commented out. After your initial database migration on a new site, if you wish to enable database templates, simply uncomment this line by removing the `#` and save the `prod.py` file, run `python3 manage.py migrate` via your server's shell in the `/ home / rentfree / rentfree` folder, and restart your webserver. After doing so the `HTML Templates` option should appear in the main CMS menu.
## Template Override
To customize a particular template, first click on `HTML Templates` in the main CMS menu, and then click `add template`.
If you save a template that already exists without any content, the existing template on the disk will be copied to the database template, and the database template will override the template on disk, so always do that first when customizing templates.
For example, if you wish to override the template for the "pagelist" streamfield block, you would click `add template` and type the relative path to that template, `website/blocks/pagelist_block.html`, while leaving the content box blank.
Then, save the template and re-open it, and you should see the content box populated with the template defaults, copied from the template on the server disk. At this point you can modify the template within the CMS as needed and the template from the CMS database will override the template on the disk.
If you make a catastrophic mistake and / or need to restore the default template, simply delete the template from the `HTML Templates` section and the template on the server disk will return to the top of the precedence list and be served in place of the broken database template.
With all of this in mind, it would probably be a good idea to save a document externally that contains your HTML Template changes, so that you can restore to a working state if you make a mistake.
## Template Logic
Django templates are very similar to Jekyll templates if you have ever used, for example, Github pages or the Jekyll static site generator by itself, and also very similar to Shopify templates if you've ever set up Shopify pages.
You can not only render items from the database but also include logic at the template level, as we briefly explained in the `Sending Email` section of this guide.
Refer to the Django template docs and Wagtail template docs for detailed template documentation.
Before you decide to create an entirely new UI for a Rent Free Media site to replace all of the stock templates, consider the fact that you can go a very long way toward a completely custom site with only custom headers and footers, a custom bootstrap css build, and a few custom HTML templates.
There are over 110 HTML templates in the main `website` app of Rent Free Media. This isn't counting the user profile templates and the payment app templates for Stripe subscriptions. However, most of them are simple containers with placeholders for options you specify in the CMS and not specific to a particular style or design. For example, on the [Dubious Podcast](https://dubiouspod.com) site as a "mostly stock" example, we only have a custom bootstrap css and seven custom HTML templates, we are otherwise completely stock in terms of Rent Free Media templates. Our headers and footers are defined as snippets in the database as well.
Most of a website's appearance is in the menus, stylesheet, and basic layout, all of which can be accomplished on Rent Free Media sites without complete replacement of all of the HTML templates in most cases, unless you really want to replace the entire UI with something completely custom.
If you do, you should check out guides such as Kalob Taulien's excellent "headless Wagtail" tutorial for how to proceed.
[https://learnwagtail.com/tutorials/how-to-enable-the-v2-api-to-create-a-headless-cms/](https://learnwagtail.com/tutorials/how-to-enable-the-v2-api-to-create-a-headless-cms/)
Also, please consult the Django and Wagtail documentation for further template customization tutorials if you are unfamiliar with Django templates entirely.
[https://docs.djangoproject.com/en/stable/topics/templates/](https://docs.djangoproject.com/en/stable/topics/templates/)
[https://docs.wagtail.org/en/stable/topics/writing_templates.html](https://docs.wagtail.org/en/stable/topics/writing_templates.html)
[https://docs.wagtail.org/en/stable/topics/images.html](https://docs.wagtail.org/en/stable/topics/images.html)

7
docs/django_admin.md Normal file
View File

@ -0,0 +1,7 @@
# Django Core Admin
By default, the Rent Free Media CMS menus do not include all available admin options, but rather just the most commonly used ones. Of particular note here is social login support for user accounts that you may wish to employ, and more Stripe data you may wish to see.
These admin menu items are still available in the core Django admin, which is available at [https://yourdomain.com/django-admin](https://yourdomain.com/django-admin)
As this project progresses we will hopefully get feature parity with the Django admin and be able to completely disable it, but for now it is available if you need it at the above address. PRs to add more functionality to the CMS admin are welcome, particularly for social logins.

13
docs/index.md Normal file
View File

@ -0,0 +1,13 @@
# Welcome to Rent Free Media's documentation
Rent Free Media is a publishing distribution of Wagtail + Django aimed specifically at content creators who might otherwise use platforms such as Patreon, Substack, and Apple Podcasts. With it you can publish any sort of content to subscription users, as those services allow you to do.
As Rent Free Media is open source, you can do this on your own web host with your own user database and your own credit card (Stripe supported out of the box) merchant account. This is not a publishing "service" that extracts a fee for inserting itself between you and fans of your content. Quite the opposite actually, it aims to remove such middle men.
For those unfamiliar with the underlying frameworks, [Wagtail](https://wagtail.org) is a CMS and website building platform that sits atop [Django](https://djangoproject.com), a web framework originally written by newspaper publishers.
Please start at the "Installing" menu on the left and follow along with this tutorial in its entirety if you are unfamiliar with Wagtail and Django, you will have much more success with this software if you read the docuemntation in full. Publishing content isn't something one can just point and click their way through without understanding all of the particulars. You need to know not only what to click and input, but how the software works to get optimal results.
While it's certainly possible to publish content to your site via a phone or other mobile device once it's all set up, for the purposes of going through this manual the first time, you'll probably want two browser windows on a laptop or desktop side by side to make it easy to follow.
Enjoy!

71
docs/install.md Normal file
View File

@ -0,0 +1,71 @@
# Installing
Installation for local development or test usage is similar to other Django / Wagtail projects, with a few small caveats. You'll need a Python v3 installation, which should be included if you're doing this on a Linux machine, or you can download a Mac version of Python from [python.org](https://python.org).
Once you have a version of Python 3 installed, go through the following steps, executing the bolded commands in a terminal window. Commands and actions you perform are in `red`.
## Initial Setup
Step 1. Download the latest release from the main repository, or clone/fork the development branch if you plan to make changes to the underlying code. If you unzip it to your home folder you should be able to move to that folder (replace with wherever you unzipped or cloned the repo to):
`cd ~/rentfree`
Step 2. Make a virtual environment
`python3 -m venv ~/rentfreelibs`
Step 3. Activate it
`~/rentfreelibs/bin/activate`
Step 4. Install the required dependencies
`pip install -r requirements.txt`
Step 5. Make migrations and migrate
`python3 manage.py makemigrations`
`python3 manage.py migrate`
Step 6. Create an admin user
`python3 manage.py createsuperuser`
Step 7. Start the local test server
`python3 manage.py runserver`
At this point the test site should be up and running at [http://127.0.0.1:8000/](http://127.0.0.1:8000). You should check that in your browser to make sure it's working. It will say "Welcome to your new Wagtail site" (or something similar if that message changes since this was written).
Let's first get rid of the default page and specify the site settings.
## A New Home Page
Once the test server is running, head to [http://127.0.0.1:8000/admin/](http://127.0.0.1:8000/admin/) and login with the admin email and password that you provided in the above step.
Then, lets make a page to replace the "welcome to Wagtail" default page. Click on `pages` in the left hand grey menu, then click on the `home` icon at the very top of the pop-out menu. You should arrive at a page titled `Root`.
From here, `add a child page` and give it a random title (we'll come back and change it in a minute), and then `save draft`.
After you've saved the new page, go to `settings` all the way at the bottom of the left hand main admin menu, and click on `sites` in the pop-out menu. From here, change the port to `8000` since that is the port your test server is running on, and change the hostname to `127.0.0.1` since that is is the address where the test server is running.
Lastly, choose the new page you just created as the home page for the site, and then `save`.
At this point you can navigate back to `pages` at the top of the left hand main admin menu, go back to the root pages section via the house icon at the top, and `delete` the "Welcome to Wagtail" default home page. You do so by moving your mouse over the page, and selecting `delete` from the `more` menu.
Once you've deleted the default home page, you can `edit` the page you created a minute ago into your new home page. Change the title of the page to "Home" (capitalization will be respected here), under the `SEO` tab atop the page editor change the `slug` of the page to "home" (this is the URL of pages other than the default home page), and under the `Layout` tab of the page editor, select "home page without title and cover image" as the `template`.
After doing all of this click the `preview` button next to "save draft" and a new window should open in your browser with a preview of the page you're about to publish. It's always a good idea to preview before publishing for obvious reasons: as pages get more complex you need to check for errors before pages "go live." After the preview shows you the spiffy blank white page you have created, click the `lower arrow` next to "save draft" and select `publish`.
Congratulations, at this point you have published your first page. You can click `view live` in the green menu if you like to show the live page, and it should be blank rather than saying "Welcome to your new Wagtail site." Your browser back button after viewing the live page should bring you back to the admin section.
## Important Concepts in This Section
1. `Save draft` makes a page that is only visible to other editors and admins. A page isn't public until you `publish` it. Feel free to use this collaboratively, that's what it's there for. Multiple people can work on a draft, and only `publish` when they're sure it's all ready to go.
2. The `slug` under the `SEO` tab is the page's permanent URL. By default the CMS will choose one for you based on the first page title you type in the title field, but it's a good idea to double check, and you can always change it if you like, with the caveat that since slugs are URLs they must be unique.
3. The `settings` portion of the main admin menu contains global settings for the entire site. Feel free to go through the other menus and fill in things that make obvious sense. For instance there are social media URL fields, a Google Analytics API field if you use Google Analytics, a schema.org SEO metadata section, etc. Don't worry if some don't make sense or can't be selected yet, we'll cover them all over the course of this tutorial.
4. It's always a good idea to `preview` before publishing a page to check for mistakes. Preview will always reload your most recent changes to the page editor, even if the page hasn't been saved yet. Preview will also alert you to any errors in the page editor forms, such as required fields that you have neglected to fill in, for example.
Now head to the Publishing Indexes section of this guide, and lets make a podcast.

30
docs/manage_comments.md Normal file
View File

@ -0,0 +1,30 @@
# Comments
Rent Free Media includes the package `django-comment-dab` for AJAX user comments on pages you choose to include comments for, as well as upvote, downvote, and reply to other comments in a system that is as a whole very similar to the user experience of Reddit, for example.
There is a streamfield block called `user comments` available in the page body for each page which may contain user comments. Including the `user comments` block will put comments on the page, and omitting it will exclude it from the page, no further configuration is required.
By default, to curtail automatic spam bots, the comment section will not allow a newly registered user to see or participate in comments. They will receive a message that they must specify a username (which is not required on signup) in their profile and log in before viewing comments or commenting.
After a user has specified a username and logged in they will be allowed to see and participate.
## Controlling Comment Access
Using the principles explained in the previous section about paid content you could also optionally only allow comments for paying users, or only allow comments for logged-in users, by applying `Segment` rules to pages that have comment blocks in them.
For instance, you might specify a segment tier for "logged in" users, and only show the comment block on page variants that match "logged in" and "tier greater or equal" users, and thus encourage anonymous users to sign up at least, even if they don't pay for a premium tier, to see and participate in comments. Then you could in turn market to them with promo codes to entice them to sign up for a premium teir at certain intervals with a drip email rule as described in the `Sending Emails` section.
The possibilities are fairly endless, since you also have access to custom rules which may be created for the segmentation library.
## Comment Moderation
The `Comments` section of the main CMS menu allows you to audit user comments and moderate them.
This functionality is not different from that provided by `django-comment-dab` by default so refer to their documentation for how to moderate your comments.
[https://django-comment-dab.readthedocs.io/en/latest/](https://django-comment-dab.readthedocs.io/en/latest/)
## Comment Customization
`django-comment-dab` uses Boostrap for styling by default, as Rent Free Media's templates do, so changes to your bootstrap css file will automatically apply to django-comment-dab styling as well. If you choose to use a different CSS framework you will need to change the comment templates to match, or alternatively use the `django-comment-dab` API to return JSON data for comments and render them outside of the scope of HTML and CSS, that is up to you if you choose to perform heavy customization of your Rent Free Media installation's UI and templates.

42
docs/manage_media.md Normal file
View File

@ -0,0 +1,42 @@
# Images and Media
Wagtail has the ability to not only host but also automatically manipulate images "on the fly" in templates.
## Image Conversions
Generally, all of the provided default templates included with Rent Free Media convert images to jpegs, so if you wish to upload large PNG files rather than pre-convert them to smaller web-friendly versions that's not only okay, but encouraged for the purpose of easy management.
The [Wagtail Docs](https://docs.wagtail.org/en/stable/topics/images.html) have a very well-written section on manipulating images in templates, so we'll not simply repeat them here but rather suggest that you read those if you need to adjust the output of the images in the default templates.
## Image Naming
Most people who host this project will use an external CDN or at least another S3 storage bucket for public media (image) hosting. A benefit of such services is their cache, that can serve content without hitting the source file every time to check for a new image rendition.
A gotcha with that benefit is that re-generating an image won't necessarily serve it right away, so you will either need to clear your CDN's cache when you need to do so or, alternatively, rename images when you need to replace them.
If you need to replace an image in a page or RSS feed, renaming it will force the upstream cache from whatever CDN and image storage solution you use to get the new filename, so renaming is a good practice to get into with image replacements. If you rename, there's no reason to worry with clearing your upstream CDN's cache, the cached versions of stale images will eventually expire on their own.
You should also take care to avoid uploading multiple images with spaces and other such incompatible characters that have very similar names. The CMS can handle them and sanitize the filenames, but depending on the length of the filenames it may truncate the filenames at the spaces.
In short, explicitly name them, don't make a habit of uploading a dozen images that all begin with "instagram " (note the space), lest the CMS split the filename at the space due to filename length considerations and confuse one with another.
## Audio and Video
For content pages, audio and video can either be uploaded and hosted directly (required for paid podcast content that will appear in an RSS feed, optional for public content or content hosted only on the website), or linked to remotely. For remote content, Youtube and Vimeo are supported in addition to linking directly to video and audio files.
Uploaded content can be managed just like images via the `Media` section of the main CMS menu. If you used the provided Ansible deployment scripts, the maximum upload size was set to 300 megabytes with a form submission time of 300 seconds. This should allow for any podcast content, but may need to be adjusted for larger video files. There are three settings which control this limitation:
1. `client_max_body_size 300M;` in /etc/nginx/sites-available/main_site.conf
2. `proxy_read_timeout 300s;` in /etc/nginx/sites-available/main_site.conf
3. `ExecStart=%h/.local/bin/gunicorn website.wsgi --timeout 300 (...)` in /home/rentfree/.config/systemd/user/gunicorn.service
More optimally, a custom upload form could be provided that allows for chunked uploads of large video files, which would bypass the request body size and timeout limits present in this section. The media plugin that this distribution uses supports custom forms, documentation is available at:
[https://github.com/torchbox/wagtailmedia](https://github.com/torchbox/wagtailmedia)
## Important Concepts in This Section
1. Generally, throughout the site images are converted to jpeg automatically, so uploading large PNG files is best-practice for image uploads.
2. Renaming images you wish to replace is also best-practice to ensure that stale CDN cached images do not persist when you intend to replace them.
3. Audio and video file uploads are controlled via not only Wagtail and Django but also Nginx settings for max body size and request timeout, so very large videos will need a custom upload form that provides a chunked javascript uploader.

116
docs/paid_content.md Normal file
View File

@ -0,0 +1,116 @@
# Paid Content
`Segments` are the heart of Rent Free Media's support for paid subscriber content. If you have used other market segmentation libraries, you may be familiar with the functionality.
Essentially, users are assigned to a "tier" by rules matching their payment status. When a user requests a page that has a tier variant, they are given the version of the page that matches their tier silently.
For example, say you have a podcast where episode 3 is a preview of a paid episode. Behind the scenes, there may in fact be three versions of episode 3. You might have a "free" page that's a short preview of the episode, and a "tier 1" page that is the full episode for those who have subscribed to your first premium tier, and a "tier 2" page that is the full episode ad free for those who have subscribed to a higher premium tier.
Rules may be mixed to create complex page viewing experiences, but for the purposes of podcasts the "tier" rules are the most prevalent, so let's go through creating a simple premium tier.
**NOTE:** even if you used our Ansible scripts to install Rent Free Media, by default all Stripe data is running in "test mode", meaning that only test credit card numbers will work and no real money will be charged. To set Stripe to "live" mode you must edit your `.env` file and set `DOSTRIPE_LIVE` to "True" instead of "False". Before doing this, ensure that you have a valid webhook key specified for a valid webhook endpoint, and valid "live" stripe public and private keys specified in the `.env` file as well.
After any change to the `.env` file you need to restart your webserver for the change to take effect.
## Creating a Stripe Webhook Endpoint
You need to create an endpoint in your Stripe account that tells Stripe where to interact with your website backend to synchronize the database items. In your Stripe account dashboard, click on `Developers` in the upper right, and then click `Webhooks` on the left menu. Next, click on the `+ add endpoint` button, and specify an endpoint that looks like:
[https://mydomain.com/subscribe-events/](https://mydomain.com/subscribe-events/)
...replacing "mydomain.com" with your own domain name where Rent Free Media is installed. `/subscribe-events/` is the default listening address in Rent Free Media so should not be changed unless you know what you're doing and want to change the source code.
After you have created a webhook endpoint, click on it in the Stripe dashboard, and click `reveal` under "signing secret." You will need to specify this key alongside your Stripe public and private keys in the `.env` file described in the `Install` section of this document, to authenticate webhook events sent by Stripe to your database.
If you are just adding the webhook secret to your `.env` file for the first time right now, you'll also need to restart your webserver for the change to take effect.
## Creating a Subscription Product
Let's create a subscription product with a price in your Stripe account that matches a product on your site. In your Stripe dashboard, click on `Products` in the menu at the top of the page, then click `Add Product`.
Under `Product Information` you can name and describe your first subscription however you like, but you must specify additional metadata for Rent Free Media to identify this product as a subscription tier. Under `Product Information`, click `additional options`.
Inside of `additional options` you'll see a `Metadata` option. Click the `+ add metadata` button, and specify a name of `tier` and a value of `1`. Rent Free Media assumes your tiers will be labeled numerically and identified by the name "tier", so only numeric values for the name "tier" are supported here on Rent Free Media.
Make sure to select "recurring" for the billing type and specify the price you wish to charge for your tier 1 subscription, as well as the subscription term, the most common option is "monthly", of course.
The configuration of your subscription tiers is largely up to you. The only concepts that affect Rent Free Media are the subscription status, and the tier. If the subscrpition status is `active` and the user has a tier linked to a valid subscription product, the user will be granted access to items in that tier. If you wish to have free trials or different billing terms other than monthly or any other such uncommon configuration, that is all up to you.
## Synchronization
After you create a Premium Tier, if your webhook is set up properly, the data in Stripe will sync to your local Rent Free Media installation in a minute or two. You can check this by clicking on the `Subscriptions` menu in the main CMS menu, and then clicking on `Products`. After the products have synchronized, your newly created subscription tier should appear.
Similarly, on the public facing pages of your site, the `/subscribe/` page should show the product available to logged in users as well.
## Creating a Subcription Segment
After creating your webhook endpoint and creating a subscription product, you can create the Rent Free Media `Segment` to match it, which will allow separation of content between paying users and free / public users.
Click on `Segments` in the main CMS menu, and select the `add segment` button.
Give the segment a name, it makes sense to name it after the tier here in the case of multiple tiers. Let's call this segment "Tier 1".
Defaults are correct for the next few choices, we want the status to be "enabled", the persistence to be unchecked (so that user subscription status is checked upon each request to the site), "match any" to be unchecked so that all rules must match for a user to match, and the "type" of segment to be dynamic, so that users may be added and removed from the tier automatically based on their payment status.
Next, we need to select the rule that will apply to these users. Our choices here are toward the bottom, `Tier Equal` or `Tier Greater or Equal`. These are self explanatory and you can choose whichever you like depending on how you want to handle your subscription tiers. For testing purposes let's just make one tier, and select `+ Add Tier Greater or Equal` to allow all users at or above our new "tier 1" to access paid content.
When you select the button to add the greater or equal rule, you will be presented with a dropdown box that lists your product tiers, you should be able to select the subscription product you created earlier.
After you've selected your subscription product, click save.
For further documentation on segments including custom rules, see the documentation for the segmentation library at:
[https://wagtail-personalisation.readthedocs.io/en/latest/](https://wagtail-personalisation.readthedocs.io/en/latest/)
Rules are relatively simple, all they require is set of criteria to test a user for that will return either "true" or "false".
## Creating Subscription Content
After creating a subscription tier and subscription segment to match it, you will notice some extra buttons alongside pages when you mouse-over them in your `/episodes/` page index, back in the page editing portion of the CMS.
The `variants` button will show you a pencil icon for pages that have a subscription tier variant, and a plus icon for pages that do not but can have a subscription tier variant. Simply click the `+` under a page's variant button to add a premium tier version of that page.
Basically, what you will do for subscription content is create a preview page first that does not have the subscription tier content in it, but may have whatever other design you like, such as a short preview of a podcast episode or perhaps a paywall. Then, after creating a `tier 1 variant` of that page from the page index, you will have a mirror image of that page that will contain the premium content users have paid for by subscribing to tier 1. In the case of podcasts, premium authenticated RSS feeds are built for each paying user and shown to them in their personalized `/subscribe/` page, just as similar podcast subscription services do.
A premium user's RSS feed will have a URL that contains an encoded version of their email address, and a secret key that is generated when they view the `/subscribe/` page that generates the link. Navigating to that link will check their subscription tier status and if valid and active, allow their podcast app to download premium episodes using the credentials in their personalized URL.
Users will only be given a premium RSS link if their subscription to a tier exists, and is labeled as "active" by Stripe. If any of the data used to generate the secret key changes the secret key will no longer work, so logically if their subscription status changes to anything except "active" their RSS link will no longer function, and they cannot get a new one from the `/subscribe/` page until their subscription tier status is returned to an "active" state.
For users of Rent Free Media selling written content similar to what they would provide on a service such as Substack, premium RSS feeds are generated for the written content as well that works in the same manner a podcast RSS feed works, if the user chooses to use a news reader app to access premium articles.
## Auditing Accounts and Users
You can audit the most common Stripe subscription data via the `Subscriptions` portion of the main CMS menu. Note that most menu items are not editable, they are for display purposes only, as subscription data should not be changed locally, but rather changed on Stripe and sent to your database afterward.
The one exception is the `Media Downloads` auditor, which allows you to audit a user's downloads and optionally revoke their download links and RSS links if you suspect a user has shared their premium links publicly. Rent Free Media keeps count of how many times a user's RSS link has been used to download each premium content item.
**READ THIS CAREFULLY**
If you suspect a user has shared their premium content feed / links publicly, you can revoke their current premium content link and force them to retrieve a new one from within the `Media Downloads` section, by selecting the user via the `edit` button under their name, and clicking the `reset user links` button.
**MULTIPLE DOWNLOADS ARE NOT AN AUTOMATIC INDICATION OF ILLEGAL SHARING OF LINKS**
Let's consider the average user, they may legitimately have a phone, a laptop, and an iPad all synchronized to the same Apple iCloud account. In that case, they may legitimately download each episode three times. Perhaps they have a streaming device like Amazon Alexa or Plex Media Server that can also subscribe to podcast feeds, which would bump their download count of each episode to five.
Perhaps users share download links with their spouse if both they and their partner listen to the same podcasts, which presumably you would want to allow as well. That could raise their legitimate download count per episode to eight per episode if the spouse also has a phone, laptop, and iPad.
Reasonable leeway is the rule of thumb you should follow here. If a user has three or five downloads per episode on a consistent basis, it's probably fine and all taking place within their household. If a user has dozens or hundreds of downloads per episode, that's a good indicator that the user has shared their premium RSS link and the link should be revoked.
As the warning on the user audit page states, if you revoke a user's link, they will be notified by email. The email template used to notify them is located at `users / templates / account / email / reset_message.txt`. You can change the template if you wish to customize the message.
It should be noted that resetting the user's download link does not change their account or billing status, it just invalidates their RSS link by changing a parameter in their user profile, and thus forces them to obtain a new RSS link.
If you wish to ban a user from signing up for your subscription tiers, that should be handled via Stripe by cancelling their subscription and blocking their payment method. See the Stripe documentation for instructions on how to do so.
## Review RSS Settings
This is a good point to add a reminder to review the RSS feed settings you created in your index page earlier in this guide. There are relevant settings, such as whether or not preview episodes appear in your public RSS feed, and whether or not users are given "combined" feeds when they subscribe to a premium tier that allows them to unsubscribe the main public feed and receive everything they have access to in one single RSS link.
## Important Concepts in This Section
1. You must have at least one subscription product and your webhook endpoint properly configured in Stripe for Rent Free Media to function properly with paying users, so set Stripe up first.
2. Your subscription tier products must have "tier" metadata containing sequential numbers as values, as Rent Free Media uses these to calculate subscription tier access by applying "equal" and "greater or equal" rules.
3. `Segments` control the rules which allow users to receive paid content, so set up a segment after configuring your Stripe subscriptions and webhook endpoint.
4. You serve premium content by creating `variant` pages containing premium content matching your segment rules.
5. Stripe subscription data may be audited via the `Subscriptions` section of the main CMS menu, and user download counts may be audited (and optionally policed via revocation of RSS links) in the `Media Downloads` section of the `Subscriptions` menu.
6. Double check your RSS feed settings when creating subscription tiers and content, specifically in the case of podcasts, to make sure your preview episode visibility and combined feed settings are what you want them to be, before going "live" with your premium content.

115
docs/publish_content.md Normal file
View File

@ -0,0 +1,115 @@
# Publishing Content
Content pages are where your actual episodes, articles, or videos will live, beneath the indexes like the one we created in the previous section. In this portion of this tutorial we will create a podcast episode as an example.
## Creating an Episode
After you publish the `Index Page for a Podcast` that we set up in the previous section of this tutorial, you will have that page appear in the page tree beneath the home page. Just as you did before, navigate to the podcast index page in the page tree menu, and click the folder icon.
As mentioned in the previous step, certain content types are restricted by location in the page tree. This is another example of that concept, as `Podcast Episode Pages` can only be created underneath an `Index Page for a Podcast`. In fact the *only* page content type you can create beneath an `Index Page for a Podcast` is a podcast episode, so click on `add child page` while viewing your previously created `Index Page for a Podcast` and you will immediately be taken to the podcast episode page creation form.
## Primary Episode Data
Immediately atop the Podcast Episode Page form, you will be presented with required fields which must be filled in. The `title` is the same as the title on other pages, and will give the page editor words to create the automatically generated `slug` in the `SEO` tab as well, which you can optionally change if you like.
The `cover image` controls what image will be shown alongside the episode on this website. If the `SEO` tab og metadata fields are left blank the `cover image` will also become the Google search / social media preview metadata image on those other sites and services as well. This field is optional, if you omit the image no image will be shown on this website's index listings, but it doesn't affect the episode's appearance in RSS feed(s).
The `episode number` is exactly what it suggests, specify `1` here for now since this is the first episode we'll publish in this index.
`Episode type` is an optional field, do not specify it unless the episode you're publishing meets the criteria in the dropdown list. This only affects the episode's display in the public RSS feed, by flagging it as a bonus or trailer if you choose one of those options.
`Preview?` works hand-in-hand with the preview prefix setting we declared on the index page we created previously. If the episode is flagged as a preview of a paid episode here, and there is a prefix setting in the index, the preview prefix you defined will be applied to this episode's title in the show's RSS feed. If this is not a preview episode and / or you do not want to prefix preview episodes in your public RSS feed, select `no` here. For this episode choose `no` and we'll publish a public episode as an example.
`Front Page?` works with template variables on the site to control whether or not this episode appears in page preview lists in other pages on the site. For example, perhaps we might add a "latest episodes" list to our home page after we have created a few episodes of a podcast, and we might also want to omit certain episodes from that list. You can selectively omit episodes from that list by changing this `front page` setting to `no` but for now lets leave the default of `yes` as this will be a public / free episode.
`Caption` is a headline field. It represents what should be a short, one sentence description of this episode or article you are creating. `Caption` is appended to the beginning of the article body or episode notes in the RSS feed when it is generated. So let's put something simple here, like "This is my first episode."
`Publish date` is exactly what it describes. If you need to manually set the publish date it can be changed by clicking on the field and specifying something other than today, but the current date and time is chosen by default when you create the page in the editor. Note that the editor has an option to publish content at a specified date in the future from a draft in the page editor's `settings` tab, but this is not that setting, this is only the `publish date` used for display and sorting purposes.
## Authors and Contributors
This section introduces a new concept in Wagtail called a "clusterable" object. In short what this means is that you can add more than one item to a single field, which obviously applies to Hosts (Authors) and Contributors (Guests). This same functionality exists in identical form on Article pages.
You specify your hosts by selecting `author/host/creator` and choosing a user account from the list. The name that will appear on the live site goes through a series of conditional what-ifs, the most prominent of which is the "name" box you can specify here in the editor. If you want to customize how a person's name appears specify it here.
If the person specified has a `first_name` and `last_name` in their user profile it will next display those if no name is specified in the page content editor. Next, if they have only a `first_name` in their profile it will display their `first_name` only. Lastly, if they have no first name or last name and no name specified in the page editor, but they have a `user_name` in their profile, it will display their user name. There is also a URL setting on user accounts that may be specified by admins but isn't shown to users. If you wish to allow a guest to link their user profile to some thing they are promoting, for example, you can specify that URL on their user account in `settings` and `users` from the main CMS left hand menu and the URL will be the clickable link associated with their name and profile picture on the show notes page.
Contributors perform in an identical way as authors/hosts, the only difference being that the user accounts you can choose as a contributor are limited to those who belong to a contributor group.
There is no special permission attached to the contributor group, by default it simply exists for this purpose of limiting the choices for guest contributors selectable in content pages. You can add users to it by going to `settings`, `groups` in the main left hand CMS menu, and then selecting the contributor group and clicking `view users in this group` in the green menu atop the form, after which there will be a button that allows you to `add a user` to the group. After adding a user to the contributor group their email address will be selectable as a contributor in content page creation forms.
Multiple Authors/Hosts and Contributors/Guests can be specified on a podcast or article page. After filling out the form for one, simply click the respective `author/host/creator` or `contributor` button again to add another.
## Adding Media
Next, you specify what type and the location of media files that will be associated with this episode of your podcast or video cast. You may either upload a media file that you host directly, or link one that is hosted remotely. There is an error check here that will only allow you to choose one or the other, only one audio or video file per page is supported under the podcast page content type.
When specifying remote media content types, you can also link youtube and vimeo videos, which will be embedded in the custom player interface on your site.
The image selectors on the remote and local media fields will place a thumbnail image in your RSS feed which will be displayed on podcast directories as an image specific to this episode. Whether to add one or not is your choice. If you omit these images, the episode image field will be blank in the RSS feed and your main show image will be used as a default.
For considerations about media upload file sizes, see the Managing Media section of this guide.
## Extra Settings Tabs
Once all of the secondary data of your podcast episode is specified, it's a good time to take a look at the other page / content options in the other tabs in the editor.
* `tags` are keyword tags that serve two purposes. One, they will create categories on your site that allow users to click on a tag and get other episodes with similar content in them. Secondly, tags are embedded in your LD+JSON schema.org markup to specify keywords attached to the content in question for search engines, as well. Tags autocomplete to encourage you to re-use common tags over and over. Simply start typing in the tag form, and if you have used a tag with a similar spelling before it should appear as an option that you can select. The `tab` key on your keyboard completes a tag and starts a new one. You can specify as many or as few tags as you like.
* the `layout` tab allows you to specify a header and footer menu that will appear on the page, which may be defined as `snippets` in the main CMS left hand menu. Snippets are sections of content that are not specific to a page which may be re-used on multiple pages.
* the `SEO` tab has open graph metadata options like you had in the `Index Page for a Podcast` page you created earlier, and they perform the same functions here. You can override the image, title, and description that will appear on social media links to this episode by specifying them here, and you can also customize the permanent URL of the episode by changing the `slug` if you wish.
* the `settings` tab has some useful features for asynchronus publishing and marketing purposes. First, you may schedule an episode to be posted at a future date (and then saving it as a draft). Secondly, you may optionally have an episode *un-published* which could be useful for temporary "unlocks" of premium content, for example. You may also define a paywall in the `settings` tab, which will show a user a pop-up modal alerting them to whatever information you wish to show them. Content walls, like headers and footers, are defined as `snippets` which we will go through in detail. later in this tutorial.
## The Episode Notes / Body
The episode notes that will appear in your RSS feed and on podcast directories that parse the feed are comprised of two things: 1, the main body text, and 2 the `caption` or 'headline' from your site.
At this point we have arrived at a new concept, the `streamfield` portion of the page. This is a feature specific to Wagtail, the CMS backend of Rent Free Media. It allows you to place what will appear on your published page, in the order you want them to appear in, and control the basic layout dynamically within the page editor in the CMS admin, all without writing any code.
While you have control over the order and visibility of streamfield blocks in your finished page, the blocks will still be rendered with the templates specified in HTML/CSS code, resulting in a consistent look, feel, and user experience throughout your site.
In the simplest of terms, if you include a block in the streamfield editor, it will be shown on the page. If you omit the block, it will not be shown. If you change the order of the blocks, the order in which they appear on the page will also be changed respectively.
For a podcast episode page, the only required streamfield block is the body text, which when combined with the `caption` will become the episode notes that appear in your RSS feed and on podcast directories.
Lets create a podcast episode page body in the streamfield with all of the data we've specified to this point displayed.
Step 1. Click `title and heading data` in the streamfield page body editor, and the block will appear at the top of your page body items list. This block has no data to specify, it takes its information from the episode title and caption that you've already specified above.
Step 2. Click the `+` beneath title and heading data to add another block, and then click `authors and contributors` in the streamfield editor, and the block will appear below the title and heading data block. This block also does not have any data to specify, it takes its information from the authors and contributors you provided above.
Step 3. Click the `+` beneath authors and contributors, and select `emebed local media`. This block provides a player embedded in the page for video and audio files. Like the previous two, this block has no information for you to specify, it will embed a player for whichever type of media you chose to attach to this page in the previous steps.
Step 4. Click the `+` beneath embed local media, and select `main body text`. Here you must choose which format you wish to write your episode notes in. Rich Text is a WYSIWYG type rich text editor similar to a Google Docs file, and Markdown is... well... Markdown. If you like Markdown you already know what it is.
Write some random text in whichever format you prefer for the main body text.
Step 5. Click the `+` beneath main body text, and select `user comments` in the streamfield block menu. This will add a block that renders the user comment section beneath the main body text. Again, there's no data to specify here, including the block in the list simply chooses whether or not comments will appear on the page.
At this point your episode page is complete. You can click `preview` next to the page save action menu at the bottom of the editor and you should see all of the page rendered as a user would see it on the live site, with all of the data filled in. As mentioned before in this guide, you can change any data you like, in any of the settings tabs in the editor, and click `preview` again to verify that the result is what you want.
When you're satisfied with all of the data and changes you've made, click `publish` in the menu above save draft to publish the page live.
## Seeing Your RSS Feed
As soon as you've published the above episode live, it will appear in the public RSS feed for your podcast. You can pull up the feed by going to the URL slug you specified on your episode index page, and appending `/rss/` to the end of the address.
For instance, if your episode index page was named and slugged "Episodes" your podcast RSS feed will be available at [http://127.0.0.1:8000/episodes/rss/](http://127.0.0.1:8000/episodes/rss/) for the purposes of this tutorial, or https://mysite.com/episodes/rss/ in the case of a live website.
Note that Chrome and Firefox will show you the raw output of an RSS feed (or optionally download it so that you can view it in a text editor) but Safari will not.
## Publishing Written Articles
If you are publishing written articles instead of podcast episodes, all of the above information is still applicable. All of the features of a written article page are present in a podcast page, with podcast-specific items omitted. A written article index will also generate an RSS feed available at the same address as if you had created a podcast index and feed, so that your users may read your articles in a feed reader app or device.
## Important Concepts in This Section
1. There are settings in `podcast content pages` which interact with the index, and must be defined at the beginning of the new episode page form. You should read the tooltips and this guide to understand how these work together.
2. `Caption` and `Main Body Text` are also related, in that they will be merged together when your episode is submitted to podcast indexers. `Caption` is also the one-sentence headline on your site, so you should write the two with this relation in mind.
3. `Tags` not only categorize your content for users of your site, but also get submitted to search engines as keywords.
4. `Header` and `Footer` snippets can be defined per-page to add head and foot menus to each page.
5. `Scheduled publishing` can be used to schedule episodes to post at a future date, as well as schedule "unlock promo" episodes to be un-published at a future date, by defining options in the page editor `settings` tab. To employ this feature, simply choose the dates and times you wish to trigger and save the episode as a draft, the rest will be handled automatically.
6. `Content walls` (paywalls) can be added to pages selectively in the page editor `settings` tab, and are also defined as `snippets` like headers and footers.
7. The `streamfield` is what you use to define what will actually appear on your page's main body. You can display data or not, block by block, while keeping all of the data required to build your RSS feed present.
Next, we will build some `snippets`, also with `streamfield` blocks, to define a header and footer which can be used throughout the site.

67
docs/publish_index.md Normal file
View File

@ -0,0 +1,67 @@
# Publishing Indexes
Publishing index pages, whether it be a podcst or written articles or even video, is very similar to the process of creating the home page you went through in the previous section. The only difference will be the data you put in the pages.
## Adding Child Pages
Head back to the `pages` menu in the main left hand admin menu once you return to the CMS admin, and click the `home` page folder icon.
The large green menu atop the page browser always lets you `edit` your current page position in the index, so you could edit the home page again from here, and also `add a child page` underneath it in the larger middle section of the page if you want to make a new child of your present position in the page tree.
Note that certain pages can only be added at certain positions in the page tree. This is by design, to ensure that pages which inherit data from their parent pages are always in the proper place. For example, you can only create a `Podcast Content Page` (i.e. an episode) underneath an `Index Page for a Podcast`. If you're looking to create a particular type of content and can't find it in your `add a child page` options, you're probably in the wrong place in the page tree and need to navigate a step higher or lower to get to the proper location. `Generic Page` is a freeform content type that can be added anywhere in the tree.
Lets create an `Index Page for a Podcast` and its associated RSS feed. From the `Home` section of the page tree browser, click `add a child page` and select `Index Page for a Podcast` as the content type.
## Creating a Podcast Feed
By default, the `Index Page for a Podcast` and `Index Page for Articles` page types don't need any content added to the main body of the page, but you are welcome to add more if you wish via the main editor tab. Absent any other options, they will display a paginated list of all of the articles, blog posts, or episodes published beneath the index page. They also automatically create RSS feeds that can be published to podcast directories or used in feed readers to read articles via RSS, including authenticated ones for paying subscribers which we will get into later in this guide.
First, head to the `layout` tab in the editor and you'll see some options that you can change. `Show child pages` will be selected by default, this causes the index page to display a list of its children as an index. You can change the number of episodes or articles per page if you like from the default of 10, by changing the number in `number per page` to a different value. `Order child pages by` can be changed as well, but for our podcast example we can leave the default of `publish date, newest first` alone.
Lastly for the `layout` section, you can enable or disable the display of episode images, headlines (`captions`), author information, and date information on the index page. These settings only affect the display on your site, not on external podcast directories or feed readers, so you can choose whichever options you prefer.
## Setting up the RSS feed
The `SEO` tab of the `Index Page for a Podcast` page editor contains the bulk of the settings you'll be concerned with when publishing a podcast via this framework. Let's go through them all one by one.
* `slug` again, is the permanent URL of the page. Could be as simple as "episodes" or as complex as "my-awesome-podcast." This will be a part of your feed URL, though, so choose wisely. If your primary purpose for using this framework is publishing a podcast, and you want your URLs to look like [mypodcast.com/episodes](https://mypodcast.com/episodes) then keeping it simple here is probably what you want.
* `title tag` and `meta description` are what will display on places like Google, Twitter, and Facebook when this page is linked on those sites, so you can change these options here.
* `open graph preview image` is the image that will show on places like Google, Twitter, and Facebook, so you can click the button and upload an image for that purpose here if you like.
## Main RSS Feed Settings
Next we have the main RSS feed settings that are specific to a podcast, so lets specify what our test podcast feed will look like.
* `main entity of site` concerns JSON+LD schema.org metadata, which the site generates for search indexes automatically. Select "yes" here if this podcast will be the main content type that your site will publish, otherwise select "no."
* `rss title`, and `rss TTL` should be familiar to you if you have published a podcast before, these are your RSS feed's main title, logo / cover image, and time between episodes that you want indexes to wait before checking for a new one.
* `rss image` and `rss premium image` should also be familiar if you have seen a premium-tier podcast before. The first image setting will set the main public feed's image, and the premium image will set the cover logo for the paid subscriber feed.
At this point it's worth noting that the tooltips below each form field are written to be as helpful and descriptive as possible. For instance, if you notice the tooltip below `TTL` says that you can specify this value in "hours:minutes" format or plain "minutes" format. For instance, there are 1440 minutes in a day so putting 1440 in the `TTL` field will set that value in the feed. If you were to input "23:59" instead, it the editor will automatically convert the value to "1439" for you when the page is saved.
You will also notice that some fields in the editor have a red `*` next to them and some do not. The red `*` denotes a required field that must be filled in, while fields without the red dot are optional and may be left blank if you don't want to fill them in.
Moving along in our new podcast feed...
* `rss description` is the main description of your show in this case. HTML is enabled, and the buttons are limited to HTML tags that Apple/iTunes support. Moving your mouse over each one will show you what clicking one of the HTML tag buttons will create.
* `rss_category` and `rss_subcategory` options are precisely what they appear to be, allowing you to select a main (and optional sub) category twice for Apple's podcast directory.
* `iTunes explicit` sets whether or not your show has explicit language
* `iTunes type` controls whether or not your show will have season designators, or be listed as a a plain series of episodes. If you select `serial` here, your site index page for podcasts will also paginate the episodes by season as well.
* `combine feeds` controls whether or not the public episodes and the private episodes a paying user subscribe to will appear combined in their personal subscription feed or not.
* `episode numbers` controls whether or not episode numbers are shown in the RSS feed
* `preview prefix` will prepend episodes marked as preview of subscriber content with a string, if you publish previews of paid episodes in the public feed. For example "PREVIEW - "
* `omit previews` will select whether or not preview episodes exist in the public RSS feed. If no, you'll still publish public preview pages for paid episodes on the site, but they will be skipped over as the RSS feed is generated.
The optional fields may be filled in or not depending on your preference. Their tooltips explain their behavior.
There is no `preview` for index pages since there isn't any content beneath them to show you immemdiately after you create them, so you can go ahead and click the `lower arrow` and directly `publish` the podcast index page once you're happy with all of the options you've chosen.
## Important Concepts in This Section
1. `Add a child page` is limited based on your position in the page tree menu, to automatically handle the simplest of errors, such as trying to put a podcast episode outside of its index for example. If you can't find the content type you want when creating a page, check your position in the page tree, it's probably slightly off and you just need to navigate to the right place.
2. The content for `Index Page for a Podcast` and `Index Page for Articles` pages is controlled almost entirely in the `layout` tab, you don't need to manually specify any content in the main body of the page, unless you want to provide some extra functionality.
3. The `SEO` tab of the index page types contains all of the podcast RSS feed options you need to specify when publishing a podcast or video cast.
4. The `SEO` tab also contains the metadata fields that let you customize the appearance of pages on Google and social media sites when linked on those services.
5. There's no `preview` when creating an index page type, because there isn't content beneath it to show you immediately after the index's creation, so the `preview` button will not show a page preview if you try to use it on an index page.
Next, we will publish an episode in our new podcast index.

30
docs/reusable_snippets.md Normal file
View File

@ -0,0 +1,30 @@
# Snippets
Snippets are fragments of pages such as paywalls, headers, and footers which may be re-used throughout the site on multiple pages. They are stored independently of pages in the database, but allow the editor to create page-like streamfield content within them.
Snippets do not have "preview" functionality like pages since they are not attached to a page by default, but can be previewed by creating a draft page, attaching a snippet to that draft page, and then previewing the page.
## Content Walls
Paywalls are an obvious use case for snippets. In Rentfree, editors may create paywalls as snippets and then attach them to any number of pages after the fact.
## Headers and Footers
Header and footer menus may also be created as snippets. The editing experience is identical to the streamfield that you learned about previously when editing a page, but for extra CSS options such as specification of rows and columns to better control the layout of the resulting header and footer.
Headers and footers may be specified in two different location types after they have been created.
1. In the main CMS `Settings`, under `Layout`, default header and footer options may be specified for pages which do not exist in Wagtail. For example the user registration and user profile pages and the payment pages are stock Django views, not Wagtail pages / views. Defining a default header and footer snippet for those page types in settings / layout will apply the selected footer and header to those pages.
2. Wagtail pages can have a header and footer selected on a per-page basis, in the page editor's `Layout` tab.
## Important Concepts in This Section
1. Snippets are for creating re-usable page components like paywalls, headers, and footers.
2. There is no built-in preview functionality when editing snippets, but they can be previewed by attaching them to a page draft, and then previewing the page draft in another browser tab.
3. Headers and footers can be specified per-page on Wagtail pages in the `Layout` tab, and in the main site settings under `Layout` for page categories that aren't rendered by Wagtail, such as user profile pages and payment pages.
For further documentation on snippets, see the Wagtail core docs:
[https://docs.wagtail.org/en/stable/editor_manual/documents_images_snippets/snippets.html](https://docs.wagtail.org/en/stable/editor_manual/documents_images_snippets/snippets.html)

226
docs/send_email.md Normal file
View File

@ -0,0 +1,226 @@
# Sending Email
Rent Free Media has powerful email sending capability which allows you to not only manually email single users or groups of users, but also email marketing promotions to users by rules which parse user profile data, and in turn send personalized email to users based on templates.
It is highly recommended to use an API based email provider such as Mailgun, Amazon AWS SES, Mailjet, or Sendgrid. When sending large amounts of email, performance becomes a concern.
If you used our Ansible scripts to deploy your site on Digital Ocean, or if you deployed Rent Free Media yourself and set up email to send via Unix / Linux cron, you will be checking for queued email and sending them out once per minute. An SMTP based email setup will connect, send, disconnect, and reconnect for every single outgoing email, which is rather slow. Logically, if you have more emails to send than can be processed via SMTP in one minute, your server will fall behind in its own email queue, and a newly registered user or new paying subscriber might not get their confirmation email right away, as an example of the problems this could create.
The requirements for Rent Free Media specify [Django Anymail](https://github.com/anymail/django-anymail) as a dependency so the answer to this problem will already be installed. Consult the Anymail docs for your API email service to configure Anymail for sending via API. Using an API based email provider you will be able to send emails in batches, which should alleviate the performance concern for up to tens of thousands of users. If you get into hundreds of thousands or millions of users, you will be into custom deployment solution territory anyway, so that is a bridge to cross when your audience size reaches such levels.
## Email Templates
Rent Free Media uses a slightly customized version of [Django Post Office](https://github.com/ui/django-post_office) to send email by template. Our fork of Post Office is modified to include support for sending mail to groups of users, user data context in templated email sent to groups of users, per-user unsubscribe links in any mail sent via a template, and automatic omission of users who have unsubscribed from mail sent via a template.
Under the `Email` portion of the main CMS menu you'll notice the first option is `Email Templates`. These allow you to send one message to an unlimited number of users by group (all users who register via the public facing sign up forms are put into a "customers" group by default for example), and personalize things such as the person's name in each email that is sent.
Lets make an email template.
Step 1. Click on the `Email` menu item, then click `Email Templates`. When you arrive at the empty template list, click the `Add Email Template` button. In the editor, you must first specify a name for the template, lets call this one "test template" for demonstration purposes.
Step 2. The description is optional, its only purpose is to give you a field to search in the future when you find yourself with hundreds of email templates and need to find a particular one. Put "test" for this description for now.
Step 3. All that remain in your email template are the `Subject`, and one or both of `HTML Content` and `Text Content`. These boxes are what they suggest. You can send plain text, or HTML, or both.
You'll notice that the usubscribe link required to be in the email body is already included in the email template, so don't remove it or you will be out of compliance for sending bulk email without including a means of unsubscribing. Add your email content above the unsubscribe link(s).
*(Tip: A useful way to generate nice looking email templates is the open source project GrapesJS. There is a demo of the email template builder on their website that you can use for free to make email templates, at [https://grapesjs.com/demo-newsletter-editor.html](https://grapesjs.com/demo-newsletter-editor.html))*
You can personalize email rendering by including user data template variables. A user's main profile context (minus password and session data) is included in the email template context by default. `Context` in the case of a Django / Wagtail application like Rent Free Media means the data available from the database for you to use for a particular view or function.
Lets take a look at the user context from an email that has already been sent and see how to use it.
```
{
"id": 2,
"last_login": "2022-03-03T18:21:26.187956Z",
"is_superuser": true,
"first_name": "Sandra",
"last_name": "",
"is_staff": false,
"is_active": true,
"date_joined": "2022-03-03T18:15:12.215513Z",
"user_name": "",
"email": "sandra@email.com",
"is_mailsubscribed": true,
"is_paysubscribed": 1,
"paysubscribe_changed": "2022-03-05T16:05:16.182439Z",
"is_smssubscribed": false,
"is_newuserprofile": true,
"stripe_customer": 14,
"stripe_subscription": 14,
"stripe_paymentmethod": 17,
"url": "",
"download_resets": 0,
"unsubscribe": "domain.com/unsubscribe/asdf1234/1234asdf/"
}
```
In this case we can see that we have a user named Sandra, whose email is sandra@email.com. She is subscribed to email alerts and offers from us (`is_mailsubscribed`: `true`) and is also a premium content subscriber at tier 1 (`is_paysubscribed`: `1`).
We can use these little bits of data to personalize the email template that will be sent to each user.
For example, if we put `Dear {{ first_name }},` in the place of where we might put the user's name, such as in the first line of an email reading "Dear Sandra," the email will be personalized for *every user that receives an email sent by this template*. A user named John would recieve "Dear John," for example.
Beyond this simple example, you can also employ logic in the template rendering to customize the content.
For example, if we wanted to send an email to all email subscribed users about new content and selectively offer promo codes based on subscription tier, we could do something like the following...
```
{% if is_paysubscribed == 1 %}
Thanks for your subscription to our content!
Here is a sneak peek at the latest episodes we are working on...
Did you know that if you upgrade to tier 2, you could also get XYZ?
As a token of our appreciation, here is a promo code.
MY50OFFPROMO
Enter it at checkout to get 50% off a subscription upgrade.
{% elif is_paysubscribed >= 2 %}
Thanks for your subscription to our content!
Here is a sneak peek at the latest episodes we are working on...
{% else %}
Here is a sneak peek at the latest episodes we are working on...
Did you know that we have subscriber only content?
If you are curious, sign up for a premium subscription!
Use this code at checkout to get 25% off of any subscription tier:
MY25OFFPROMO
{% endif %}
```
Lets examine what the above email would do...
Firstly, it would check to see `if` the user `is_paysubscribed` exactly equal to (`==`) the lowest tier, tier number 1 (`{% if is_paysubscribed == 1 %}`). If so, it would include a thanks for their subscription, the sneak peak at future content, and a promo to offer the user an upgrade to tier 2 or higher at half price, in the form of a coupon code that they could apply at checkout when they upgrade.
Else, if the user is already subscribed at a tier greater than or equal to 2 (`{% elif is_paysubscribed >= 2 %}`), the promo code offer would be exlcuded, and the user would only get the sentence about "thanks for subscribing to our content, here's the sneak peek."
Else (`{% else %}`) if the user is a subscriber of neither tier 1, nor greater than or higher than tier 2, omit the "thanks for subscribing to our content" sentence, and instead send the user a 25% off promo coupon good for *any* subscription tier, since we can presume that if they're not tier 1, nor greater than or equal to tier 2, they do not have a premium subscription at all.
This email could then be sent to all email subscription enabled users, and each one would get content and coupon codes personalized based on their subscription tier. For more examples on how to use template logic, see the [Django documentation](https://docs.djangoproject.com/en/stable/).
Back to our test template. Type a subject, some plain text or HTML content, and start the email by addressing it to Dear `{{ first_name }}` as in our previous example, and save the template.
## The Send Emails Queue
`Send Emails` is where you go to send email via the templates you have created, or just to send a "one off" email to a user or group of users.
It should be noted that unless you send via a template, which has the pre-appended unsubscribe link, users *WILL NOT GET* an email with an unsubscribe link. Consider this when sending one-off emails without a template, to avoid falling out of compliance in terms of marketing email rules. The short answer to the situation regarding unsubscribe links is "if in doubt make a template, even for a one-time email."
Click on `Email` in the main CMS menu and then `Send Emails` to get to the mail queue. From here, click `Add Email` to create a new email.
Step 1. The `Email from` field should already be filled with the default email you have provided in your main site settings.
Step 2. You can choose either a single recipient by typing the email address, a comma separated list of email addresses, or a group to send the email to. Lets choose Moderators from the group email list for this example to send a test email to everyone with access to the CMS admin section, so leave the `Email to` line blank and select `Moderators` from the group list.
Step 3. Select the template you created in the previous step as the template to use for this email.
Step 4. The `status` field changes based on the email's send status, but can also be manually specified to send email by selecing `queued`, so select that option now for this email to send it out in the next batch.
You don't need to specify priority if you don't want to, but if you need to do so for performance reasons as mentioned at the top of this guide, the option exists.
`Scheduled sending time` is hopefully self explanatory. If you specify a date and time, the mail will be sent at that date and time, otherwise a queued mail will go out in the next batch.
After filling in these options click `save` to save the email queue addition, and your email will be sent with the next outgoing batch (or at the scheduled time if you selected one).
If you are using our Ansible scripts to deploy to Digital Ocean, as mentioned at the top of this section emails will be sent every minute via cron. If you are running the dev version of this project locally and haven't set cron to send mail, you can manually send the mail queue from the command line via the `send_queued_mail` command.
From the rentfree folder you created in the install section of this guide, in your terminal window:
`python3 manage.py send_queued_mail`
You should see an output like the following after running this command:
```
[INFO]2022-03-21 PID 137337: Acquired lock for sending queued emails at /tmp/post_office.lock
Acquired lock for sending queued emails at /tmp/post_office.lock
[INFO]2022-03-21 PID 137337: Started sending 1 emails with 1 processes.
Started sending 1 emails with 1 processes.
[INFO]2022-03-21 PID 137337: 1 emails attempted, 1 sent, 0 failed, 0 requeued
1 emails attempted, 1 sent, 0 failed, 0 requeued
```
...and shortly thereafter you should receive the email you sent if you put a valid email address in when you created your admin account in the install guide.
## Drip Email
Rent Free Media also has a second, more powerful way to send rule-based emails to your users. `Drip Email` allows you to specify any number of rules to send email to users by, including all data *related to* the main user field in the database, and most importantly, only send each drip email to each user one time.
By using drip email, you could have a whole marketing plan's worth of emails pre-written for users based on arbitrary criteria, and they would all send only once to each user and only at the time which all of the rules in an email "pass."
Lets create a drip email as an example.
Step 1. Select `Email`, and then `Drop Email` from the main CMS menu.
Step 2. Click on `Add Drip`
Step 3. Give the drip email a name, lets call this one "test drip".
Step 4, Leave `enabled` unchecked, so that we can test the email before it sends.
Step 5. `From email` should already be filled based on the admin email you supplied in your main site settings.
Step 6. `From email name` is optional, it will be appended as the "from email" user's common name, for instance "Mysite.com Admin" would make sense.
Step 7. The `subject template` and `body template` are exactly like their counterparts in the previous section. You can use not only plain text or HTML, but also user variables such as `{{ first_name }}` to fill the user's real first name. Compose some text, and as in the previous example address it by providing a first line of "Dear {{ first_name }},"
Step 8. At this point we must define our rules. This is very similar to the code-based rules we used as an example in the prior section, but in this case there is a menu to define them, and there are more user data fields available to you. All related database fields specific to users are present for you to check rules against.
You'll notice that one rule is already pre-filled, our unsubscribe check. Lets break down what each option means.
`Method type` defines whether the rule includes users (filter) or excludes them (exclude). We want to include users who are mail subscribed, so leave this as "filter."
`Field name` is the database field you wish to check for the data you are going to test for the rule, in this case "is_mailsubscribed" to make sure the user has not unsubscribed from email alerts.
`Lookup type` is how we are going to compare the data, as you can see you have more options here, but we want users who are "exactly" 1, or the boolean true/false equivalent of `true` for their mail subscription status.
Lastly, `rule type` defines this rule's relation *to the other rules*. In this case, we want this rule to be additive, so we select "and" whereas if you wanted to compare *multiple rules* you could select "or" instead. Remember how Authors and Contributors were able to have multiple values in the podcast content page editor in the previous section? Drip rules work exactly the same way. You can simply add another rule, define either `and` or `or` as the rule type, and have a theoretically infinte number of filter rules applied to each individual email template.
*(These are powerful filtering features and it is highly recommended that whomever writes your drip email templates and rules read the documentation at [https://django-drip-campaigns.readthedocs.io/en/latest/](https://django-drip-campaigns.readthedocs.io/en/latest/) and [https://django-drip.readthedocs.io/en/latest/](https://django-drip.readthedocs.io/en/latest/). In particular, the date/time filters are quite useful for marketing email purposes.)*
Finally, save the drip email, and back on the previous page, if you hover your mouse over the drip email you will see a button labeled `inspect`. This is the *most* useful feature of this method of sending mail, clicking on inspect will show you the emails, hypothetically, that would have been sent 3 days prior to today through 7 days after today. It is highly recommended that you save your drip emails not `enabled` and run this test to see if they match the users you expect them to match, before enabling them.
If you're satisfied that the email is going to be sent to the proper users, you can re-edit the drip email you created and change the status to `enabled` to set the email to send on the next scheduled sending time.
Unlike account and invoice emails which need to be sent as quickly as possible drip emails are designed with marketing in mind, so do not need to be sent as regularly. If you are using our Ansible scripts to deploy to Digital Ocean, a cron job was created to send drip emails once per week on Thursdays, by default. You may want to check the time on the rentfree user's crontab to tweak the time of day that they send to your liking, by logging into your server's console and running...
`crontab -e`
...as your rentfree user.
The command to send drip email manually, and the one that will be specified in your crontab if you used our Ansible scripts is (again from your rentfree user's "rentfree" folder)...
`python3 manage.py send_drips`
## Email Logs
The drip email functionality ensures that each user only receives a particular email one time by checking against its log when sending emails. *YOU SHOULD NEVER DELETE THE DRIP EMAIL LOGS* for this reason.
The primary `Send Emails` queue from the first half of this part of the guide on the other hand will quickly become quite large, since you are sending not only any marketing emails you create with its templates but also registration confirmations, password change emails to users who need to reset their passwords, subscription confirmations, etc. With that in mind, there is a cron job created by our Ansible scripts to purge the queue of old emails every night.
The command is...
`python3 manage.py cleanup_mail -d 30 --delete-attachments`
Predictably, "-d 30" stands for "days 30" and --delete-attahcments deletes orphaned email attachments. So if you run it every night, you'll have a constant log of 30 days of emails in case you need to retrieve one for whatever reason.
## Important Concepts in This Section
1. `Emails` in Rent Free Media may be sent from the CMS admin two ways, either manually by template and adding them to a queue to be sent to individual users or a group, or by `Drip Mail` which are rule-based primarily designed for marketing purposes.
2. User context is available in both email template types to personalize emails sent to each user via a template.
3. Unsubscribe links are automatically generated in email templates and you should be careful not to delete them, lest you forget to include them and fall out of compliance with email marketing laws and platform terms of service.
4. The `Send Emails` log should be cleaned out via a daily cron job that deletes email older than a certain number of days to avoid over-filling your database with email logs, while you should **NEVER** delete any of the `Drip Email Logs`.
5. People can receive any email you specify in the `Send Emails` queue, but by design each user will only receive each drip email once.
Next, we will go over uploading images and media.

92
docs/site_settings.md Normal file
View File

@ -0,0 +1,92 @@
# Site Settings
The `Settings` menu at the lower end of the main CMS menu contains site-wide settings, some of which you have specified already, but the rest are detailed here.
## Users and Groups
`Users` and `Groups` can be managed from their respective menus in the main site settings. By default all registered users are placed into a `Customers` group to facilitate emailing to all registered users, particularly for drip emails.
As mentioned in the content authoring sections of this guide, the `Contributors` and `Authors` groups exist to provide selectable names for content authoring attribution on content pages. These groups do not contain any special permissions within the CMS.
## Permissions
Permissions may be assigned to groups of users by selecting them in the `Groups` menu. Note that by default, two-factor authentication is *required* for any user with access to the CMS admin, for security purposes.
With this in mind, if you give a user CMS admin access every page on the site will redirect them to a 2FA setup form until they set up 2FA. You should probably warn them to set up 2FA on their own in their user profile beforehand to avoid this scenario.
If a user is using a phone app for 2FA and loses their phone, you can delete a user's 2FA devices for them by selecting `manage 2FA` under their name in the `Users` menu under the site settings.
## Collections
`Collections` are ways of categorizing things like documents and images into more manageable lists, since over time many hundreds or even thousands of images may be added to the site.
The usage of collections is optional.
See the Wagtail docs on collections for more detailed explanation on the usage of collections.
[https://docs.wagtail.org/en/stable/editor_manual/documents_images_snippets/collections.html](https://docs.wagtail.org/en/stable/editor_manual/documents_images_snippets/collections.html)
## Site Layouts
The `Layout` section of the site settings allows you to specify a favicon and global site logo for metadata purposes, as well as what items appear on your site's search result pages by default.
Additionally, as mentioned previously in this guide you may specify header and footer snippets in the site `Layout` settings that will apply to all search, subscribe, and user profile pages.
## Social Media
Links to `Social Media` accounts associated with your brand may be specified in the `Social Media` site settings. These links are all optional, and serve two purposes.
1. They will be appended to a list in your JSON+LD SEO metadata to inform search engines that this site is the "same as" the `Social Media` profiles listed.
2. They will be selectable when creating button links on the site, so that you may quickly create buttons linking to your social media accounts without writing any code. You can combine icons from the Bootstrap icon library in the CSS "settings" of a button in a streamfield to create branded buttons for various social media sites.
See the bootstrap icon reference for button class names.
[https://icons.getbootstrap.com](https://icons.getbootstrap.com)
## Tracking
The `Tracking` section of the main site settings has a form field for you to input your Google Analytics ID if you choose to use it. The javascript include for Google is conditionally included on the base site template and will be rendered if a GA ID is given.
There is a field for Google Tag Manager as well but by default this is not implemented, since it requires additional configuration on Google's end.
Refer to the template `website / templates / website / pages / base.html` in the head section of the HTML for how you might implement the tag manager code. It should be relatively simple to accomplish, with something like...
`{% if settings.website.AnalyticsSettings.ga_tag_manager_id and settings.website.AnalyticsSettings.ga_track_button_clicks %}`
...
`{% endif %}`
With the ellipses replaced by the provided Google script tag in the head section, and...
`{% if settings.website.AnalyticsSettings.ga_tag_manager_id and settings.website.AnalyticsSettings.ga_track_button_clicks %}`
...
`{% endif %}`
Again in the body of your base.html template, again with the provided Google iframe code replacing the ellipses.
## SEO
The `SEO` section of the main site settings contains site-wide JSON+LD schema settings for search engine purposes.
Most options are self explanatory, but take care to correctly identify your Organization type, brand name, and main content type. These will affect the search engine metadata that appears on each page. For the search engine metadata templates, see the `struct_data_` templates in `website / templates / website / includes`
If you need to specify additional JSON+LD schema data for your main page, you may do so here in the `additional organizational markup` box with the caveat that...
1. It must be in valid JSON+LD format (without curly brackets, it will go within the existing ones)
2. It must be properties of "Organization", see [https://schema.org](https://schema.org) for details.
## Google API
This setting is a single property for your Google Maps API key, if you wish to enable "places" support on Google Maps blocks.
## Cache
This is where you should go if you need to clear your site's page cache. Clicking the button purges the entire cache.
## Styleguide
The `Styleguide` is a Wagtail reference if you need to add any custom items to the CMS menu. Of particular note are bundled icons that you may wish to use toward the bottom for custom menu items.
## Reports and Workflows
If you wish to use these functions, refer to the Wagtail documentation, they are unchanged from the CMS core in Rent Free Media as of the publishing of this guide.

17
mkdocs.yml Normal file
View File

@ -0,0 +1,17 @@
site_name: Rent Free Media docs
site_url: https://rentfree.media/
theme: readthedocs
nav:
- About: index.md
- Installing: install.md
- Publishing Indexes: publish_index.md
- Publishing Content: publish_content.md
- Sending Email: send_email.md
- Creating Forms: create_forms.md
- Images & Media: manage_media.md
- Snippets: reusable_snippets.md
- Paid Content: paid_content.md
- Comments: manage_comments.md
- HTML Templates: customizing_templates.md
- Site Settings: site_settings.md
- Django Core Admin: django_admin.md

View File

@ -0,0 +1,53 @@
from django.conf import settings
from django.core.files.storage import DefaultStorage
from storages.backends.s3boto3 import S3Boto3Storage
from django.utils.deconstruct import deconstructible
@deconstructible
class StaticStorage(S3Boto3Storage):
try:
bucket_name = settings.AWS_PUBSTORAGE_BUCKET_NAME
location = settings.STATICFILES_LOCATION
gzip = settings.AWS_IS_GZIPPED
default_acl = 'public-read'
except:
pass
if settings.DEBUG:
s3_static_storage = DefaultStorage()
else:
s3_static_storage = StaticStorage()
@deconstructible
class PubMediaStorage(S3Boto3Storage):
try:
bucket_name = settings.AWS_PUBSTORAGE_BUCKET_NAME
location = settings.MEDIAFILES_LOCATION
default_acl = 'public-read'
except:
pass
if settings.DEBUG:
s3_media_storage = DefaultStorage()
else:
s3_media_storage = PubMediaStorage()
@deconstructible
class PrivMediaStorage(S3Boto3Storage):
try:
custom_domain = None
signature_version = settings.AWS_S3_SIGNATURE_VERSION
bucket_name = settings.AWS_PRIVSTORAGE_BUCKET_NAME
location = settings.MEDIAFILES_LOCATION
except:
pass
if settings.DEBUG:
s3_priv_storage = DefaultStorage()
else:
s3_priv_storage = PrivMediaStorage()

36
rentfree/env Normal file
View File

@ -0,0 +1,36 @@
DOSECRET_KEY=your-django-secret-key-generate-a-new-one
DOEMAIL_HOST=your-email-smptp.host.com
DOEMAIL_PORT=587
DOEMAIL_USER=your-email-smtp-username
DOEMAIL_PASS=yourEmailSmtpPassword
DOEMAIL_TLS=True
DOEMAIL_ADMIN=Admin
DOEMAIL_ADDR=admin@your-email-domain.net
DOBASE_URL=www.mysite.com
DOACCESS_KEY_ID=AKIAyourS3storageAccessKey
DOSECRET_ACCESS_KEY=lmnopYourS3StorageSecretKey
DOPUB_BUCKET=your-public-S3-bucket-name
DOPRIV_BUCKET=your-private-s3-bucket-name
DODEFAULT_ACL=private
DOBUCKET_ZIP=True
DOPROVIDER_URL=amazonaws.com
DOCDN_URL=cdn.yourpublicbucket.com
DOAWS_REGION=us-east1
DOTIME_ZONE=America/Chicago
DOSITE_NAME=YourSpiffyWebsiteName
DOPROJECT_NAME=website
DODB_NAME=databasename
DODB_USER=databaseuser
DODB_PASS=databasepassword
DODB_HOST=
DODB_PORT=5432
DOTIME_ZONES=US/Eastern,US/Central,US/Mountain,US/Arizona,US/Pacific,US/Hawaii,UTC
DONOSEO_VIEWS=profile,unsubscribe,subscribe_switch_view,subscribe_new,subscribe_update,subscribe_checkout_session,subscribe_price_change,subscribe_canceled,subscribe_card_change_canceled,subscribe_card_change_session,subscribe_card_change_complete,subscribe_complete,subscribe_stripe_config
DOSTRIPE_TESTPUB=pk_test_987654yourStripeTestModePublicKey
DOSTRIPE_TESTKEY=sk_test_987654yourStripetestModeSecretKey
DOSTRIPE_LIVEPUB=
DOSTRIPE_LIVEKEY=
DOSTRIPE_LIVE=False
DOSTRIPE_WHKEY=whsec_987654yourStripeWebHookSecret
DBSTRAP_URL=https://yoursite.com/static/yourcustombootstrap.min.css

13
rentfree/manage.py Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env python
import os
import sys
from dotenv import load_dotenv
load_dotenv()
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "website.settings.prod")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)

View File

@ -0,0 +1 @@
default_app_config = 'payments.apps.PaymentsConfig'

View File

@ -0,0 +1,2 @@
# Register your models here.

View File

@ -0,0 +1,8 @@
from django.apps import AppConfig
class PaymentsConfig(AppConfig):
name = 'payments'
def ready(self):
import payments.signals

View File

View File

@ -0,0 +1,2 @@
# Create your models here.

View File

@ -0,0 +1,18 @@
from djstripe import webhooks
from django.db import transaction
""" this file is an example of how you might configure custom events triggered by stripe data."""
def do_something():
pass # change pass to some other function, to send a mail, invalidate a cache, fire off a task, etc.
@webhooks.handler('plan', 'product', 'customer', 'subscription')
def my_handler(event, **kwargs): # pass the event data object and associated keys/values
if event.type == 'product': # check what type of event it is
transaction.on_commit(do_something) # after the database commits stripe product data, do a thing
else: # else, if the event type check doesn't match 'product'
pass # do nothing
""" all of this is optional, the core site functionality knows if a subscriber is 'active' (paid) and
knows if events received from stripe are valid. however, if you need some functionality not present
in the data synchronization between your database and stripe's database, this is the way to do it."""

View File

@ -0,0 +1,8 @@
.active {
border-color: var(--bs-secondary) !important;
background-color: var(--bs-primary);
color: var(--bs-dark) !important;
box-shadow: 0 2px 5px rgb(0 0 0 / 15%), 0 2px 5px rgb(0 0 0 / 20%);
} .active h1, .active h2, .active h3, .active h4, .active h5, .active h6 {
color: #303030;
}

View File

@ -0,0 +1,47 @@
var DOMAIN = window.location.origin;
var changeLoadingState = function(isLoading) {
if (isLoading) {
document.getElementById('card-submit').disabled = true;
document.getElementById('card-spinner').classList.remove('d-none');
document.getElementById('card-button-text').classList.add('d-none');
} else {
document.getElementById('card-submit').disabled = false;
document.getElementById('card-spinner').classList.add('d-none');
document.getElementById('card-button-text').classList.remove('d-none');
}
};
fetch('/subscribe-stripe-config/')
.then(function(result) { return result.json(); })
.then(function(data) {
// Initialize Stripe.js
const stripe = Stripe(data.publicKey);
var cardChangeForm = document.getElementById('subscribe-card-change-form');
if (cardChangeForm) {
cardChangeForm.addEventListener('submit', function (event) {
event.preventDefault();
changeLoadingState(true);
fetch('/subscribe-card-change-session/', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"X-CSRFToken": document.querySelector('[name="csrfmiddlewaretoken"]').value,
},
credentials: 'same-origin',
body: JSON.stringify({
"domain": DOMAIN,
})
}).then(function(result) { return result.json(); }).then(function(data) {
stripe.redirectToCheckout(
{
sessionId: data.sessionId
}
);
});
});
}
});

View File

@ -0,0 +1,68 @@
document.getElementById('submit').disabled = true;
var DOMAIN = window.location.origin;
var changeLoadingState = function(isLoading) {
if (isLoading) {
document.getElementById('submit').disabled = true;
document.getElementById('spinner').classList.remove('d-none');
document.getElementById('button-text').classList.add('d-none');
} else {
document.getElementById('submit').disabled = false;
document.getElementById('spinner').classList.add('d-none');
document.getElementById('button-text').classList.remove('d-none');
}
};
var planSelect = function(elid, name, price, prodtier) {
var pln = document.getElementById('plan');
var prce = document.getElementById('price');
var prod_tier = document.getElementById('prodtier');
var element = document.getElementById(elid);
var actives = document.getElementsByClassName('active');
pln.innerHTML = name;
prce.innerHTML = price;
prod_tier.value = prodtier;
document.getElementById('submit').disabled = false;
while(actives.length > 0){
actives[0].classList.remove('active');
}
element.classList.add('active');
};
fetch('/subscribe-stripe-config/')
.then(function(result) { return result.json(); })
.then(function(data) {
// Initialize Stripe.js
const stripe = Stripe(data.publicKey);
var paymentForm = document.getElementById('subscribe-new-form');
if (paymentForm) {
paymentForm.addEventListener('submit', function (event) {
event.preventDefault();
changeLoadingState(true);
fetch('/subscribe-checkout-session/', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"X-CSRFToken": document.querySelector('[name="csrfmiddlewaretoken"]').value,
},
credentials: 'same-origin',
body: JSON.stringify({
"product_tier": document.getElementById('prodtier').value,
"domain": DOMAIN,
})
}).then(function(result) { return result.json(); }).then(function(data) {
stripe.redirectToCheckout(
{
sessionId: data.sessionId
}
);
});
});
}
});

View File

@ -0,0 +1,14 @@
{% extends "website/pages/web_page.html" %}
{% load website_tags %}
{% block header %}
<header>
{% snippet_header %}
</header>
{% endblock %}
{% block footer %}
<footer class="footer mt-auto">
{% snippet_footer %}
</footer>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends "payments/base.html" %}
{% load i18n account website_tags %}
{% block title %}{% basefilename %}{% endblock %}
{% block content %}
<h2>{% trans 'Unsubscribe' %}</h2>
<br />
<p>{% trans 'Success! Your subscription will cancel at the end of this billing period. Until then your access to the premium content you have already paid for will remain active. We hope to see you subscribe again in the future.' %}</p>
<p class="mb-4">{% trans 'If you experience any difficulties, please ' %}<a href="mailto:{{ 'EMAIL_ADDR'|django_settings }}">{% trans 'contact us' %}</a>.</p>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "payments/base.html" %}
{% load i18n website_tags %}
{% block title %}{% basefilename %}{% endblock %}
{% block content %}
<h2>{% trans 'Subscribe Canceled' %}</h2>
<br />
<p class="mb-4">{% trans 'Action canceled. You will not be billed.' %}.</p>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "payments/base.html" %}
{% load i18n website_tags %}
{% block title %}{% basefilename %}{% endblock %}
{% block content %}
<h2>{% trans 'Card Change Canceled' %}</h2>
<br />
<p class="mb-4">{% trans 'Action canceled. No changes have been made to your account.' %}.</p>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends "payments/base.html" %}
{% load i18n account website_tags %}
{% block title %}{% basefilename %}{% endblock %}
{% block content %}
<h2>{% trans 'Payment Method Change' %}</h2>
<br />
<p>{% trans 'Success! Your payment method has been changed. You will be billed via the new payment method on your next subscription renewal date. If you need to make further changes to your account, you can do so via the ' %}<a href="{% url 'subscribe' %}">{% trans 'subscribe' %}</a> {% trans 'page you previously submitted.' %}</p>
<p class="mb-4">{% trans 'If you experience any difficulties, please ' %}<a href="mailto:{{ 'EMAIL_ADDR'|django_settings }}">{% trans 'contact us' %}</a>.</p>
{% endblock %}

View File

@ -0,0 +1,53 @@
{% extends "payments/base.html" %}
{% load i18n payments_tags website_tags static %}
{% block title %}{% basefilename %}{% endblock %}
{% block frontend_assets %}
{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static 'payments/css/stripe.checkout.css' %}">
{% endblock %}
{% block content %}
<h2>{% trans "Choose a Subscription Tier" %}</h2>
<br />
<p>{% blocktrans %}We keep our content ad-free by providing premium content to paid subscribers. Please choose a subscription tier below. All are monthly, recurring subscriptions.{% endblocktrans %}</p>
<p>{% blocktrans %}After successful payment, this page will contain premium content access instructions. You can cancel an active subscription anytime by returning to this page and selecting the 'cancel' subscription option.{% endblocktrans %}</p>
<form id="subscribe-new-form">
{% csrf_token %}
<div class="form-group">
<div class="col">
<div class="row">
{% for product in products %}
{% for price in product.plan_set.all %}
<div class="col col-md-4 pb-4 mx-auto">
<div class="card mb-4 h-100">
<div class="card-body" id="{{ product.name|slugify }}-card" role="button" onclick="planSelect('{{ product.name|slugify }}-card', '{{ product.name }}', '{{ price.human_readable_price }}', '{{ product.metadata|get_tier }}')">
<h4 class="card-title text-center"><label for="{{ product.name }}">{{ product.name }}</label></h4>
<p class="text-center">{{ price.human_readable_price }}</p>
<p class="card-text text-center">{{ product.description }}</p>
</div>
</div>
</div>
{% endfor %}
{% endfor %}
</div>
</div>
<div class="card col col-lg-9 mx-auto mb-4">
<div class="d-flex justify-content-between mx-4 mb-2 mt-4">
<p class="text-start d-inline-block">Plan:</p>
<p id="plan" class="float-right text-end d-inline-block">First select a plan above.</p>
</div>
<div class="d-flex justify-content-between mx-4 my-2">
<p class="text-start d-inline-block">Price:</p>
<p id="price" class="float-right text-end d-inline-block"></p>
<input type="hidden" name="prod_tier" value="prodtier" id="prodtier"></input>
</div>
<div class="mx-auto mb-4"><button type="submit" id="submit" class="btn btn-block btn-primary"><div class="spinner-border spinner-border-sm m-1 text-light d-none" id="spinner" role="status"></div><span id="button-text">Subscribe</span></button></div></div></div>
</form>
{% endblock %}
{% block required_scripts %}
<script src="https://js.stripe.com/v3/"></script>
<script src="{% static 'payments/js/stripe.checkout.js' %}" crossorigin="anonymous"></script>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends "payments/base.html" %}
{% load i18n account website_tags %}
{% block title %}{% basefilename %}{% endblock %}
{% block content %}
<h2>{% trans 'Subscribe' %}</h2>
<br />
<p>{% trans 'Success! If you are subscribing for the first time, your premium access details will be available on the ' %}<a href="{% url 'subscribe' %}">{% trans 'subscribe' %}</a> {% trans 'page you previously submitted' %}.</p>
<p>{% trans 'If you changed your subscription, your premium content links will change as well, so you will need to update any RSS reader(s), podcast app(s), or other such devices and applications with your new subscription link(s).' %}</p>
<p class="mb-4">{% trans 'If you experience any difficulties, please ' %}<a href="mailto:{{ 'EMAIL_ADDR'|django_settings }}">{% trans 'contact us' %}</a>.</p>
{% endblock %}

View File

@ -0,0 +1,194 @@
{% extends "payments/base.html" %}
{% load i18n payments_tags website_tags static wagtailcore_tags %}
{% wagtail_site as current_site %}
{% block title %}{% basefilename %}{% endblock %}
{% block frontend_assets %}
{{block.super}}
<link rel="stylesheet" type="text/css" href="{% static 'payments/css/stripe.checkout.css' %}">
{% endblock %}
{% block content %}
{% if request.user.stripe_subscription and request.user.stripe_subscription.status == 'active' %}
<h2 class="mb-4">{% trans "Your Premium Content Links" %}</h2>
<hr>
<p>{% blocktrans %}Links to the premium content you are subscribed to are shown below. DO NOT SHARE THEM. They are bound to your account, and enable password-less login to your premium content. You can import them to any device or application you choose such as feed readers or podcast players to access your premium content.{% endblocktrans %}</p>
{% if podcasts %}
{% for podcast in podcasts %}
<div class="form-group row mb-4">
<h5 class="mb-3 pl-4">{{podcast.parent_page.rss_title}}</h5>
<div class="col-9">
<input class="form-control mt-2" type="text" value="{{current_site.root_url}}{{podcast.parent_page.url}}premiumfeed/{{uid}}/{{token}}/" readonly="readonly">
</div>
<div class="col-3">
<button class="btn btn-primary rounded-circle mx-2" onclick="copyField(this)"><i class="bi-clipboard2-fill" role="img" aria-hidden="true" style="color:var(--bs-dark);font-size:24px;"></i></button>
</div>
</div>
{% endfor %}
{% else %}
<div class="col-9 mb-4">
<h5 class="mb-3 pl-4">No Episodes to Show</h5>
<p>
<input class="form-control" type="text" value="No premium episodes yet" readonly="readonly">
</p>
</div>
{% endif %}
{% if articles %}
{% for article in articles %}
<div class="form-group row">
<h5 class="mb-3 pl-4">{{article.parent_page.rss_title}}</h5>
<div class="col-9">
<input class="form-control mt-2" type="text" value="{{current_site.root_url}}{{article.parent_page.url}}premiumfeed/{{uid}}/{{token}}/" readonly="readonly">
</div>
<div class="col-3">
<button class="btn btn-primary rounded-circle mx-2" onclick="copyField(this)"><i class="bi-clipboard2-fill" role="img" aria-hidden="true" style="color:var(--bs-dark);font-size:24px;"></i></button>
</div>
</div>
{% endfor %}
{% else %}
<div class="col-9">
<h5 class="mb-3 pl-4">No Articles to Show</h5>
<p>
<input class="form-control" type="text" value="No premium articles yet" readonly="readonly">
</p>
</div>
{% endif %}
<h2 class="mb-4">{% trans "Your Payment Method Details" %}</h2>
<hr>
<p>{% blocktrans %}Your current default payment method is shown below, if you need to change it, submit the change button and you'll be redirected to our payment processor to update your payment method details.{% endblocktrans %}</p>
<form id="subscribe-card-change-form">
{% csrf_token %}
<div class="form-group">
<div class="col">
<div class="row">
<div class="card col col-lg-6 my-4 mx-auto">
<div class="d-flex justify-content-between mx-4 mb-2 mt-4">
<p class="text-start d-inline-block">Card:</p>
<p id="card-brand" class="float-right text-end d-inline-block">{% if request.user.stripe_paymentmethod.card.brand %}{{request.user.stripe_paymentmethod.card.brand|title}}{% endif %}</p>
</div>
<div class="d-flex justify-content-between mx-4 my-2">
<p class="text-start d-inline-block">Number:</p>
<p id="card-number" class="float-right text-end d-inline-block">{% if request.user.stripe_paymentmethod.card.last4 %}* * * * * * * * * * * * {{request.user.stripe_paymentmethod.card.last4}}{% endif %}</p>
</div>
<div class="d-flex justify-content-between mx-4 my-2">
<p class="text-start d-inline-block">Expiration:</p>
<p id="card-exp" class="float-right text-end d-inline-block">{% if request.user.stripe_paymentmethod.card and request.user.stripe_paymentmethod.card.exp_year %}{{request.user.stripe_paymentmethod.card.exp_month}} / {{request.user.stripe_paymentmethod.card.exp_year}}{% endif %}</p>
</div>
<input type="hidden" name="prod_tier" value="prodtier" id="prodtier"></input>
<div class="mx-auto mb-4"><button type="submit" id="card-submit" class="btn btn-block btn-primary"><div class="spinner-border spinner-border-sm m-1 text-light d-none" id="card-spinner" role="status"></div><span id="card-button-text">Change</span></button></div>
</div>
</div>
</div>
</div>
</form>
{% endif %}
<h2 class="mb-4">{% trans "Your Subscription Details" %}</h2>
<hr>
<p>{% blocktrans %}Your current subscription is higlighted below. You can modify your subscription plan here by submitting a different choice if you wish to upgrade or downgrade. If you change to a different subscription tier, any credits or pro-rated upgrade charges will be applied to your next monthly billing total.{% endblocktrans %}</p>
<form id="subscribe-price-change-form" action="/subscribe-price-change/" method="POST">
{% csrf_token %}
<div class="form-group row">
<div class="col">
<div class="row justify-content-center">
{% for product in products %}
{% for price in product.plan_set.all %}
<div class="col col-lg-3 pb-4">
<div class="card mb-4 h-100">
<div class="card-body{% if user.stripe_subscription.cancel_at_period_end %}"{% elif request.user.is_paysubscribed|add:"0" == product.metadata.tier|add:"0" %} active"{% else %}"{% endif %} id="{{product.name|slugify}}-card" role="button" onclick="planSelect('{{product.name|slugify}}-card', '{{product.name}}', '{{price.human_readable_price}}', '{{product.metadata|get_tier}}')">
<h4 class="card-title text-center"><label for="{{product.name}}">{{product.name}}</label></h4>
<p class="text-center">{{price.human_readable_price}}</p>
<p class="card-text">{{product.description}}</p>
</div>
</div>
</div>
{% endfor %}
{% endfor %}
<div class="col col-lg-3 pb-4">
<div class="card mb-4 h-100">
<div class="card-body{% if user.stripe_subscription.cancel_at_period_end %} active"{% else %}"{% endif %} id="cancel-subscription-card" role="button" onclick="planSelect('cancel-subscription-card', '{% if user.stripe_subscription.cancel_at_period_end %}{% for product in products %}{% if request.user.is_paysubscribed|add:"0" == product.metadata.tier|add:"0" %}Cancel {{product.name}}{% endif %}{% endfor %}{% else %}{% for product in products %}{% if request.user.is_paysubscribed|add:"0" == product.metadata.tier|add:"0" %}Cancel {{product.name}}{% endif %}{% endfor %}{% endif %}', '$0.00', '0')">
<h4 class="card-title text-center"><label for="Cancel Subscription">Cancel</label></h4>
<p class="text-center">$0.00</p>
<p class="card-text">Cancel your subscription.</p>
</div>
</div>
</div>
</div>
</div>
</div>
{% if user.stripe_subscription %}
<div class="card col col-lg-9 mx-auto mb-4">
<div class="d-flex justify-content-between mx-4 mb-2 mt-4">
<p class="text-start d-inline-block">Plan:</p>
<p id="price-name" class="float-right text-end d-inline-block">{% if user.stripe_subscription.cancel_at_period_end %}Cancel {{user.stripe_subscription.plan.product.name}}{% else %}{{user.stripe_subscription.plan.product.name}}{% endif %}</p>
</div>
<div class="d-flex justify-content-between mx-4 my-2">
<p class="text-start d-inline-block">Price:</p>
<p id="price-amount" class="float-right text-end d-inline-block">{% if user.stripe_subscription.plan.human_readable_price %}{{user.stripe_subscription.plan.human_readable_price}}{% endif %}</p>
</div>
<div class="d-flex justify-content-between mx-4 my-2">
<p class="text-start d-inline-block">Discount/Coupon:</p>
<p id="discount-amount" class="float-right text-end d-inline-block">{% if user.stripe_subscription.discount.coupon %}{% if user.stripe_subscription.discount.coupon.percent_off %}{{user.stripe_subscription.discount.coupon.percent_off|add:"0"}}% Off{% endif %}{% endif %}</p>
</div>
<div class="d-flex justify-content-between mx-4 my-2">
<p class="text-start d-inline-block">Last Action:</p>
<p id="price-status" class="float-right text-end d-inline-block">{% if user.stripe_subscription.cancel_at_period_end %}Cancels on {{user.stripe_subscription.current_period_end|date:"M d, Y"}}{% else %}{% if user.stripe_subscription.customer.charges.latest.human_readable_amount %}Billed {{user.stripe_subscription.customer.charges.latest.human_readable_amount}} on {{user.stripe_subscription.customer.charges.latest.invoice.created|date:"M d, Y"}}{% endif %}{% endif %}</p>
</div>
<div class="d-flex justify-content-between mx-4 my-2">
<input type="hidden" name="prod_tier" value="prodtier" id="product-tier"></input>
</div>
<div class="mx-auto mb-4"><button type="submit" id="price-submit" class="btn btn-block btn-primary"><span id="price-button-text">Current</span></button></div></div>
{% endif %}
</form>
{% endblock %}
{% block required_scripts %}
<script src="https://js.stripe.com/v3/"></script>
<script src="{% static 'payments/js/stripe.change.js' %}" crossorigin="anonymous"></script>
<script type="text/javascript">
document.getElementById('price-submit').disabled = true;
var planSelect = function(elid, name, price, prodtier) {
var pln = document.getElementById('price-name');
var prce = document.getElementById('price-amount');
var prod_tier = document.getElementById('product-tier');
var element = document.getElementById(elid);
var actives = document.getElementsByClassName('active');
var default_card = document.getElementById('{% if user.stripe_subscription.cancel_at_period_end %}cancel-subscription-card{% endif %}{% for product in products %}{% if request.user.is_paysubscribed|add:"0" == product.metadata.tier|add:"0" and not user.stripe_subscription.cancel_at_period_end %}{{product.name|slugify}}-card{% endif %}{% endfor %}')
pln.innerHTML = name;
prce.innerHTML = price;
prod_tier.value = prodtier;
if (element != default_card && default_card == 'cancel-subscription-card') {
document.getElementById('price-submit').disabled = false;
document.getElementById('price-button-text').innerHTML = 'Change';
document.getElementById('discount-amount').innerHTML = '';
document.getElementById('price-status').innerHTML = '';
} else if (element != default_card && default_card != 'cancel-subscription-card') {
document.getElementById('price-submit').disabled = false;
document.getElementById('price-button-text').innerHTML = 'Change';
document.getElementById('discount-amount').innerHTML = '<i><small>Coupons transfer with tier changes.</small></i>';
document.getElementById('price-status').innerHTML = '';
} else {
document.getElementById('price-submit').disabled = true;
document.getElementById('price-button-text').innerHTML = 'Current';
document.getElementById('discount-amount').innerHTML = '{% if user.stripe_subscription.discount.coupon and user.stripe_subscription.discount.coupon.valid %}{% if user.stripe_subscription.discount.coupon.percent_off %}{{user.stripe_subscription.discount.coupon.percent_off|add:"0"}}% Off{% endif %}{% endif %}';
document.getElementById('price-status').innerHTML = '{% if user.stripe_subscription.customer.charges.latest.human_readable_amount %}Billed {{user.stripe_subscription.customer.charges.latest.human_readable_amount}} on {{user.stripe_subscription.customer.charges.latest.invoice.created|date:"M d, Y"}}{% endif %}';
};
while(actives.length > 0){
actives[0].classList.remove('active');
}
element.classList.add('active');
};
</script>
<script type="text/javascript">
function copyField(event) {
/* Get the text field */
var field = event.previousSibling.parentElement.previousSibling.previousSibling.querySelector('input');
field.select();
document.execCommand("copy");
}
</script>
{% endblock %}

View File

@ -0,0 +1,8 @@
from django import template
register = template.Library()
@register.filter
def get_tier(value):
tier_value = value.get('tier')
return str(tier_value)

View File

@ -0,0 +1,2 @@
# Create your tests here.

31
rentfree/payments/urls.py Normal file
View File

@ -0,0 +1,31 @@
from django.urls import include, path, re_path
from django.views.decorators.csrf import csrf_exempt
from djstripe.views import ProcessWebhookView
from payments.views import (
subscribe_switch_view,
subscribe_new,
subscribe_update,
subscribe_checkout_session,
subscribe_price_change,
subscribe_canceled,
subscribe_card_change_canceled,
subscribe_card_change_session,
subscribe_card_change_complete,
subscribe_complete,
subscribe_stripe_config,
reset_user
)
urlpatterns = [
path('subscribe-stripe-config/', subscribe_stripe_config, name='subscribe_stripe_config'),
path('subscribe/', subscribe_switch_view(subscribe_new, subscribe_update), name='subscribe'),
path('subscribe-checkout-session/', subscribe_checkout_session, name='subscribe_checkout_session'),
path('subscribe-price-change/', subscribe_price_change, name='subscribe_price_change'),
path('subscribe-card-change-complete/<session_id>/', subscribe_card_change_complete, name='subscribe_card_change_complete'),
path('subscribe-complete/<session_id>/', subscribe_complete, name='subscribe_complete'),
path('subscribe-canceled/', subscribe_canceled, name='subscribe_canceled'),
path('subscribe-card-change-canceled/', subscribe_card_change_canceled, name='subscribe_card_change_canceled'),
path('subscribe-card-change-session/', subscribe_card_change_session, name='subscribe_card_change_session'),
path('subscribe-events/', csrf_exempt(ProcessWebhookView.as_view()), name='webhook'),
path('reset-user/<uidb64>/', reset_user, name='reset_user')
]

436
rentfree/payments/views.py Normal file
View File

@ -0,0 +1,436 @@
import json
from post_office import mail
import stripe
import uuid
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required, user_passes_test
from django.db import transaction
from django.db.models.query_utils import Q
from django.forms.models import model_to_dict
from django.http import JsonResponse, HttpResponseRedirect
from django.shortcuts import render, redirect
from django.template import Context, loader
from django.utils import timezone
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from djstripe.models import Product, Customer, Subscription, Price, PaymentMethod
from users.tokens import subscribe_token, cardchange_token, premium_token
from website.models.pages import PodcastContentPage, ArticleContentPage
from website.models.rules import TierEqualOrGreater, TierEqual
from website.wagtail_hooks import DownloadAdmin
UserModel = get_user_model()
@csrf_exempt
@login_required
def subscribe_stripe_config(request):
if request.method == 'GET':
if settings.STRIPE_LIVE_MODE == 'True':
stripe_config = {'publicKey': settings.STRIPE_LIVE_PUBLIC_KEY}
else:
stripe_config = {'publicKey': settings.STRIPE_TEST_PUBLIC_KEY}
return JsonResponse(stripe_config, safe=False)
def subscribe_switch_view(freeuser_view, paiduser_view):
@login_required
def inner_view(request, *args, **kwargs):
user_level = request.user.is_paysubscribed
if user_level == 0:
return freeuser_view(request, *args, **kwargs)
else:
return paiduser_view(request, *args, **kwargs)
return inner_view
@login_required
@require_POST
def subscribe_card_change_session(request):
if settings.STRIPE_LIVE_MODE == 'True':
stripe.api_key = settings.STRIPE_LIVE_SECRET_KEY
else:
stripe.api_key = settings.STRIPE_TEST_SECRET_KEY
data = json.loads(request.body)
token = cardchange_token.make_token(request.user)
paymethod_obj = PaymentMethod.objects.get(pk=request.user.stripe_paymentmethod.djstripe_id)
customer_obj = Customer.objects.get(pk=request.user.stripe_customer.djstripe_id)
subscription_obj = Subscription.objects.get(pk=request.user.stripe_subscription.djstripe_id)
card_change_session = stripe.checkout.Session.create(
payment_method_types = ['card'],
mode = 'setup',
customer = customer_obj.id,
metadata = {'token': token},
setup_intent_data = {
'metadata': {
'subscription_id': subscription_obj.id,
},
},
success_url = data['domain'] + '/subscribe-card-change-complete/{CHECKOUT_SESSION_ID}/',
cancel_url = data['domain'] + '/subscribe-card-change-canceled/',
)
try:
return JsonResponse({'sessionId': card_change_session['id']})
except Exception as e:
return JsonResponse({'error': e})
@login_required
@require_POST
def subscribe_checkout_session(request):
if settings.STRIPE_LIVE_MODE == 'True':
stripe.api_key = settings.STRIPE_LIVE_SECRET_KEY
else:
stripe.api_key = settings.STRIPE_TEST_SECRET_KEY
data = json.loads(request.body)
token = subscribe_token.make_token(request.user)
product_obj = Product.objects.get(metadata={'tier': data['product_tier']})
price_obj = Price.objects.get(product_id=product_obj.id)
checkout_session = stripe.checkout.Session.create(
success_url = data['domain'] + '/subscribe-complete/{CHECKOUT_SESSION_ID}/',
cancel_url = data['domain'] + '/subscribe-canceled/',
payment_method_types = ['card'],
allow_promotion_codes=True,
mode = 'subscription',
customer_email = request.user.email,
metadata = {'token': token},
line_items = [
{
'price': price_obj.id,
'quantity': 1,
}
]
)
try:
return JsonResponse({'sessionId': checkout_session['id']})
except Exception as e:
return JsonResponse({'error': e})
@transaction.atomic
@login_required
@require_POST
def subscribe_price_change(request, *args, **kwargs):
if settings.STRIPE_LIVE_MODE == 'True':
stripe.api_key = settings.STRIPE_LIVE_SECRET_KEY
else:
stripe.api_key = settings.STRIPE_TEST_SECRET_KEY
new_tier = request.POST['prod_tier']
if new_tier != '0' and request.user.is_paysubscribed > 0:
try:
user = request.user
product_obj = Product.objects.get(metadata={'tier': new_tier})
price_obj = Price.objects.get(product_id=product_obj.id)
subscription_obj = Subscription.objects.get(djstripe_id=user.stripe_subscription_id)
subscription = stripe.Subscription.retrieve(subscription_obj.id)
except Exception:
user = None
if user and subscription.cancel_at_period_end == True:
change_subscription = stripe.Subscription.modify(
subscription.id,
cancel_at_period_end=False,
proration_behavior='create_prorations',
items=[
{
'id': subscription['items']['data'][0].id,
'price': price_obj.id,
}
]
)
djstripe_subscription = Subscription.sync_from_stripe_data(change_subscription)
user.stripe_subscription = djstripe_subscription
user.is_paysubscribed = new_tier
user.paysubscribe_changed = timezone.now()
user.save()
return render(request, 'payments/subscribe_complete.html')
elif user and subscription.cancel_at_period_end == False:
"""Optionally, you could add flood control here by checking
paysubscribe_changed versus timezone.now() for users who
are not presently set to cancel, to prevent flooding the
Stripe API with subscription changes. By default this
method is identical to the subscription change method for
users who *are* set to cancel at billing cycle end."""
change_subscription = stripe.Subscription.modify(
subscription.id,
cancel_at_period_end=False,
proration_behavior='create_prorations',
items=[
{
'id': subscription['items']['data'][0].id,
'price': price_obj.id,
}
]
)
djstripe_subscription = Subscription.sync_from_stripe_data(change_subscription)
user.stripe_subscription = djstripe_subscription
user.is_paysubscribed = new_tier
user.paysubscribe_changed = timezone.now()
user.save()
return render(request, 'payments/subscribe_complete.html')
else:
return render(request, '400.html')
elif new_tier == '0' and request.user.is_paysubscribed > 0:
try:
user = request.user
subscription_obj = Subscription.objects.get(djstripe_id=user.stripe_subscription_id)
subscription = stripe.Subscription.retrieve(subscription_obj.id)
except Exception:
user = None
if user and subscription:
change_subscription = stripe.Subscription.modify(
subscription.id,
cancel_at_period_end=True,
)
djstripe_subscription = Subscription.sync_from_stripe_data(change_subscription)
user.stripe_subscription = djstripe_subscription
user.paysubscribe_changed = timezone.now()
user.save()
return render(request, 'payments/cancel_subscription.html')
else:
return render(request, '400.html')
else:
return render(request, '400.html')
@transaction.atomic
@login_required
@csrf_exempt
def subscribe_card_change_complete(request, *args, **kwargs):
if settings.STRIPE_LIVE_MODE == 'True':
stripe.api_key = settings.STRIPE_LIVE_SECRET_KEY
else:
stripe.api_key = settings.STRIPE_TEST_SECRET_KEY
if kwargs['session_id']:
try:
user = request.user
data = stripe.checkout.Session.retrieve(
kwargs['session_id'],
expand=['setup_intent', 'setup_intent.customer', 'setup_intent.payment_method']
)
token = data.metadata.token
except Exception:
user = None
if user and cardchange_token.check_token(user, token):
customer_obj = data.setup_intent.customer
payment_obj = data.setup_intent.payment_method
subscription = Subscription.objects.get(id=data.setup_intent.metadata.subscription_id)
djstripe_payment = PaymentMethod.sync_from_stripe_data(payment_obj)
user.stripe_paymentmethod = djstripe_payment
djstripe_customer = Customer.sync_from_stripe_data(customer_obj)
user.stripe_customer = djstripe_customer
subscription.default_payment_method_id = payment_obj.id
user.paysubscribe_changed = timezone.now()
subscription.save()
user.save()
return render(request, 'payments/card_change_complete.html')
else:
return render(request, '400.html')
else:
return render(request, '400.html')
@transaction.atomic
@login_required
@csrf_exempt
def subscribe_complete(request, *args, **kwargs):
if settings.STRIPE_LIVE_MODE == 'True':
stripe.api_key = settings.STRIPE_LIVE_SECRET_KEY
else:
stripe.api_key = settings.STRIPE_TEST_SECRET_KEY
if kwargs['session_id']:
try:
user = request.user
data = stripe.checkout.Session.retrieve(
kwargs['session_id'],
expand=['subscription', 'subscription.default_payment_method', 'subscription.plan.product', 'customer']
)
token = data.metadata.token
except Exception:
user = None
if user and subscribe_token.check_token(user, token):
customer_obj = data.customer
subscription_obj = data.subscription
payment_obj = data.subscription.default_payment_method
product_obj = data.subscription.plan.product
djstripe_payment = PaymentMethod.sync_from_stripe_data(payment_obj)
user.stripe_paymentmethod = djstripe_payment
djstripe_customer = Customer.sync_from_stripe_data(customer_obj)
user.stripe_customer = djstripe_customer
djstripe_subscription = Subscription.sync_from_stripe_data(subscription_obj)
user.stripe_subscription = djstripe_subscription
djstripe_tierlevel = data.subscription.plan.product.metadata.tier
user.is_paysubscribed = djstripe_tierlevel
user.paysubscribe_changed = timezone.now()
user.save()
return render(request, 'payments/subscribe_complete.html')
else:
return render(request, '400.html')
else:
return render(request, '400.html')
@login_required
@csrf_exempt
def subscribe_canceled(request):
return render(request, 'payments/canceled.html')
@login_required
@csrf_exempt
def subscribe_card_change_canceled(request):
return render(request, 'payments/card_change_canceled.html')
@login_required
def subscribe_new(request):
return render(request, 'payments/subscribe.html', {
'products': Product.objects.all().filter(type='service').exclude(metadata__isnull=True).order_by('metadata')
})
@login_required
def subscribe_update(request):
try:
tier_gte_objects = TierEqualOrGreater.objects.all()
except:
tier_gte_objects = None
try:
tier_eq_objects = TierEqual.objects.all()
except:
tier_eq_objects = None
self = request
if tier_gte_objects:
user_gte_tiers = []
for obj in tier_gte_objects:
if obj.test_user(self):
user_gte_tiers += [obj.segment_id]
if tier_eq_objects:
user_eq_tiers = []
for obj in tier_eq_objects:
if obj.test_user(self):
user_eq_tiers += [obj.segment_id]
private_queryset_pages = PodcastContentPage.objects.none()
if tier_gte_objects and tier_eq_objects:
for tier in user_gte_tiers:
private_queryset_pages |= TierEqualOrGreater.segment.get_queryset().get(id=tier).get_used_pages().values_list('variant_id')
for tier in user_eq_tiers:
private_queryset_pages |= TierEqual.segment.get_queryset().get(id=tier).get_used_pages().values_list('variant_id')
elif tier_gte_objects and not tier_eq_objects:
for tier in user_gte_tiers:
private_queryset_pages |= TierEqualOrGreater.segment.get_queryset().get(id=tier).get_used_pages().values_list('variant_id')
elif tier_eq_objects and not tier_gte_objects:
for tier in user_eq_tiers:
private_queryset_pages |= TierEqual.segment.get_queryset().get(id=tier).get_used_pages().values_list('variant_id')
else:
private_queryset_pages = None
if settings.DEBUG and settings.DATABASES['default']['ENGINE'] =='django.db.backends.sqlite3':
try:
podcast_queryset = PodcastContentPage.objects.filter(Q(id__in=private_queryset_pages)).order_by('parent_page').distinct()
except:
podcast_queryset = None
else:
try:
podcast_queryset = PodcastContentPage.objects.filter(Q(id__in=private_queryset_pages)).order_by('parent_page').distinct('parent_page')
except:
podcast_queryset = None
if settings.DEBUG and settings.DATABASES['default']['ENGINE'] =='django.db.backends.sqlite3':
try:
article_queryset = ArticleContentPage.objects.filter(Q(id__in=private_queryset_pages)).order_by('parent_page').distinct()
except:
article_queryset = None
else:
try:
article_queryset = ArticleContentPage.objects.filter(Q(id__in=private_queryset_pages)).order_by('parent_page').distinct('parent_page')
except:
article_queryset = None
token = premium_token.make_token(request.user)
uid = urlsafe_base64_encode(force_bytes(request.user.email))
return render(request, 'payments/update_subscription.html', {
'products': Product.objects.all().filter(type='service').exclude(metadata__isnull=True).order_by('metadata'),
'token': token,
'uid': uid,
'podcasts': podcast_queryset if podcast_queryset else None,
'articles': article_queryset if article_queryset else None,
})
@transaction.atomic
@user_passes_test(lambda u: u.is_superuser)
def reset_user(request, uidb64):
try:
user_email = urlsafe_base64_decode(uidb64).decode()
user = UserModel.objects.get(email=user_email)
except:
user = None
if user:
user.uuid = uuid.uuid4()
user.download_resets += 1
user.save()
DownloadAdmin.model.objects.filter(user_id=user.id).delete()
user_context = model_to_dict(user, exclude=['groups', 'password', 'user_permissions'])
request_context = { 'request': request }
template = loader.get_template('account/email/reset_message.txt')
context = {**user_context, **request_context}
html_message = template.render(context)
mail.send(
user_email,
settings.EMAIL_ADDR,
subject='Premium download link reset on ' + request.get_host(),
html_message=html_message
)
url_helper = DownloadAdmin().url_helper
index_url = url_helper.index_url
return HttpResponseRedirect(index_url)

33
rentfree/requirements.txt Normal file
View File

@ -0,0 +1,33 @@
--no-binary=wagtail-2fa
bleach==3.3.1
beautifulsoup4>=4.8,<4.10
python-dotenv>=0.19.2
Django>=3.2.12,<3.3
phonenumberslite>=8.12.30
django-allauth>=0.48.0
django-allauth-2fa>=0.8
django-anymail>=8.4
django-bootstrap5>=21
django-phonenumber-field[phonenumberslite]>=5.2
django-storages>=1.11.1
django-comments-dab>=2.7.1,<2.7.2
dj-stripe>=2.6.1
boto3>=1.20.51
lxml>=4.7.1
psycopg2>=2.9.3
git+git://github.com/rentfreemedia/django-betterforms@changes
git+git://github.com/rentfreemedia/django-post_office.git@changes
git+git://github.com/rentfreemedia/wagtail-cache.git@changes
git+git://github.com/rentfreemedia/python-video-ids.git@changes
git+git://github.com/rentfreemedia/django-summernote.git@changes
git+git://github.com/rentfreemedia/django-drip-campaigns.git@changes
git+git://github.com/rentfreemedia/wagtail-personalisation.git@changes
git+git://github.com/rentfreemedia/django-dbtemplates.git@changes
git+git://github.com/rentfreemedia/markdown.git@changes
pytube>=11.0.1
unidecode>=1.3.2
wagtail>=2.15,<2.16
wagtail-2fa>=1.5.0
wagtailmedia>=0.8.0
wagtail-markdown>=0.7.0
gunicorn>=20.1.0

View File

@ -0,0 +1,30 @@
--no-binary=wagtail-2fa
bleach==3.3.1
beautifulsoup4>=4.8,<4.10
python-dotenv>=0.19.2
Django>=3.2.12,<3.3
phonenumberslite>=8.12.30
django-allauth>=0.48.0
django-allauth-2fa>=0.8
django-anymail>=8.4
django-bootstrap5>=21
django-phonenumber-field[phonenumberslite]>=5.2
django-storages>=1.11.1
django-comments-dab>=2.7.1,<2.7.2
dj-stripe>=2.6.1
git+git://github.com/rentfreemedia/django-betterforms@changes
git+git://github.com/rentfreemedia/django-post_office.git@changes
git+git://github.com/rentfreemedia/wagtail-cache.git@changes
git+git://github.com/rentfreemedia/python-video-ids.git@changes
git+git://github.com/rentfreemedia/django-summernote.git@changes
git+git://github.com/rentfreemedia/django-drip-campaigns.git@changes
git+git://github.com/rentfreemedia/wagtail-personalisation.git@changes
git+git://github.com/rentfreemedia/django-dbtemplates.git@changes
git+git://github.com/rentfreemedia/markdown.git@changes
pytube>=11.0.1
unidecode>=1.3.2
wagtail>=2.15,<2.16
wagtail-2fa>=1.5.0
wagtailmedia>=0.8.0
wagtail-markdown>=0.7.0
gunicorn>=20.1.0

View File

View File

@ -0,0 +1,14 @@
{% extends "website/pages/web_page.html" %}
{% load website_tags %}
{% block header %}
<header>
{% snippet_header %}
</header>
{% endblock %}
{% block footer %}
<footer class="footer mt-auto">
{% snippet_footer %}
</footer>
{% endblock %}

View File

@ -0,0 +1,23 @@
{% load website_tags wagtailsettings_tags wagtailimages_tags %}{% get_settings %}
{% if not forloop.first %}
<hr>{% endif %}
<div class="row mt-4">
{% if settings.website.LayoutSettings.show_search_images %}
<div class="col-6 col-md-3">
<a href="{{page.url}}" title="{{page.title}}">
{% if page.content_type.model_class in pagetypes %}<small class="ml-3 badge badge-secondary">{{page.search_name}}</small>{% endif %}
{% if page.cover_image %}
{% image page.cover_image fill-750x750-c100 format-jpeg jpegquality-60 class="img-fluid rounded" %}
{% else %}
<p class="p-5 lead text-center bg-secondary text-white-50">{{page.title}}</p>
{% endif %}
</a>
</div>
{% endif %}
<div class="col-6 col-md-9">
<h3><a href="{{page.url}}">{{page.title}}</a></h3>
{% if settings.website.LayoutSettings.show_search_captions and page.caption %}<p class="lead">{{page.caption}}</p>{% endif %}
{% if settings.website.LayoutSettings.show_search_meta and page.get_pub_date %}<p>{{page.get_pub_date}}</p>{% endif %}
{% if settings.website.LayoutSettings.show_search_preview and page.body_preview %}<p class="d-none d-md-block">{{page.body_preview|strip_markup}}</p>{% endif %}
</div>
</div>

View File

@ -0,0 +1,55 @@
{% extends "search/base.html" %}
{% load django_bootstrap5 i18n website_tags %}
{% block title %}
{% if not form.s.value %}
{% trans 'Search' %}
{% else %}
{% trans 'Search for' %} “{{form.s.value}}”
{%endif%}
{% endblock %}
{% block content %}
<div class="my-4">
{% if not form.s.value %}
<h2>{% trans 'Search' %}</h2>
{% else %}
<h2>{% trans 'Search for' %} “{{form.s.value}}”</h2>
{%endif%}
</div>
{% if pagetypes %}
{% query_update request.GET 'p' None as qs_nop %}
<div class="my-4">
<ul class="nav nav-pills">
<li class="nav-item">
{% query_update qs_nop 't' None as qs_t %}
<a class="nav-link {% if not form.t.value %}active{% endif %}" href="?{{qs_t.urlencode}}">{% trans 'All Results' %}</a>
</li>
{% for pt in pagetypes %}
<li class="nav-item">
{% query_update qs_nop 't' pt.content_type.model as qs_t %}
<a class="nav-link {% if form.t.value == pt.content_type.model %}active{% endif %}" href="?{{qs_t.urlencode}}">{{pt.search_name_plural}}</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if results_paginated.object_list %}
{% for page in results_paginated %}
{% with page=page.specific %}
{% if not page.is_podcast_index %}{% if not page.is_article_index %}
{% include page.search_template %}
{% endif %}{% endif %}
{% endwith %}
{% endfor %}
{% include "website/includes/pagination_standard.html" with items=results_paginated %}
{% else %}
{% if form.s.value %}
<p>{% trans 'No results found.' %}</p>
{% endif %}
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% load website_tags wagtailsettings_tags wagtailimages_tags %}{% get_settings %}
<div class="row mt-4">
{% if settings.website.LayoutSettings.show_search_images %}
<div class="col-6 col-md-3">
<a href="{{page.url}}" title="{{page.title}}">
{% if page.content_type.model_class in pagetypes %}<small class="ml-3 badge badge-secondary">{{page.search_name}}</small>{% endif %}
{% if page.cover_image %}
{% image content.specific.cover_image fill-750x750-c100 format-jpeg jpegquality-60 class="img-fluid rounded" %}
{% else %}
<p class="p-5 lead text-center bg-secondary text-white-50">{{page.title}}</p>
{% endif %}
</a>
</div>
{% endif %}
<div class="col-6 col-md-9">
<h3><a href="{{page.url}}">{{page.title}}</a></h3>
{% if settings.website.LayoutSettings.show_search_captions and page.caption %}<p class="lead">{{page.caption}}</p>{% endif %}
{% if settings.website.LayoutSettings.show_search_meta and page.get_pub_date %}<p>{{page.get_pub_date}}</p>{% endif %}
{% if settings.website.LayoutSettings.show_search_preview and page.body_preview %}<p class="d-none d-md-block">{{page.body_preview|strip_markup}}</p>{% endif %}
</div>
</div>

6
rentfree/search/urls.py Normal file
View File

@ -0,0 +1,6 @@
from django.urls import re_path
from search.views import search
urlpatterns = [
re_path(r'', search, name='website_search'),
]

92
rentfree/search/views.py Normal file
View File

@ -0,0 +1,92 @@
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.contrib.contenttypes.models import ContentType
from django.db.models import F
from django.shortcuts import render
from wagtail.core.models import Site
from wagtail.search.backends import db, get_search_backend
from wagtail.search.models import Query
from wagtail_personalisation.models import PersonalisablePageMetadata
from website.forms import SearchForm
from website.models.pages import get_page_models, BasePage
from website.models.settings import GeneralSettings
def search(request):
"""
Searches pages across the entire site.
"""
search_form = SearchForm(request.GET)
pagetypes = []
results = None
results_paginated = None
site = Site.find_for_request(request)
excluded_variant_pages = PersonalisablePageMetadata.objects.exclude(canonical_page_id=F('variant_id')).values_list('variant_id')
if search_form.is_valid():
search_query = search_form.cleaned_data['s']
search_model = search_form.cleaned_data['t']
# get all website models
pagemodels = sorted(get_page_models(), key=lambda k: k.search_name)
# get filterable models
for model in pagemodels:
if model.search_filterable:
pagetypes.append(model)
# get backend
backend = get_search_backend()
# DB search. Since this backend can't handle inheritance or scoring,
# search specified page types in the desired order and chain the results together.
# This provides better search results than simply searching limited fields on WebsitePage.
db_models = []
if backend.__class__ == db.SearchBackend:
for model in get_page_models():
if model.search_db_include:
db_models.append(model)
db_models = sorted(db_models, reverse=True, key=lambda k: k.search_db_boost)
if backend.__class__ == db.SearchBackend and db_models:
for model in db_models:
# if search_model is provided, only search on that model
if not search_model or search_model == ContentType.objects.get_for_model(model).model: # noqa
curr_results = model.objects.live().exclude(pk__in=excluded_variant_pages).search(search_query)
if results:
results = list(chain(results, curr_results))
else:
results = curr_results
# Fallback for any other search backend
else:
if search_model:
try:
model = ContentType.objects.get(model=search_model).model_class()
results = model.objects.live().exclude(pk__in=excluded_variant_pages).search(search_query)
except search_model.DoesNotExist:
results = None
else:
results = BasePage.objects.live().exclude(pk__in=excluded_variant_pages).order_by('-last_published_at').search(search_query) # noqa
# paginate results
if results:
paginator = Paginator(results, GeneralSettings.for_request(request).search_num_results)
page = request.GET.get('p', 1)
try:
results_paginated = paginator.page(page)
except PageNotAnInteger:
results_paginated = paginator.page(1)
except EmptyPage:
results_paginated = paginator.page(1)
except InvalidPage:
results_paginated = paginator.page(1)
# Log the query so Wagtail can suggest promoted results
Query.get(search_query).add_hit()
# Render template
return render(request, 'search/search.html', {
'request': request,
'pagetypes': pagetypes,
'form': search_form,
'results': results,
'results_paginated': results_paginated
})

View File

@ -0,0 +1 @@
default_app_config = 'users.apps.UsersConfig'

28
rentfree/users/admin.py Normal file
View File

@ -0,0 +1,28 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .forms import CustomUserCreationForm, CustomUserChangeForm
from .models import CustomUser
class CustomUserAdmin(UserAdmin):
add_form = CustomUserCreationForm
form = CustomUserChangeForm
model = CustomUser
list_display = ('email', 'is_staff', 'is_active',)
list_filter = ('email', 'is_staff', 'is_active',)
fieldsets = (
(None, {'fields': ('email', 'password')}),
('Permissions', {'fields': ('is_staff', 'is_active')}),
)
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('email', 'password1', 'password2', 'is_staff', 'is_active')}
),
)
search_fields = ('email',)
ordering = ('email',)
admin.site.register(CustomUser, CustomUserAdmin)

8
rentfree/users/apps.py Normal file
View File

@ -0,0 +1,8 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
name = 'users'
def ready(self):
import users.signals

View File

@ -0,0 +1,5 @@
from django.conf import settings
def excluded_seo_views(request):
return {'EXCLUDED_SEO_VIEWS': settings.EXCLUDED_SEO_VIEWS}

496
rentfree/users/forms.py Normal file
View File

@ -0,0 +1,496 @@
import l18n
from operator import itemgetter
from django import forms
from django.forms import fields
from django.forms import widgets
from betterforms.multiform import MultiModelForm
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import UserCreationForm, UserChangeForm
from django.db.models.fields import BLANK_CHOICE_DASH
from django.utils.translation import ugettext_lazy as _
from wagtail.admin.localization import get_available_admin_time_zones
from wagtail.contrib.forms.models import AbstractFormField
from wagtail.users.models import UserProfile
from users.models import CustomUserProfile
from phonenumber_field.widgets import PhoneNumberInternationalFallbackWidget
from allauth.account import app_settings
from allauth.account import forms as allauthforms
from allauth.socialaccount import forms as allauthsocialforms
from allauth.utils import set_form_field_order
UserModel = get_user_model()
def _get_time_zone_choices():
time_zones = []
for tz in get_available_admin_time_zones():
if 'US/' not in tz:
time_zones.append((tz, str(l18n.tz_fullnames.get(tz, tz))))
else:
time_zones.append((tz, str(tz)))
time_zones.sort(key=itemgetter(1))
return BLANK_CHOICE_DASH + time_zones
class PasswordField(forms.CharField):
def __init__(self, *args, **kwargs):
render_value = kwargs.pop(
"render_value", app_settings.PASSWORD_INPUT_RENDER_VALUE
)
kwargs["widget"] = forms.PasswordInput(
render_value=render_value,
attrs={"placeholder": 'Password', "class": "form-control"},
)
autocomplete = kwargs.pop("autocomplete", None)
if autocomplete is not None:
kwargs["widget"].attrs["autocomplete"] = autocomplete
super(PasswordField, self).__init__(*args, **kwargs)
class SetPasswordField(PasswordField):
def __init__(self, *args, **kwargs):
kwargs["autocomplete"] = "new-password"
super(SetPasswordField, self).__init__(*args, **kwargs)
self.user = None
def clean(self, value):
value = super(SetPasswordField, self).clean(value)
value = get_adapter().clean_password(value, user=self.user)
return value
class CustomUserCreationForm(UserCreationForm):
class Meta(UserCreationForm):
model = UserModel
fields = ('email',)
class CustomUserChangeForm(UserChangeForm):
class Meta(UserChangeForm):
model = UserModel
fields = ('email',)
class InitialUserForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.Meta.required:
self.fields[field].required = True
class Meta:
model = UserModel
fields = ('first_name', 'last_name', 'user_name', 'is_mailsubscribed')
required = ('user_name',)
labels = {
'user_name': _('User name'),
'first_name': _('First name'),
'last_name': _('Last name'),
'is_mailsubscribed': _('Recieve notification and offer emails from us'),
}
widgets = {
'user_name': widgets.TextInput(attrs={'placeholder': _('User name (public: visible on comments and replies)'),
'autocomplete': 'username', 'class': 'form-control'}),
'first_name': widgets.TextInput(attrs={'placeholder':_('First name (private: only visible to admins/staff)'),
'autocomplete': 'first name', 'class': 'form-control'}),
'last_name': widgets.TextInput(attrs={'placeholder':_('Last name (private: only visible to admins/staff)'),
'autocomplete': 'last name', 'class': 'form-control'}),
'is_mailsubscribed': widgets.CheckboxInput(attrs={'input_type': 'checkbox'}),
}
error_messages = {
'user_name': {
'max_length': _('15 characters or fewer.'),
'invalid':_('Letters, digits, and the characters @ . + - _ only.'),
'unique': _('A user with that username already exists.'),
},
}
help_texts = {
'user_name': None,
}
class InitialUserProfileForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.Meta.required:
self.fields[field].required = True
class Meta:
model = CustomUserProfile
fields = ('phone',)
required = ('')
labels = {
'phone': _('Phone number'),
}
widgets = {
'phone': PhoneNumberInternationalFallbackWidget(
attrs={'placeholder': _('Phone (private: only visible to admins/staff)'),
'autocomplete': 'tel', 'class': 'form-control', 'type': 'tel'}
),
}
error_messages = {
'phone': {
'invalid': _('Invalid phone number, double check your entry.'),
'unique': _('A user with that phone number already exists.'),
},
}
class InitialWagtailProfileForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._original_avatar = self.instance.avatar
for field in self.Meta.required:
self.fields[field].required = True
current_time_zone = fields.ChoiceField(label=_('Time zone'),
choices=_get_time_zone_choices, initial='US/Eastern',
required=True, widget=widgets.Select(attrs={'class': 'form-select'}))
avatar = fields.ImageField(label=_('Profile picture'),
required=False, widget=widgets.FileInput(attrs={'label': _('Choose file'),
'class': 'form-control', 'type': 'file'}))
class Meta:
model = UserProfile
fields = ('current_time_zone', 'avatar')
required = ('current_time_zone',)
def save(self, commit=True):
if commit and self._original_avatar and (self._original_avatar != self.cleaned_data['avatar']):
# Call delete() on the storage backend directly, as calling self._original_avatar.delete()
# will clear the now-updated field on self.instance too
try:
self._original_avatar.storage.delete(self._original_avatar.name)
except IOError:
# failure to delete the old avatar shouldn't prevent us from continuing
warnings.warn("Failed to delete old avatar file: %s" % self._original_avatar.name)
super().save(commit=commit)
class InitialProfileMultiForm(MultiModelForm):
form_classes = {
'user': InitialUserForm,
'base_userprofile': InitialUserProfileForm,
'wagtail_userprofile': InitialWagtailProfileForm,
}
class SubsequentUserForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.Meta.required:
self.fields[field].required = True
class Meta:
model = UserModel
fields = ('first_name', 'last_name', 'user_name', 'is_mailsubscribed')
required = ('user_name',)
labels = {
'user_name': _('User name'),
'first_name': _('First name'),
'last_name': _('Last name'),
'is_mailsubscribed': _('Recieve notification and offer emails from us'),
}
widgets = {
'user_name': widgets.TextInput(attrs={'placeholder': _('User name (public: visible on comments and replies)'),
'autocomplete': 'username', 'class': 'form-control'}),
'first_name': widgets.TextInput(attrs={'placeholder':_('First name (private: only visible to admins/staff)'),
'autocomplete': 'first name', 'class': 'form-control'}),
'last_name': widgets.TextInput(attrs={'placeholder':_('Last name (private: only visible to admins/staff)'),
'autocomplete': 'last name', 'class': 'form-control'}),
'is_mailsubscribed': widgets.CheckboxInput(attrs={'input_type': 'checkbox'}),
}
error_messages = {
'user_name': {
'max_length': _('15 characters or fewer.'),
'invalid':_('Letters, digits, and the characters @ . + - _ only.'),
'unique': _('A user with that username already exists.'),
},
}
help_texts = {
'user_name': None,
}
class SubsequentUserProfileForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.Meta.required:
self.fields[field].required = True
class Meta:
model = CustomUserProfile
fields = ('phone',)
required = ('')
labels = {
'phone': _('Phone number'),
}
widgets = {
'phone': PhoneNumberInternationalFallbackWidget(
attrs={'placeholder': _('Phone (private: only visible to admins/staff)'),
'autocomplete': 'tel', 'class': 'form-control', 'type': 'tel'}
),
}
error_messages = {
'phone': {
'invalid': _('Invalid phone number, double check your entry.'),
'unique': _('A user with that phone number already exists.'),
},
}
class SubsequentWagtailProfileForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._original_avatar = self.instance.avatar
for field in self.Meta.required:
self.fields[field].required = True
current_time_zone = fields.ChoiceField(label=_('Time zone'),
choices=_get_time_zone_choices, initial='US/Eastern',
required=True, widget=widgets.Select(attrs={'class': 'form-select'}))
avatar = fields.ImageField(label=_('Profile picture'),
required=False, widget=widgets.FileInput(attrs={'label': _('Choose file'),
'class': 'form-control', 'type': 'file'}))
class Meta:
model = UserProfile
fields = ('current_time_zone', 'avatar')
required = ('current_time_zone',)
def save(self, commit=True):
if commit and self._original_avatar and (self._original_avatar != self.cleaned_data['avatar']):
# Call delete() on the storage backend directly, as calling self._original_avatar.delete()
# will clear the now-updated field on self.instance too
try:
self._original_avatar.storage.delete(self._original_avatar.name)
except IOError:
# failure to delete the old avatar shouldn't prevent us from continuing
warnings.warn("Failed to delete old avatar file: %s" % self._original_avatar.name)
super().save(commit=commit)
class SubsequentProfileMultiForm(MultiModelForm):
form_classes = {
'wagtail_userprofile': SubsequentWagtailProfileForm,
'base_userprofile': SubsequentUserProfileForm,
'user': SubsequentUserForm
}
class CustomLoginForm(allauthforms.LoginForm):
password = PasswordField(label=_("Password"), autocomplete="current-password")
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request", None)
super(allauthforms.LoginForm, self).__init__(*args, **kwargs)
if app_settings.AUTHENTICATION_METHOD == app_settings.AuthenticationMethod.EMAIL:
login_widget = forms.TextInput(
attrs={
"type": "email",
"placeholder": _("E-mail address"),
"autocomplete": "email",
"class": "form-control"
}
)
login_field = forms.EmailField(label=_("E-mail"), widget=login_widget)
elif app_settings.AUTHENTICATION_METHOD == app_settings.AuthenticationMethod.USERNAME:
login_widget = forms.TextInput(
attrs={"placeholder": _("Username"), "autocomplete": "username", "class": "form-control"}
)
login_field = forms.CharField(
label=_("Username"),
widget=login_widget,
max_length=get_username_max_length(),
)
else:
assert (
app_settings.AUTHENTICATION_METHOD
== app_settings.AuthenticationMethod.USERNAME_EMAIL
)
login_widget = forms.TextInput(
attrs={"placeholder": _("Username or e-mail"), "autocomplete": "email", "class": "form-control"}
)
login_field = forms.CharField(
label=pgettext("field label", "Login"), widget=login_widget
)
self.fields["login"] = login_field
set_form_field_order(self, ["login", "password", "remember"])
if app_settings.SESSION_REMEMBER is not None:
del self.fields["remember"]
def login(self, *args, **kwargs):
# Add your own processing here.
# You must return the original result.
return super(CustomLoginForm, self).login(*args, **kwargs)
class CustomSignupForm(allauthforms.SignupForm):
def __init__(self, *args, **kwargs):
super(allauthforms.SignupForm, self).__init__(*args, **kwargs)
self.fields["password1"] = PasswordField(
label=_("Password (your account password for this site)"), autocomplete="new-password"
)
self.fields["email"] = fields.EmailField(label=_("E-Mail (this will be your account username)"), widget=widgets.TextInput(attrs={'type': 'email',
'placeholder': _('E-Mail'),
'autocomplete': 'email', 'class': 'form-control'}))
if app_settings.SIGNUP_PASSWORD_ENTER_TWICE:
self.fields["password2"] = PasswordField(label=_("Password (again)"))
if hasattr(self, "field_order"):
set_form_field_order(self, self.field_order)
is_mailsubscribed = fields.BooleanField(label=_("Allow us to send you notifications and offers."), initial=True)
field_order = ['email', 'email2', 'password1', 'password2', 'is_mailsubscribed']
def save(self, request):
# Ensure you call the parent class's save.
# .save() returns a User object.
user = super(CustomSignupForm, self).save(request)
# Add your own processing here.
user.is_mailsubscribed = self.cleaned_data['is_mailsubscribed']
user.save()
# You must return the original result.
return user
class CustomSocialSignupForm(allauthsocialforms.SignupForm):
first_name = fields.CharField(max_length=30, label=_("First Name"),
widget=widgets.TextInput(attrs={'placeholder': _('First name'), 'class': 'form-control'}))
last_name = fields.CharField(max_length=30, label=_("Last Name"),
widget=widgets.TextInput(attrs={'placeholder': _('Last name'), 'class': 'form-control'}))
is_mailsubscribed = fields.BooleanField(label=_("Allow us to e-mail you"), initial=True)
field_order = ['first_name', 'last_name', 'email', 'email2', 'password1', 'password2', 'is_mailsubscribed']
def save(self):
# Ensure you call the parent class's save.
# .save() returns a User object.
user = super(CustomSocialSignupForm, self).save()
# Add your own processing here.
user.first_name = self.cleaned_data['first_name']
user.last_name = self.cleaned_data['last_name']
user.is_mailsubscribed = self.cleaned_data['is_mailsubscribed']
user.save()
# You must return the original result.
return user
class CustomSocialDisconnectForm(allauthsocialforms.DisconnectForm):
def save(self):
# Add your own processing here if you do need access to the
# socialaccount being deleted.
# Ensure you call the parent class's save.
# .save() does not return anything
super(CustomSocialDisconnectForm, self).save()
# Add your own processing here if you don't need access to the
# socialaccount being deleted.
class CustomAddEmailForm(allauthforms.AddEmailForm):
email = forms.EmailField(
label=_("E-mail"),
required=True,
widget=forms.TextInput(
attrs={"type": "email", "placeholder": _("E-mail address"), "class": "form-control"}
),
)
class CustomChangePasswordForm(allauthforms.ChangePasswordForm):
oldpassword = PasswordField(
label=_("Current Password"), autocomplete="current-password"
)
password1 = SetPasswordField(label=_("New Password"))
password2 = PasswordField(label=_("New Password (again)"))
def save(self):
# Ensure you call the parent class's save.
# .save() does not return anything
super(CustomChangePasswordForm, self).save()
# Add your own processing here.
class CustomSetPasswordForm(allauthforms.SetPasswordForm):
password1 = SetPasswordField(label=_("Password"))
password2 = PasswordField(label=_("Password (again)"))
def save(self):
# Ensure you call the parent class's save.
# .save() does not return anything
super(CustomSetPasswordForm, self).save()
# Add your own processing here.
class CustomResetPasswordForm(allauthforms.ResetPasswordForm):
email = forms.EmailField(
label=_("E-mail"),
required=True,
widget=forms.TextInput(
attrs={
"type": "email",
"placeholder": _("E-mail address"),
"autocomplete": "email",
"class": "form-control"
}
),
)
def save(self, request):
# Ensure you call the parent class's save.
# .save() returns a string containing the email address supplied
email_address = super(CustomResetPasswordForm, self).save(request)
# Add your own processing here.
# Ensure you return the original result
return email_address
class CustomResetPasswordKeyForm(allauthforms.ResetPasswordKeyForm):
password1 = SetPasswordField(label=_("Password"))
password2 = PasswordField(label=_("Password (again)"))
def save(self):
# Add your own processing here.
# Ensure you call the parent class's save.
# .save() does not return anything
super(CustomResetPasswordKeyForm, self).save()

View File

@ -0,0 +1,36 @@
from django.contrib.auth.base_user import BaseUserManager
from django.utils.translation import ugettext_lazy as _
class CustomUserManager(BaseUserManager):
"""
Custom user model manager where email is the unique identifiers
for authentication instead of usernames.
"""
def create_user(self, email, password, **extra_fields):
"""
Create and save a User with the given email and password.
"""
if not email:
raise ValueError(_('The Email must be set'))
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save()
return user
def create_superuser(self, email, password, **extra_fields):
"""
Create and save a SuperUser with the given email and password.
"""
extra_fields.setdefault('is_staff', True)
extra_fields.setdefault('is_superuser', True)
extra_fields.setdefault('is_active', True)
if extra_fields.get('is_staff') is not True:
raise ValueError(_('Superuser must have is_staff=True.'))
if extra_fields.get('is_superuser') is not True:
raise ValueError(_('Superuser must have is_superuser=True.'))
return self.create_user(email, password, **extra_fields)

View File

@ -0,0 +1,61 @@
# Generated by Django 3.2.12 on 2022-02-09 11:32
from django.conf import settings
import django.contrib.auth.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import phonenumber_field.modelfields
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='CustomUser',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('user_name', models.CharField(blank=True, error_messages={'unique': 'A user with that user name already exists.'}, help_text='15 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=15, null=True, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='user_name')),
('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')),
('is_mailsubscribed', models.BooleanField(default=True)),
('is_paysubscribed', models.PositiveSmallIntegerField(default=0)),
('paysubscribe_changed', models.DateTimeField(blank=True, default=django.utils.timezone.now)),
('is_smssubscribed', models.BooleanField(default=False)),
('is_newuserprofile', models.BooleanField(default=True)),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('url', models.URLField(blank=True, help_text='The preferred url for this user, will be linked on content pages for their bio.', null=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
),
migrations.CreateModel(
name='CustomUserProfile',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, default=None, max_length=128, null=True, region=None, unique=True, verbose_name='phone number')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='base_userprofile', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'users_customuser_profile',
},
),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 3.2.12 on 2022-02-09 11:40
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('djstripe', '0008_2_5'),
('users', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='stripe_customer',
field=models.ForeignKey(blank=True, help_text='The user Stripe Customer object, if it exists.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='djstripe.customer'),
),
migrations.AddField(
model_name='customuser',
name='stripe_paymentmethod',
field=models.ForeignKey(blank=True, help_text='The user Stripe Payment Method object, if it exists.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='djstripe.paymentmethod'),
),
migrations.AddField(
model_name='customuser',
name='stripe_subscription',
field=models.ForeignKey(blank=True, help_text='The user Stripe Subscription object, if it exists.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='djstripe.subscription'),
),
migrations.AddField(
model_name='customuser',
name='user_permissions',
field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.12 on 2022-02-22 02:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0002_auto_20220209_0540'),
]
operations = [
migrations.AddField(
model_name='customuserprofile',
name='download_resets',
field=models.SmallIntegerField(default=0, verbose_name='Download resets'),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 3.2.12 on 2022-02-22 03:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0003_customuserprofile_download_resets'),
]
operations = [
migrations.RemoveField(
model_name='customuserprofile',
name='download_resets',
),
migrations.AddField(
model_name='customuser',
name='download_resets',
field=models.SmallIntegerField(default=0, verbose_name='Download resets'),
),
]

View File

72
rentfree/users/models.py Normal file
View File

@ -0,0 +1,72 @@
import uuid
from django.utils import timezone
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.utils.translation import ugettext_lazy as _
from django.contrib.auth.validators import UnicodeUsernameValidator
from phonenumber_field.modelfields import PhoneNumberField
from .managers import CustomUserManager
class CustomUser(AbstractUser):
username_validator = UnicodeUsernameValidator()
username = None
user_name = models.CharField(
_('user_name'),
max_length=15,
unique=True,
null=True,
blank=True,
help_text=_('15 characters or fewer. Letters, digits and @/./+/-/_ only.'),
validators=[username_validator],
error_messages={
'unique': _('A user with that user name already exists.'),
},
)
email = models.EmailField(_('email address'), unique=True)
is_mailsubscribed = models.BooleanField(default=True)
is_paysubscribed = models.PositiveSmallIntegerField(default=0)
paysubscribe_changed = models.DateTimeField(default=timezone.now, blank=True)
is_smssubscribed = models.BooleanField(default=False)
is_newuserprofile = models.BooleanField(default=True)
stripe_customer = models.ForeignKey(
'djstripe.Customer', null=True, blank=True, on_delete=models.SET_NULL,
help_text=_('The user Stripe Customer object, if it exists.')
)
stripe_subscription = models.ForeignKey(
'djstripe.Subscription', null=True, blank=True, on_delete=models.SET_NULL,
help_text=_('The user Stripe Subscription object, if it exists.')
)
stripe_paymentmethod = models.ForeignKey(
'djstripe.PaymentMethod', null=True, blank=True, on_delete=models.SET_NULL,
help_text=_('The user Stripe Payment Method object, if it exists.')
)
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
url = models.URLField(null=True, blank=True,
help_text=_('The preferred url for this user, will be linked on content pages for their bio.')
)
download_resets = models.SmallIntegerField(_('Download resets'), blank=False, null=False, default=0)
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = []
objects = CustomUserManager()
def __str__(self):
return self.email
class CustomUserProfile(models.Model):
user = models.OneToOneField(
CustomUser, on_delete=models.CASCADE, related_name='base_userprofile'
)
phone = PhoneNumberField(_('phone number'), blank=True, unique=True, default=None, null=True)
REQUIRED_FIELDS = []
class Meta:
db_table = 'users_customuser_profile'
def __str__(self):
return self.user.email

Some files were not shown because too many files have changed in this diff Show More