├── .github ├── pr-title-checker-config.json └── workflows │ ├── autolabeler.yml │ ├── git.yml │ ├── pr-title.yml │ ├── python-publish.yml │ └── release-drafter.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── django_tenants_celery_beat ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ └── __init__.py ├── models.py └── utils.py ├── example ├── core │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── tasks.py │ └── views.py ├── example │ ├── __init__.py │ ├── asgi.py │ ├── celery.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── manage.py ├── requirements.txt ├── tenancy │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_periodictasktenantlink.py │ │ └── __init__.py │ └── models.py └── tests │ ├── __init__.py │ ├── test_models.py │ └── test_utils.py ├── requirements.txt └── setup.py /.github/pr-title-checker-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "LABEL": { 3 | "name": "invalid title", 4 | "color": "B33A3A" 5 | }, 6 | "CHECKS": { 7 | "regexp": "^(docs|fix|feat|build|ci|chore|perf|refactor|revert|style|test)(\\([0-9a-zA-Z-_]+\\))?: .+$", 8 | "ignoreLabels": ["skip-pr-title-check"] 9 | }, 10 | "MESSAGES": { 11 | "success": "Title OK", 12 | "failure": "Failing Title Check Test", 13 | "notice": "" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/autolabeler.yml: -------------------------------------------------------------------------------- 1 | name: Autolabeler 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened, edited, synchronize] 6 | branches: 7 | - main 8 | 9 | jobs: 10 | update_labels: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 5 13 | steps: 14 | # Autolabel PRs for Release Drafter categorisation 15 | # `major` label must be manually added if major release required 16 | - uses: release-drafter/release-drafter@v5 17 | with: 18 | disable-releaser: true 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.WORKFLOW_ACCESS_TOKEN }} 21 | -------------------------------------------------------------------------------- /.github/workflows/git.yml: -------------------------------------------------------------------------------- 1 | name: Git Checks 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | block-fixup: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Block Fixup Commit Merge 12 | uses: 13rac1/block-fixup-merge-action@v2.0.0 13 | -------------------------------------------------------------------------------- /.github/workflows/pr-title.yml: -------------------------------------------------------------------------------- 1 | name: PR Title Check 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened, edited, labeled, unlabeled] 6 | 7 | jobs: 8 | check-title: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Check PR Title Format 12 | uses: thehanimo/pr-title-checker@v1.3.4 13 | with: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | configuration_path: ".github/pr-title-checker-config.json" 16 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is published 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 5 12 | steps: 13 | # Drafts your next Release notes as Pull Requests are merged into "master" 14 | # Warning: will clobber manual changes while in draft 15 | - uses: release-drafter/release-drafter@v5 16 | with: 17 | disable-autolabeler: true 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.WORKFLOW_ACCESS_TOKEN }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # PyCharm 132 | .idea/* 133 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Quick Release_ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-tenants-celery-beat 2 | 3 | Support for celery beat in multitenant Django projects. Schedule periodic tasks for a 4 | specific tenant, with flexibility to run tasks with respect to each tenant's timezone. 5 | 6 | For use with [django-tenants](https://github.com/django-tenants/django-tenants) and 7 | [tenant-schemas-celery](https://github.com/maciej-gol/tenant-schemas-celery). 8 | 9 | Features: 10 | - Configure static periodic tasks in `app.conf.beat_schedule` automatically for all 11 | tenants, optionally in their own timezones 12 | - Django admin modified to show and give you control over the tenant a task will run in 13 | - Filter the admin based on tenants 14 | - Tenant-level admin (e.g. tenant.domain.com) will only show tasks for that tenant 15 | 16 | ## Installation 17 | 18 | Install via pip: 19 | ```commandline 20 | pip install django-tenants-celery-beat 21 | ``` 22 | 23 | ## Usage 24 | 25 | Follow the instructions for [django-tenants](https://github.com/django-tenants/django-tenants) 26 | and [tenant-schemas-celery](https://github.com/maciej-gol/tenant-schemas-celery). 27 | 28 | In your `SHARED_APPS` (_not_ your `TENANT_APPS`): 29 | ```python 30 | SHARED_APPS = [ 31 | # ... 32 | "django_celery_results", 33 | "django_celery_beat", 34 | "django_tenants_celery_beat", 35 | # ... 36 | ] 37 | ``` 38 | Depending on your setup, you may also put `django_celery_results` in your `TENANT_APPS`. 39 | (Assuming you have followed the instructions for 40 | [django-tenants](https://github.com/django-tenants/django-tenants) 41 | all your `SHARED_APPS` will also appear in your `INSTALLED_APPS`.) 42 | 43 | `django-tenants-celery-beat` requires your `Tenant` model to have a `timezone` field in 44 | order to control periodic task scheduling. To this end, we provide a `TenantTimezoneMixin` 45 | that you should inherit from in your `Tenant` model, e.g.: 46 | ```python 47 | from django_tenants.models import TenantMixin 48 | from django_tenants_celery_beat.models import TenantTimezoneMixin 49 | 50 | class Tenant(TenantTimezoneMixin, TenantMixin): 51 | pass 52 | ``` 53 | You can configure whether the timezones are displayed with the GMT offset, i.e. 54 | `Australia/Sydney` vs. `GMT+11:00 Australia/Sydney`, using the setting 55 | `TENANT_TIMEZONE_DISPLAY_GMT_OFFSET`. By default, the GMT offset is not shown. 56 | (If you later change this setting, you will need to run `makemigrations` to see any effect.) 57 | 58 | Ensure that `DJANGO_CELERY_BEAT_TZ_AWARE` is True (the default) for any timezone aware 59 | scheduling to work. 60 | 61 | In order to make the link between your `Tenant` model and `PeriodicTask`, the app comes 62 | with an abstract model. You simply need create a class that inherits from this mixin and 63 | does nothing else. Having this model in your own first-party app means that the migrations 64 | can be managed properly. 65 | ```python 66 | from django_tenants_celery_beat.models import PeriodicTaskTenantLinkMixin 67 | 68 | class PeriodicTaskTenantLink(PeriodicTaskTenantLinkMixin): 69 | pass 70 | ``` 71 | You need to register which model is acting as the link. If your tenancy models live in 72 | an app called `tenancy` and the model is named as above, you need the following in your 73 | project settings: 74 | ```python 75 | PERIODIC_TASK_TENANT_LINK_MODEL = "tenancy.PeriodicTaskTenantLink" 76 | ``` 77 | 78 | Once this has been done, you will need to run `makemigrations`. This will create the 79 | necessary migrations for your `Tenant` and `PeriodicTaskTenantLink` models. 80 | To apply the migrations, run: 81 | ```commandline 82 | python manage.py migrate_schemas --shared 83 | ``` 84 | 85 | ### Setting up a `beat_schedule` 86 | 87 | For statically configured periodic tasks assigned via `app.conf.beat_schedule`, there 88 | is a helper utility function to produce a valid tenant-aware `beat_schedule`. You can take 89 | an existing `beat_schedule` and make minor modifications to achieve the desired behaviour. 90 | 91 | The `generate_beat_schedule` function takes a dict that looks exactly like the usual 92 | `beat_schedule` dict, but each task contains an additional entry with the key `tenancy_options`. 93 | Here you can specify three things: 94 | - Should the task run in the `public` schema? 95 | - Should the task run on all tenant schemas? 96 | - Should the task scheduling use the tenant's timezone? 97 | 98 | All of these are False by default, so you only need to include them if you set them to True, 99 | though you may prefer to keep them there to be explicit about your intentions. At least one 100 | of the `public` or `all_tenants` keys must be True, otherwise the entry is ignored. 101 | Additionally, if the `tenancy_option` key is missing from an entry, that entry will be ignored. 102 | 103 | Example usage: 104 | ```python 105 | app.conf.beat_schedule = generate_beat_schedule( 106 | { 107 | "tenant_task": { 108 | "task": "app.tasks.tenant_task", 109 | "schedule": crontab(minute=0, hour=12, day_of_week=1), 110 | "tenancy_options": { 111 | "public": False, 112 | "all_tenants": True, 113 | "use_tenant_timezone": True, 114 | } 115 | }, 116 | "hourly_tenant_task": { 117 | "task": "app.tasks.hourly_tenant_task", 118 | "schedule": crontab(minute=0), 119 | "tenancy_options": { 120 | "public": False, 121 | "all_tenants": True, 122 | "use_tenant_timezone": False, 123 | } 124 | }, 125 | "public_task": { 126 | "task": "app.tasks.tenant_task", 127 | "schedule": crontab(minute=0, hour=0, day_of_month=1), 128 | "tenancy_options": { 129 | "public": True, 130 | "all_tenants": False, 131 | } 132 | } 133 | } 134 | ) 135 | ``` 136 | This `beat_schedule` will actually produce an entry for each tenant with the schema name 137 | as a prefix. For example, `tenant1: celery.backend_cleanup`. For public tasks, there is 138 | no prefix added to the name. 139 | 140 | This function also sets some AMQP message headers, which is how the schema and timezone 141 | settings are configured. 142 | 143 | #### Configuring `celery.backend_cleanup` 144 | 145 | Note that in many cases, tasks should not be both run on the `public` schema and on all 146 | tenant schemas, as the database tables are often very different. One example that most 147 | likely should is the `celery.backend_cleanup` task that is automatically added. If you 148 | do nothing with it, it will run only in the public schema, which may or may not suit your 149 | needs. Assuming you have `django_celery_results` in `TENANT_APPS` you will need this task to 150 | be run on all tenants, and if you also have it in `SHARED_APPS`, you will need it to run 151 | on the `public` schema too. This task is also a case where you will likely want it to run 152 | in the tenant's timezone so it always runs during a quiet time. 153 | 154 | Using the utility function, this is how we could set up the `celery.backend_cleanup` task: 155 | ```python 156 | from django_tenants_celery_beat.utils import generate_beat_schedule 157 | 158 | # ... 159 | 160 | app.conf.beat_schedule = generate_beat_schedule( 161 | { 162 | "celery.backend_cleanup": { 163 | "task": "celery.backend_cleanup", 164 | "schedule": crontab("0", "4", "*"), 165 | "options": {"expire_seconds": 12 * 3600}, 166 | "tenancy_options": { 167 | "public": True, 168 | "all_tenants": True, 169 | "use_tenant_timezone": True, 170 | } 171 | } 172 | } 173 | ) 174 | ``` 175 | This will prevent the automatically created one being added, though the settings are 176 | identical to the automatic one as of `django-celery-beat==2.2.0`. You could also set 177 | `public` to False here for exactly the same resulting schedule, as the public one will 178 | be automatically created by `django-celery-beat`. 179 | 180 | ### Modifying Periodic Tasks in the Django Admin 181 | 182 | You can further manage periodic tasks in the Django admin. 183 | 184 | The public schema admin will display the periodic tasks for each tenant as well as the 185 | public tenant. 186 | 187 | When on a tenant-level admin (e.g. `tenant.domain.com`), you can only see 188 | the tasks for the given tenant, and any filters are hidden so as to not show a list of 189 | tenants. 190 | 191 | When editing a `PeriodicTask`, there is an inline form for the `OneToOneModel` added by 192 | this package that connects a `PeriodicTask` to a `Tenant`. You can toggle the 193 | `use_tenant_timezone` setting (but when restarting the beat service, the `beat_schedule` 194 | will always take precedence). The tenant is shown as a read-only field, unless you are 195 | on the public admin site, in which case you have the option edit the tenant. Editing the 196 | tenant here will take precedence over the `beat_schedule`. 197 | 198 | ## Developer Setup 199 | 200 | To set up the example app: 201 | 1. Navigate into the `example` directory 202 | 2. Create a virtual environment and install the requirements in `requirements.txt` 203 | 3. Create a postgres database according to the `example.settings.DATABASES["default"]` (edit the settings if necessary) 204 | 4. Run `python manage.py migrate_schemas` to create the public schema 205 | 5. Run `python manage.py create_tenant` to create the public tenant and any other tenants 206 | 6. Create superusers with `python manage.py create_tenant_superuser` 207 | 7. Run `celery -A example beat --loglevel=INFO` to run the beat scheduler 208 | 8. Run `celery -A example worker --loglevel=INFO` (add `--pool=solo` if on Windows) 209 | -------------------------------------------------------------------------------- /django_tenants_celery_beat/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuickRelease/django-tenants-celery-beat/b63783ca70907068b9318e79326583a4081750d4/django_tenants_celery_beat/__init__.py -------------------------------------------------------------------------------- /django_tenants_celery_beat/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.db.models import F 3 | 4 | from django_celery_beat.admin import PeriodicTaskAdmin 5 | from django_celery_beat.models import PeriodicTask 6 | from django_tenants.utils import get_tenant_model, get_public_schema_name 7 | from django_tenants_celery_beat.utils import get_periodic_task_tenant_link_model 8 | 9 | 10 | class PeriodicTaskTenantLinkInline(admin.StackedInline): 11 | model = get_periodic_task_tenant_link_model() 12 | can_delete = False 13 | 14 | def get_formset(self, request, obj=None, **kwargs): 15 | formset = super().get_formset(request, obj, **kwargs) 16 | if obj is None: 17 | # Only for adding a new PeriodicTask 18 | formset.form.base_fields["tenant"].initial = request.tenant 19 | if not is_public(request): 20 | # Hide all other Tenants 21 | # Need to make the field non-readonly as otherwise the default value 22 | # is blank, and the PeriodicTask will be created with no Tenant, which 23 | # means it is then lost to the public schema 24 | formset.form.base_fields["tenant"].queryset = ( 25 | get_tenant_model().objects.filter(pk=request.tenant.pk) 26 | ) 27 | return formset 28 | 29 | def get_readonly_fields(self, request, obj=None): 30 | if is_public(request): 31 | return tuple() 32 | if obj is None: 33 | # For new PeriodicTasks, we need to set the Tenant 34 | return tuple() 35 | return ("tenant",) 36 | 37 | 38 | class TenantPeriodicTaskAdmin(PeriodicTaskAdmin): 39 | list_display = ( 40 | "__str__", 41 | "tenant", 42 | "enabled", 43 | "interval", 44 | "start_time", 45 | "last_run_at", 46 | "one_off", 47 | ) 48 | list_filter = [ 49 | ("periodic_task_tenant_link__tenant", admin.RelatedOnlyFieldListFilter), 50 | "enabled", 51 | "one_off", 52 | "task", 53 | "start_time", 54 | "last_run_at", 55 | ] 56 | inlines = [PeriodicTaskTenantLinkInline] 57 | 58 | def tenant(self, instance): 59 | return instance.periodic_task_tenant_link.tenant.name 60 | 61 | def get_queryset(self, request): 62 | qs = super().get_queryset(request) 63 | if not is_public(request): 64 | qs = qs.filter(periodic_task_tenant_link__tenant=request.tenant) 65 | return qs.annotate( 66 | tenant=F("periodic_task_tenant_link__tenant__name") 67 | ).select_related("periodic_task_tenant_link__tenant") 68 | 69 | 70 | admin.site.unregister(PeriodicTask) 71 | admin.site.register(PeriodicTask, TenantPeriodicTaskAdmin) 72 | 73 | 74 | def is_public(request): 75 | return request.tenant.schema_name == get_public_schema_name() 76 | -------------------------------------------------------------------------------- /django_tenants_celery_beat/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DjangoTenantsCeleryBeatConfig(AppConfig): 5 | name = 'django_tenants_celery_beat' 6 | -------------------------------------------------------------------------------- /django_tenants_celery_beat/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuickRelease/django-tenants-celery-beat/b63783ca70907068b9318e79326583a4081750d4/django_tenants_celery_beat/migrations/__init__.py -------------------------------------------------------------------------------- /django_tenants_celery_beat/models.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.db import models 4 | from django_celery_beat.models import PeriodicTask, CrontabSchedule 5 | import pytz 6 | import timezone_field 7 | 8 | from django.conf import settings 9 | from django_tenants.utils import get_tenant_model, get_public_schema_name 10 | from django_tenants_celery_beat.utils import get_periodic_task_tenant_link_model 11 | 12 | 13 | 14 | timezone_field_kwargs = { 15 | "default": "UTC", 16 | } 17 | if getattr(settings, "TENANT_TIMEZONE_DISPLAY_GMT_OFFSET", False): 18 | timezone_field_kwargs["choices_display"] = "WITH_GMT_OFFSET" 19 | 20 | class TenantTimezoneMixin(models.Model): 21 | timezone = timezone_field.TimeZoneField(**timezone_field_kwargs) 22 | class Meta: 23 | abstract = True 24 | 25 | 26 | class PeriodicTaskTenantLinkMixin(models.Model): 27 | tenant = models.ForeignKey( 28 | settings.TENANT_MODEL, 29 | on_delete=models.CASCADE, 30 | related_name="periodic_task_tenant_links", 31 | ) 32 | periodic_task = models.OneToOneField( 33 | PeriodicTask, 34 | on_delete=models.CASCADE, 35 | related_name="periodic_task_tenant_link", 36 | ) 37 | use_tenant_timezone = models.BooleanField(default=False) 38 | 39 | class Meta: 40 | abstract = True 41 | 42 | def __str__(self): 43 | return f"{self.tenant} - {self.periodic_task}" 44 | 45 | def save(self, *args, **kwargs): 46 | """Make PeriodicTask tenant-aware. 47 | 48 | Inserts correct `_schema_name` for `self.tenant` and into 49 | `self.periodic_task.headers`. 50 | If `self.periodic_task` uses a crontab schedule and the tenant timezone should 51 | be used, the crontab is adjusted to use the timezone of the tenant. 52 | """ 53 | update_fields = ["headers"] 54 | 55 | headers = json.loads(self.periodic_task.headers) 56 | headers["_schema_name"] = self.tenant.schema_name 57 | self.use_tenant_timezone = headers.pop( 58 | "_use_tenant_timezone", self.use_tenant_timezone 59 | ) 60 | self.periodic_task.headers = json.dumps(headers) 61 | 62 | if self.periodic_task.crontab is not None: 63 | tz = self.tenant.timezone if self.use_tenant_timezone else pytz.utc 64 | schedule = self.periodic_task.crontab.schedule 65 | if schedule.tz != tz: 66 | schedule.tz = tz 67 | crontab = CrontabSchedule.from_schedule(schedule) 68 | if not crontab.id: 69 | crontab.save() 70 | self.periodic_task.crontab = crontab 71 | update_fields.append("crontab") 72 | 73 | self.periodic_task.save(update_fields=update_fields) 74 | super().save(*args, **kwargs) 75 | 76 | 77 | def align(instance, **kwargs): 78 | """Ensure PeriodicTask `instance` is aligned with its tenant. 79 | 80 | If no PeriodicTaskTenantLink is attached, the headers dict determines how to 81 | create the tenant link (if not present or missing the `_schema_name` key, use 82 | `public`). Otherwise, the PeriodicTaskTenantLink is used to set the headers if 83 | they are not already set. 84 | """ 85 | if hasattr(instance, "periodic_task_tenant_link"): 86 | # Ensure that the headers are present and aligned 87 | headers = json.loads(instance.headers) 88 | tenant_link = instance.periodic_task_tenant_link 89 | if ( 90 | "_use_tenant_timezone" in headers 91 | or headers.get("_schema_name") != tenant_link.tenant.schema_name 92 | ): 93 | instance.periodic_task_tenant_link.save() 94 | else: 95 | headers = json.loads(instance.headers) 96 | schema_name = headers.get("_schema_name", get_public_schema_name()) 97 | use_tenant_timezone = headers.get("_use_tenant_timezone", False) 98 | get_periodic_task_tenant_link_model().objects.create( 99 | periodic_task=instance, 100 | # Assumes the public schema has been created already 101 | # As long as no fiddling goes on, these tenants should always exist 102 | tenant=get_tenant_model().objects.get(schema_name=schema_name), 103 | use_tenant_timezone=use_tenant_timezone, 104 | ) 105 | 106 | 107 | models.signals.post_save.connect(align, sender=PeriodicTask) 108 | -------------------------------------------------------------------------------- /django_tenants_celery_beat/utils.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | from django_tenants.utils import get_tenant_model, get_public_schema_name, get_model 4 | from django.conf import settings 5 | 6 | 7 | def generate_beat_schedule(beat_schedule_config): 8 | """Generate a tenant-aware beat_schedule. 9 | 10 | Pass in a beat_schedule as normal, but each entry can have an extra key 11 | `tenancy_options`, which is a dict with up to three Boolean keys: 12 | - `public`: run on the public schema 13 | - `all_tenants`: run on all tenant schemas 14 | - `use_tenant_timezone`: use the tenants' timezones for any crontab schedules 15 | 16 | For example, if you want the entry "everywhere" to run on the public schema, and 17 | on all tenant schemas at midday using their local timezone: 18 | ``` 19 | generate_beat_schedule({ 20 | "everywhere": { 21 | "task": "some_task", 22 | "schedule": crontab(hour=12), 23 | "tenancy_options": { 24 | "public": True, 25 | "all_tenants": True, 26 | "use_tenant_timezone": True, 27 | } 28 | } 29 | }) 30 | ``` 31 | This would generate the following beat_schedule: 32 | ``` 33 | { 34 | "everywhere": { "task": "some_task", "schedule": crontab(hour=12) }, 35 | "tenant1: everywhere": { "task": "some_task", "schedule": crontab(hour=12) }, 36 | "tenant2: everywhere": { "task": "some_task", "schedule": crontab(hour=12) }, 37 | ... 38 | } 39 | ``` 40 | The timezone would then be set on the CrontabSchedule object that is later created 41 | when the beat_schedule is synced with the database. 42 | 43 | Args: 44 | beat_schedule_config: A valid beat_schedule dict with additional config 45 | describing how to handle tenancy options. 46 | 47 | Returns: 48 | A valid beat_schedule (assign it to `app.conf.beat_schedule`). 49 | """ 50 | import django 51 | django.setup() 52 | 53 | public_schema_name = get_public_schema_name() 54 | tenants = get_tenant_model().objects.exclude(schema_name=public_schema_name) 55 | beat_schedule = {} 56 | for name, config in beat_schedule_config.items(): 57 | tenancy_options = config.pop("tenancy_options") 58 | if tenancy_options is None: 59 | # Missing `tenancy_options` key means the entry is ignored 60 | continue 61 | if tenancy_options.get("public", False): 62 | beat_schedule[name] = _set_schema_headers( 63 | deepcopy(config), public_schema_name 64 | ) 65 | if tenancy_options.get("all_tenants", False): 66 | for tenant in tenants: 67 | _config = deepcopy(config) 68 | use_tenant_timezone = tenancy_options.get("use_tenant_timezone", False) 69 | beat_schedule[f"{tenant.schema_name}: {name}"] = _set_schema_headers( 70 | _config, tenant.schema_name, use_tenant_timezone 71 | ) 72 | return beat_schedule 73 | 74 | 75 | def _set_schema_headers(config, schema_name, use_tenant_timezone=False): 76 | options = config.get("options", {}) 77 | headers = options.get("headers", {}) 78 | headers["_schema_name"] = schema_name 79 | headers["_use_tenant_timezone"] = use_tenant_timezone 80 | options["headers"] = headers 81 | config["options"] = options 82 | return config 83 | 84 | 85 | def get_periodic_task_tenant_link_model(): 86 | return get_model(settings.PERIODIC_TASK_TENANT_LINK_MODEL) 87 | -------------------------------------------------------------------------------- /example/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuickRelease/django-tenants-celery-beat/b63783ca70907068b9318e79326583a4081750d4/example/core/__init__.py -------------------------------------------------------------------------------- /example/core/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Alignment, Character 4 | 5 | 6 | @admin.register(Alignment) 7 | class AlignmentAdmin(admin.ModelAdmin): 8 | pass 9 | 10 | 11 | @admin.register(Character) 12 | class CharacterAdmin(admin.ModelAdmin): 13 | list_display = ("name", "alignment") 14 | -------------------------------------------------------------------------------- /example/core/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CoreConfig(AppConfig): 5 | name = 'core' 6 | -------------------------------------------------------------------------------- /example/core/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-02-23 14:52 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Alignment', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('law_vs_chaos', models.CharField(choices=[('Lawful', 'Lawful'), ('Neutral', 'Neutral'), ('Chaotic', 'Chaotic')], default='Neutral', max_length=7)), 20 | ('good_vs_evil', models.CharField(choices=[('Good', 'Good'), ('Neutral', 'Neutral'), ('Evil', 'Evil')], default='Neutral', max_length=7)), 21 | ], 22 | ), 23 | migrations.CreateModel( 24 | name='Character', 25 | fields=[ 26 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 27 | ('name', models.CharField(max_length=50)), 28 | ('alignment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='characters', to='core.alignment')), 29 | ], 30 | ), 31 | migrations.AddConstraint( 32 | model_name='alignment', 33 | constraint=models.UniqueConstraint(fields=('law_vs_chaos', 'good_vs_evil'), name='unique_alignment'), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /example/core/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuickRelease/django-tenants-celery-beat/b63783ca70907068b9318e79326583a4081750d4/example/core/migrations/__init__.py -------------------------------------------------------------------------------- /example/core/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Alignment(models.Model): 5 | class LawVsChaos(models.TextChoices): 6 | LAWFUL = "Lawful" 7 | NEUTRAL = "Neutral" 8 | CHAOTIC = "Chaotic" 9 | 10 | class GoodVsEvil(models.TextChoices): 11 | GOOD = "Good" 12 | NEUTRAL = "Neutral" 13 | EVIL = "Evil" 14 | 15 | law_vs_chaos = models.CharField( 16 | max_length=7, choices=LawVsChaos.choices, default=LawVsChaos.NEUTRAL 17 | ) 18 | good_vs_evil = models.CharField( 19 | max_length=7, choices=GoodVsEvil.choices, default=GoodVsEvil.NEUTRAL 20 | ) 21 | 22 | class Meta: 23 | constraints = [ 24 | models.UniqueConstraint( 25 | fields=["law_vs_chaos", "good_vs_evil"], 26 | name="unique_alignment", 27 | ) 28 | ] 29 | 30 | def __str__(self): 31 | return f"{self.law_vs_chaos} {self.good_vs_evil}" 32 | 33 | 34 | class Character(models.Model): 35 | name = models.CharField(max_length=50) 36 | alignment = models.ForeignKey( 37 | Alignment, 38 | on_delete=models.SET_NULL, 39 | null=True, 40 | blank=True, 41 | related_name="characters", 42 | ) 43 | 44 | def __str__(self): 45 | return self.name 46 | -------------------------------------------------------------------------------- /example/core/tasks.py: -------------------------------------------------------------------------------- 1 | from .models import Character 2 | from example.celery import app 3 | 4 | 5 | @app.task() 6 | def reveal_alignment(cid): 7 | char = Character.objects.get(pk=cid) 8 | print(char.alignment or "Unaligned") 9 | -------------------------------------------------------------------------------- /example/core/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuickRelease/django-tenants-celery-beat/b63783ca70907068b9318e79326583a4081750d4/example/example/__init__.py -------------------------------------------------------------------------------- /example/example/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for example project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /example/example/celery.py: -------------------------------------------------------------------------------- 1 | from tenant_schemas_celery.app import CeleryApp as TenantAwareCeleryApp 2 | from celery.schedules import crontab 3 | 4 | from django_tenants_celery_beat.utils import generate_beat_schedule 5 | 6 | app = TenantAwareCeleryApp() 7 | 8 | # Using a string here means the worker doesn't have to serialize 9 | # the configuration object to child processes. 10 | # - namespace='CELERY' means all celery-related configuration keys 11 | # should have a `CELERY_` prefix. 12 | app.config_from_object("django.conf:settings", namespace="CELERY") 13 | 14 | # Load task modules from all registered Django app configs. 15 | app.autodiscover_tasks() 16 | 17 | # Note: celery worker must be run with "default" queue defined 18 | # app.conf.task_default_queue = "default" 19 | 20 | app.conf.beat_schedule = generate_beat_schedule( 21 | { 22 | "celery.backend_cleanup": { 23 | "task": "celery.backend_cleanup", 24 | "schedule": crontab("0", "4", "*"), 25 | "options": {"expire_seconds": 12 * 3600}, 26 | "tenancy_options": { 27 | "public": False, 28 | "all_tenants": True, 29 | "use_tenant_timezone": True, 30 | } 31 | }, 32 | } 33 | ) 34 | 35 | 36 | @app.task(bind=True) 37 | def debug_task(self): 38 | print("Request: {0!r}".format(self.request)) 39 | -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.1.7. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.1/ref/settings/ 11 | """ 12 | import os 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "%dt6pg*c3-z09wu(7r3e4h745bf^bj2hj@yd(rokm$g7!fr5m7" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | # Application definition 29 | 30 | DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",) 31 | 32 | INSTALLED_APPS = [ 33 | "django.contrib.messages", 34 | "django.contrib.staticfiles", 35 | ] 36 | 37 | SHARED_APPS = ( 38 | "django_tenants", # mandatory 39 | "tenancy", # you must list the app where your tenant model resides in 40 | "django.contrib.contenttypes", 41 | # everything below here is optional 42 | "django.contrib.admin", 43 | "django.contrib.auth", 44 | "django.contrib.sessions", 45 | "django_celery_results", 46 | "django_celery_beat", 47 | "django_tenants_celery_beat", 48 | ) 49 | 50 | TENANT_APPS = ( 51 | # The following Django contrib apps must be in TENANT_APPS 52 | "django.contrib.contenttypes", 53 | # your tenant-specific apps 54 | "django.contrib.admin", 55 | "django.contrib.auth", 56 | "django.contrib.sessions", 57 | "django_celery_results", 58 | "core", 59 | ) 60 | 61 | INSTALLED_APPS += list(SHARED_APPS) + [ 62 | app for app in TENANT_APPS if app not in SHARED_APPS 63 | ] 64 | 65 | TENANT_MODEL = "tenancy.Tenant" 66 | 67 | TENANT_DOMAIN_MODEL = "tenancy.Domain" 68 | 69 | PERIODIC_TASK_TENANT_LINK_MODEL = "tenancy.PeriodicTaskTenantLink" 70 | 71 | TENANT_TIMEZONE_DISPLAY_GMT_OFFSET = False 72 | 73 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 74 | 75 | ALLOWED_HOSTS = ["*"] 76 | 77 | CACHES = { 78 | "default": { 79 | "BACKEND": "django.core.cache.backends.locmem.LocMemCache", 80 | "LOCATION": "cachetable", 81 | "KEY_PREFIX": "local_david", 82 | "KEY_FUNCTION": "django_tenants.cache.make_key", 83 | } 84 | } 85 | 86 | CELERY_TASK_TENANT_CACHE_SECONDS = 60 * 60 * 24 87 | 88 | MIDDLEWARE = [ 89 | "django_tenants.middleware.main.TenantMainMiddleware", 90 | "django.middleware.security.SecurityMiddleware", 91 | "django.contrib.sessions.middleware.SessionMiddleware", 92 | "django.middleware.common.CommonMiddleware", 93 | "django.middleware.csrf.CsrfViewMiddleware", 94 | "django.contrib.auth.middleware.AuthenticationMiddleware", 95 | "django.contrib.messages.middleware.MessageMiddleware", 96 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 97 | ] 98 | 99 | ROOT_URLCONF = "example.urls" 100 | 101 | TEMPLATES = [ 102 | { 103 | "BACKEND": "django.template.backends.django.DjangoTemplates", 104 | "DIRS": [], 105 | "APP_DIRS": True, 106 | "OPTIONS": { 107 | "context_processors": [ 108 | "django.template.context_processors.request", 109 | "django.template.context_processors.debug", 110 | "django.contrib.auth.context_processors.auth", 111 | "django.contrib.messages.context_processors.messages", 112 | ], 113 | }, 114 | }, 115 | ] 116 | 117 | WSGI_APPLICATION = "example.wsgi.application" 118 | 119 | 120 | # Database 121 | # https://docs.djangoproject.com/en/3.1/ref/settings/#databases 122 | 123 | DATABASES = { 124 | "default": { 125 | "ENGINE": "django_tenants.postgresql_backend", 126 | "NAME": "django-tenants-celery-beat", 127 | "HOST": "localhost", 128 | "PORT": "5432", 129 | "USER": "postgres", 130 | "PASSWORD": "postgres", 131 | }, 132 | } 133 | 134 | 135 | # Password validation 136 | # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators 137 | 138 | AUTH_PASSWORD_VALIDATORS = [ 139 | { 140 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 141 | }, 142 | { 143 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 144 | }, 145 | { 146 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 147 | }, 148 | { 149 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 150 | }, 151 | ] 152 | 153 | 154 | # Internationalization 155 | # https://docs.djangoproject.com/en/3.1/topics/i18n/ 156 | 157 | LANGUAGE_CODE = "en-us" 158 | 159 | TIME_ZONE = "UTC" 160 | 161 | USE_I18N = True 162 | 163 | USE_L10N = True 164 | 165 | USE_TZ = True 166 | 167 | 168 | # Static files (CSS, JavaScript, Images) 169 | # https://docs.djangoproject.com/en/3.1/howto/static-files/ 170 | 171 | STATIC_URL = "/static/" 172 | 173 | # Celery 174 | 175 | broker_dir = BASE_DIR / ".broker" 176 | CELERY_BROKER_URL = "filesystem://" 177 | CELERY_BROKER_TRANSPORT_OPTIONS = { 178 | "data_folder_in": broker_dir / "out", 179 | "data_folder_out": broker_dir / "out", 180 | "data_folder_processed": broker_dir / "processed", 181 | } 182 | 183 | CELERY_RESULT_BACKEND = "django-db" 184 | CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler" 185 | 186 | os.makedirs(CELERY_BROKER_TRANSPORT_OPTIONS["data_folder_out"], exist_ok=True) 187 | os.makedirs(CELERY_BROKER_TRANSPORT_OPTIONS["data_folder_processed"], exist_ok=True) 188 | # TO CREATE A CELERY WORKER WHEN TESTING LOCALLY RUN THIS IN THE PROJECT FOLDER: 189 | # celery -A example worker --loglevel=info --pool=solo 190 | # TO CREATE A CELERY BEAT SERVICE WHEN TESTING LOCALLY RUN THIS IN THE PROJECT FOLDER: 191 | # celery -A example beat --loglevel=info 192 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | """example URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | 19 | urlpatterns = [ 20 | path('admin/', admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | Django==3.2.13 2 | django-timezone-field==4.1.1 3 | django-celery-beat==2.2.0 4 | django-tenants==3.4.2 5 | celery==5.0.5 6 | pywin32==304; sys_platform == 'win32' 7 | tenant-schemas-celery==1.0.1 8 | django-celery-results==2.0.1 9 | psycopg2==2.9.3 10 | 11 | # Install django-tenants-celery-beat 12 | -e .. 13 | -------------------------------------------------------------------------------- /example/tenancy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuickRelease/django-tenants-celery-beat/b63783ca70907068b9318e79326583a4081750d4/example/tenancy/__init__.py -------------------------------------------------------------------------------- /example/tenancy/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from django_tenants.admin import TenantAdminMixin 4 | 5 | from tenancy.models import Tenant 6 | 7 | 8 | @admin.register(Tenant) 9 | class ClientAdmin(TenantAdminMixin, admin.ModelAdmin): 10 | list_display = ("name",) 11 | -------------------------------------------------------------------------------- /example/tenancy/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TenancyConfig(AppConfig): 5 | name = "tenancy" 6 | -------------------------------------------------------------------------------- /example/tenancy/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-02-23 10:51 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import django_tenants.postgresql_backend.base 6 | import timezone_field.fields 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Tenant', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('schema_name', models.CharField(db_index=True, max_length=63, unique=True, validators=[django_tenants.postgresql_backend.base._check_schema_name])), 22 | ('timezone', timezone_field.fields.TimeZoneField(default='UTC')), 23 | ('name', models.CharField(max_length=100)), 24 | ('created_on', models.DateField(auto_now_add=True)), 25 | ], 26 | options={ 27 | 'abstract': False, 28 | }, 29 | ), 30 | migrations.CreateModel( 31 | name='Domain', 32 | fields=[ 33 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 34 | ('domain', models.CharField(db_index=True, max_length=253, unique=True)), 35 | ('is_primary', models.BooleanField(db_index=True, default=True)), 36 | ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='domains', to='tenancy.tenant')), 37 | ], 38 | options={ 39 | 'abstract': False, 40 | }, 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /example/tenancy/migrations/0002_periodictasktenantlink.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-06-20 10:30 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('django_celery_beat', '0015_edit_solarschedule_events_choices'), 11 | ('tenancy', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='PeriodicTaskTenantLink', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('use_tenant_timezone', models.BooleanField(default=False)), 20 | ('periodic_task', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='periodic_task_tenant_link', to='django_celery_beat.periodictask')), 21 | ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='periodic_task_tenant_links', to='tenancy.tenant')), 22 | ], 23 | options={ 24 | 'abstract': False, 25 | }, 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /example/tenancy/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuickRelease/django-tenants-celery-beat/b63783ca70907068b9318e79326583a4081750d4/example/tenancy/migrations/__init__.py -------------------------------------------------------------------------------- /example/tenancy/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from django_tenants.models import DomainMixin, TenantMixin 4 | from django_tenants_celery_beat.models import ( 5 | TenantTimezoneMixin, 6 | PeriodicTaskTenantLinkMixin, 7 | ) 8 | 9 | 10 | class Tenant(TenantTimezoneMixin, TenantMixin): 11 | name = models.CharField(max_length=100) 12 | created_on = models.DateField(auto_now_add=True) 13 | 14 | # default true, schema will be automatically created and synced when it is saved 15 | auto_create_schema = True 16 | 17 | def __str__(self): 18 | return self.name 19 | 20 | 21 | class Domain(DomainMixin): 22 | pass 23 | 24 | 25 | class PeriodicTaskTenantLink(PeriodicTaskTenantLinkMixin): 26 | pass 27 | -------------------------------------------------------------------------------- /example/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuickRelease/django-tenants-celery-beat/b63783ca70907068b9318e79326583a4081750d4/example/tests/__init__.py -------------------------------------------------------------------------------- /example/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import patch 3 | 4 | import pytz 5 | from django.test import TestCase 6 | 7 | from tenancy.models import Tenant 8 | from django_celery_beat.models import CrontabSchedule, PeriodicTask, IntervalSchedule 9 | 10 | 11 | class PeriodicTaskTenantLink(TestCase): 12 | @classmethod 13 | def setUpTestData(cls): 14 | cls.tenants = Tenant.objects.bulk_create( 15 | [ 16 | Tenant(name="Public", schema_name="public"), 17 | Tenant( 18 | name="Tenant 1", schema_name="tenant1", timezone="Europe/London" 19 | ), 20 | Tenant(name="Tenant 2", schema_name="tenant2", timezone="US/Eastern"), 21 | ] 22 | ) 23 | 24 | def assert_linked(self, periodic_task, tenant, use_tz): 25 | self.assertEqual( 26 | periodic_task.periodic_task_tenant_link.tenant, 27 | tenant, 28 | "Tenant link established", 29 | ) 30 | self.assertEqual( 31 | json.loads(periodic_task.headers).get("_schema_name"), 32 | tenant.schema_name, 33 | "Schema name header matches tenant", 34 | ) 35 | if use_tz: 36 | self.assertTrue( 37 | periodic_task.periodic_task_tenant_link.use_tenant_timezone, 38 | "Linked TZ flag is True", 39 | ) 40 | self.assertEqual( 41 | periodic_task.crontab.schedule.tz, 42 | pytz.timezone(tenant.timezone), 43 | "Crontab TZ matches the tenant's TZ", 44 | ) 45 | else: 46 | self.assertFalse( 47 | periodic_task.periodic_task_tenant_link.use_tenant_timezone, 48 | "Linked TZ flag is False", 49 | ) 50 | self.assertEqual( 51 | periodic_task.crontab.schedule.tz, 52 | pytz.utc, 53 | "Crontab TZ is the default (UTC)", 54 | ) 55 | 56 | def test_align_new(self): 57 | """Align classmethod should create new object with correct properties.""" 58 | crontab = CrontabSchedule.objects.create(hour="0") 59 | 60 | with self.subTest("Tenant using TZ"): 61 | periodic_task = PeriodicTask.objects.create( 62 | name="tenant_tz", 63 | task="test_task", 64 | crontab=crontab, 65 | headers=json.dumps( 66 | {"_schema_name": "tenant1", "_use_tenant_timezone": True} 67 | ), 68 | ) 69 | self.assert_linked(periodic_task, self.tenants[1], True) 70 | 71 | with self.subTest("Tenant"): 72 | periodic_task = PeriodicTask.objects.create( 73 | name="tenant", 74 | task="test_task", 75 | crontab=crontab, 76 | headers=json.dumps( 77 | {"_schema_name": "tenant1", "_use_tenant_timezone": False} 78 | ), 79 | ) 80 | self.assert_linked(periodic_task, self.tenants[1], False) 81 | 82 | with self.subTest("Public"): 83 | periodic_task = PeriodicTask.objects.create( 84 | name="public", 85 | task="test_task", 86 | crontab=crontab, 87 | headers=json.dumps( 88 | {"_schema_name": "public", "_use_tenant_timezone": False} 89 | ), 90 | ) 91 | self.assert_linked(periodic_task, self.tenants[0], False) 92 | 93 | with self.subTest("Extra headers"): 94 | periodic_task = PeriodicTask.objects.create( 95 | name="extra", 96 | task="test_task", 97 | crontab=crontab, 98 | headers=json.dumps( 99 | { 100 | "extra": "header", 101 | "_schema_name": "public", 102 | "_use_tenant_timezone": False, 103 | } 104 | ), 105 | ) 106 | self.assertEqual( 107 | json.loads(periodic_task.headers).get("extra"), 108 | "header", 109 | "Extra headers are left alone", 110 | ) 111 | self.assert_linked(periodic_task, self.tenants[0], False) 112 | 113 | with self.subTest("Missing headers"): 114 | periodic_task = PeriodicTask.objects.create( 115 | name="missing", task="test_task", crontab=crontab 116 | ) 117 | self.assert_linked(periodic_task, self.tenants[0], False) 118 | 119 | def test_align_existing(self): 120 | """Align classmethod should call save if PeriodicTask headers dictate. 121 | 122 | Saving should occur if and only if: 123 | - _schema_name does not match the linked tenant 124 | - _use_tenant_timezone is in the headers dict 125 | """ 126 | periodic_task = PeriodicTask.objects.create( 127 | name="test", 128 | task="test_task", 129 | crontab=CrontabSchedule.objects.create(), 130 | ) 131 | with patch( 132 | "tenancy.models.PeriodicTaskTenantLink.save" 133 | ) as link_save: 134 | with self.subTest("No change"): 135 | periodic_task.save() 136 | self.assertFalse(link_save.called) 137 | 138 | headers = {"_schema_name": "tenant1"} 139 | with self.subTest("Change Tenant"): 140 | periodic_task.headers = json.dumps(headers) 141 | periodic_task.save() 142 | self.assertTrue(link_save.called_once) 143 | 144 | headers["_use_tenant_timezone"] = True 145 | with self.subTest("Change TZ"): 146 | periodic_task.headers = json.dumps(headers) 147 | periodic_task.save() 148 | self.assertTrue(link_save.called_once) 149 | 150 | def test_save(self): 151 | """Save method should set timezone flag and update linked PeriodicTask. 152 | 153 | - Set _schema_name header on PeriodicTask based on self.tenant.schema_name 154 | - Set self.use_tenant_timezone based on _use_tenant_timezone PeriodicTask header 155 | - Remove _use_tenant_timezone header on PeriodicTask 156 | - If PeriodicTask uses a CrontabSchedule and the timezone does not match, the 157 | CrontabSchedule should be updated. 158 | """ 159 | periodic_task = PeriodicTask.objects.create( 160 | name="test_task", 161 | task="test_task", 162 | interval=IntervalSchedule.objects.create( 163 | every=2, period=IntervalSchedule.DAYS 164 | ), 165 | headers=json.dumps( 166 | {"_schema_name": "public", "_use_tenant_timezone": False} 167 | ), 168 | ) 169 | 170 | periodic_task.periodic_task_tenant_link.tenant = self.tenants[1] 171 | periodic_task.periodic_task_tenant_link.save(update_fields=["tenant"]) 172 | periodic_task.refresh_from_db() 173 | 174 | self.assertEqual( 175 | json.loads(periodic_task.headers).get("_schema_name"), 176 | self.tenants[1].schema_name, 177 | "Schema name header is updated to match tenant", 178 | ) 179 | 180 | crontab = CrontabSchedule.objects.create(hour=12) 181 | periodic_task.interval = None 182 | periodic_task.crontab = crontab 183 | periodic_task.save() 184 | periodic_task.refresh_from_db() 185 | 186 | self.assertEqual( 187 | periodic_task.crontab.schedule.tz, 188 | pytz.utc, 189 | "Crontab TZ shouldn't match tenant", 190 | ) 191 | 192 | periodic_task.periodic_task_tenant_link.use_tenant_timezone = True 193 | periodic_task.periodic_task_tenant_link.save( 194 | update_fields=["use_tenant_timezone"] 195 | ) 196 | periodic_task.refresh_from_db() 197 | tz_crontab = periodic_task.crontab 198 | 199 | self.assertEqual( 200 | tz_crontab.schedule.tz, 201 | pytz.timezone(self.tenants[1].timezone), 202 | "TZ updated to match tenant", 203 | ) 204 | self.assertNotEqual(crontab.id, tz_crontab.id, "New TZ aware crontab created") 205 | 206 | periodic_task.headers = json.dumps({"_use_tenant_timezone": False}) 207 | periodic_task.save() 208 | periodic_task.refresh_from_db() 209 | 210 | self.assertFalse( 211 | "_use_tenant_timezone" in json.loads(periodic_task.headers), 212 | "Use tenant timezone header removed", 213 | ) 214 | self.assertFalse( 215 | periodic_task.periodic_task_tenant_link.use_tenant_timezone, 216 | "Header overrides model field", 217 | ) 218 | self.assertEqual( 219 | periodic_task.crontab.id, crontab.id, "Existing crontab reused" 220 | ) 221 | 222 | periodic_task.periodic_task_tenant_link.use_tenant_timezone = True 223 | periodic_task.periodic_task_tenant_link.save( 224 | update_fields=["use_tenant_timezone"] 225 | ) 226 | periodic_task.refresh_from_db() 227 | 228 | self.assertEqual( 229 | periodic_task.crontab.id, tz_crontab.id, "Existing TZ aware crontab reused" 230 | ) 231 | -------------------------------------------------------------------------------- /example/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from celery.schedules import crontab 2 | from django.test import TestCase 3 | 4 | from django_tenants_celery_beat.utils import generate_beat_schedule 5 | from tenancy.models import Tenant 6 | 7 | 8 | class GenerateBeatScheduleTestCase(TestCase): 9 | @classmethod 10 | def setUpTestData(cls): 11 | Tenant.objects.bulk_create( 12 | [ 13 | Tenant(name="Tenant 1", schema_name="tenant1", timezone="Europe/London"), 14 | Tenant(name="Tenant 2", schema_name="tenant2", timezone="US/Eastern"), 15 | ] 16 | ) 17 | 18 | def test_public(self): 19 | expected = { 20 | "task_name": { 21 | "task": "core.tasks.test_task", 22 | "schedule": crontab(), 23 | "options": { 24 | "headers": {"_schema_name": "public", "_use_tenant_timezone": False} 25 | } 26 | } 27 | } 28 | beat_schedule = generate_beat_schedule( 29 | { 30 | "task_name": { 31 | "task": "core.tasks.test_task", 32 | "schedule": crontab(), 33 | "tenancy_options": { 34 | "public": True, 35 | "all_tenants": False, 36 | "use_tenant_timezone": False, 37 | } 38 | }, 39 | } 40 | ) 41 | self.assertEqual(beat_schedule, expected) 42 | 43 | def test_all_tenants(self): 44 | expected = { 45 | "tenant1: task_name": { 46 | "task": "core.tasks.test_task", 47 | "schedule": crontab(day_of_month=1), 48 | "options": {"headers": {"_schema_name": "tenant1", "_use_tenant_timezone": False}} 49 | }, 50 | "tenant2: task_name": { 51 | "task": "core.tasks.test_task", 52 | "schedule": crontab(day_of_month=1), 53 | "options": {"headers": {"_schema_name": "tenant2", "_use_tenant_timezone": False}} 54 | } 55 | } 56 | beat_schedule = generate_beat_schedule( 57 | { 58 | "task_name": { 59 | "task": "core.tasks.test_task", 60 | "schedule": crontab(day_of_month=1), 61 | "tenancy_options": { 62 | "public": False, 63 | "all_tenants": True, 64 | "use_tenant_timezone": False, 65 | } 66 | }, 67 | } 68 | ) 69 | self.assertEqual(beat_schedule, expected) 70 | 71 | def test_public_all_tenants(self): 72 | expected = { 73 | "task_name": { 74 | "task": "core.tasks.test_task", 75 | "schedule": crontab(day_of_month=1), 76 | "options": { 77 | "headers": { 78 | "_schema_name": "public", "_use_tenant_timezone": False 79 | } 80 | } 81 | }, 82 | "tenant1: task_name": { 83 | "task": "core.tasks.test_task", 84 | "schedule": crontab(day_of_month=1), 85 | "options": { 86 | "headers": { 87 | "_schema_name": "tenant1", "_use_tenant_timezone": False 88 | } 89 | } 90 | }, 91 | "tenant2: task_name": { 92 | "task": "core.tasks.test_task", 93 | "schedule": crontab(day_of_month=1), 94 | "options": { 95 | "headers": { 96 | "_schema_name": "tenant2", "_use_tenant_timezone": False 97 | } 98 | } 99 | } 100 | } 101 | beat_schedule = generate_beat_schedule( 102 | { 103 | "task_name": { 104 | "task": "core.tasks.test_task", 105 | "schedule": crontab(day_of_month=1), 106 | "tenancy_options": { 107 | "public": True, 108 | "all_tenants": True, 109 | "use_tenant_timezone": False, 110 | } 111 | }, 112 | } 113 | ) 114 | self.assertEqual(beat_schedule, expected) 115 | 116 | def test_use_tenant_timezone(self): 117 | expected = { 118 | "tenant1: task_name": { 119 | "task": "core.tasks.test_task", 120 | "schedule": crontab(0, 1), 121 | "options": { 122 | "headers": { 123 | "_schema_name": "tenant1", "_use_tenant_timezone": True 124 | } 125 | } 126 | }, 127 | "tenant2: task_name": { 128 | "task": "core.tasks.test_task", 129 | "schedule": crontab(0, 1), 130 | "options": { 131 | "headers": { 132 | "_schema_name": "tenant2", "_use_tenant_timezone": True 133 | } 134 | } 135 | } 136 | } 137 | beat_schedule = generate_beat_schedule( 138 | { 139 | "task_name": { 140 | "task": "core.tasks.test_task", 141 | "schedule": crontab(0, 1), 142 | "tenancy_options": { 143 | "public": False, 144 | "all_tenants": True, 145 | "use_tenant_timezone": True, 146 | } 147 | }, 148 | } 149 | ) 150 | self.assertEqual(beat_schedule, expected) 151 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setup( 7 | name="django-tenants-celery-beat", 8 | version="0.2.1", 9 | author="David Vaughan", 10 | author_email="david.vaughan@quickrelease.co.uk", 11 | maintainer="Quick Release (Automotive) Ltd.", 12 | description="Support for celery beat in multitenant Django projects", 13 | long_description=long_description, 14 | long_description_content_type="text/markdown", 15 | url="https://github.com/QuickRelease/django-tenants-celery-beat", 16 | classifiers=[ 17 | "Programming Language :: Python :: 3", 18 | "Framework :: Django :: 2.0", 19 | "Framework :: Django :: 2.1", 20 | "Framework :: Django :: 2.2", 21 | "Framework :: Django :: 3.0", 22 | "Framework :: Django :: 3.1", 23 | "Framework :: Django :: 3.2", 24 | "License :: OSI Approved :: MIT License", 25 | "Intended Audience :: Developers", 26 | "Operating System :: OS Independent", 27 | ], 28 | keywords="django tenants celery beat multitenancy postgres postgresql", 29 | packages=[ 30 | "django_tenants_celery_beat", 31 | "django_tenants_celery_beat.migrations", 32 | ], 33 | install_requires=[ 34 | "Django>=2.0", 35 | "django-tenants>=3.0.0", 36 | "tenant-schemas-celery>=1.0.1", 37 | "django-celery-beat>=2.2.0", 38 | "django-timezone-field>=4.1.1", 39 | ], 40 | license="MIT", 41 | ) 42 | --------------------------------------------------------------------------------