From d92d84f933677e9b31be57a825d683e207f3d8f3 Mon Sep 17 00:00:00 2001 From: jacklinke Date: Mon, 28 Oct 2024 12:02:41 -0400 Subject: [PATCH] Improve docs --- .readthedocs.yml | 4 +- README.md | 48 ++++--- docs/conf.py | 144 ++++++++++++++++++- docs/reference.md | 6 + docs/requirements.txt | 12 +- docs/usage.md | 325 +++++++++++++++++++++++++++++++++++++++++- 6 files changed, 502 insertions(+), 37 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index 04b9142..19710fa 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -2,9 +2,9 @@ version: 2 # Set the OS, Python version and other tools you might need build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: - python: "3.11" + python: "3.12" sphinx: configuration: docs/conf.py diff --git a/README.md b/README.md index 32eea59..1d06443 100644 --- a/README.md +++ b/README.md @@ -21,32 +21,38 @@ **Empowering Your SaaS Tenants with Custom Options and Sane Defaults** -`django-tenant-options` provides a powerful and flexible way for your SaaS application’s tenants to customize the selectable options in user-facing forms. This package allows you to offer a balance between providing default options—either mandatory or optional—and giving your tenants the freedom to define their own custom choices, all within a structured framework that ensures consistency and ease of use. - ## So your SaaS tenants want to provide end users with choices in a form... *How can you implement this?* -- **CharField with TextChoices or IntegerChoices**: Define a fixed set of options in your model. This approach is inflexible and doesn't allow for any customization by tenants. What one tenant needs may not be what another tenant needs. -- **ManyToManyField with a custom model**: Create a custom model to store options and use a ManyToManyField in your form. But what if one tenant wants to add a new option? Or if you would like to provide some default options? Or if not every tenant needs to show all of your defaults? -- **JSON Fields**: Store custom options as JSON in a single field. This can be difficult to query and manage, and doesn't provide a structured way to define defaults. And it has all of the problems of the ManyToManyField approach. -- **`django-tenant-options`**: A structured and flexible solution that allows tenants to define their own sets of values for form input while still allowing you, the developer, to offer global defaults (both mandatory and optional). - -## Why Use django-tenant-options? - -In a SaaS environment, one size doesn't fit all. Tenants often have unique needs for the choices they offer in user-facing forms, but building an entirely custom solution for each tenant - or requiring each tenant to define their own options from scratch - can be complex and time-consuming. `django-tenant-options` addresses this challenge by offering: - -- **Flexibility**: Tenants can tailor the options available in forms to better meet their specific needs. -- **Control**: Provide mandatory defaults to maintain a consistent experience across all tenants, while still allowing for customization. -- **Scalability**: Easily manage multiple tenants with differing requirements without compromising on performance or maintainability. -- **Simplicity**: Avoid the complexity of dynamic models or JSON fields, offering a more structured and maintainable solution. +- **CharField with TextChoices or IntegerChoices**: Define a fixed set of options in your model. + - ❌ One size fits all + - ❌ No customization for tenants + - ❌ Code changes required for new options +- **ManyToManyField with a custom model**: Create a custom model to store options and use a ManyToManyField in your form. + - ❌ No distinction between tenant options + - ❌ Complex to manage defaults + - ❌ Hard to maintain consistency +- **JSON Fields**: Store custom options as JSON in a single field. + - ❌ No schema validation + - ❌ No referential integrity +- **Custom Tables Per Tenant** + - ❌ Schema complexity + - ❌ Migration nightmares + - ❌ Performance issues +- **django-tenant-options**: + - ✅ Structured and flexible + - ✅ Allows tenants to define their own sets of values for form inputs + - ✅ Allows you, the developer, to offer global defaults (both mandatory and optional) + +In a SaaS environment, one size doesn't fit all. Tenants often have unique needs for the choices they offer in user-facing forms, but building an entirely custom solution for each tenant - or requiring each tenant to define their own options from scratch - can be complex and time-consuming. ## Key Features - **Customizable Options**: Allow tenants to define their own sets of values for form input while still offering global defaults. - **Mandatory and Optional Defaults**: Define which options are mandatory for all tenants and which can be optionally used by tenants in their forms. -- **Seamless Integration**: Designed to work smoothly with your existing Django models, making it easy to integrate into your project. -- **Tenant-Specific Logic**: Built-in support for tenant-specific logic, ensuring that each tenant’s unique needs are met. +- **Seamless Integration**: Works with your existing Django models, making it easy to integrate into your project. +- **Tenant-Specific Logic**: Built-in support for tenant-specific logic, so each tenant’s unique needs can be met. ## Potential Use-Cases @@ -100,7 +106,6 @@ We will define a very basic `Tenant` model and a `Task` model to illustrate the from django.contrib.auth import get_user_model from django.db import models - User = get_user_model() @@ -172,7 +177,6 @@ from django.db import models from example.models import TaskPriorityOption, TaskStatusOption, User - User = get_user_model() @@ -210,7 +214,9 @@ class Task(models.Model): ### Forms -`django-tenant-options` provides a set of form mixins and fields to manage the options and selections for each tenant. You can use these forms in your views to allow tenants to customize their options. +`django-tenant-options` provides a set of form mixins and fields to manage the options and selections for each tenant. + +You can use these forms in your views to allow tenants to customize their options. - `OptionCreateFormMixin` and `OptionUpdateFormMixin` are provided to create and update Options. - `SelectionForm` is used to manage the Selections associated with a tenant. @@ -275,6 +281,6 @@ python manage.py syncoptions ## Conclusion -`django-tenant-options` makes it easy to provide your SaaS application’s tenants with customizable form options, while still maintaining the consistency and control needed to ensure a smooth user experience. Whether you're managing project tasks, HR roles, marketplace filters, or any other customizable value sets, this package offers a robust solution. +`django-tenant-options` makes it easy to provide your SaaS application’s tenants with customizable form options, while maintaining consistency and control. Explore the [full documentation](https://django-tenant-options.readthedocs.io/en/latest/) for more details and start empowering your tenants today! diff --git a/docs/conf.py b/docs/conf.py index b92ce0c..64e87bd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,22 +1,152 @@ -"""Sphinx configuration.""" +"""Sphinx configuration for django-tenant-options documentation.""" -import django -from django.conf import settings +import inspect +import os +import sys +from datetime import datetime +import django +from django.utils.html import strip_tags -settings.configure(DEBUG=True) -# Initialize Django +sys.path.insert(0, os.path.abspath("..")) +os.environ["DJANGO_SETTINGS_MODULE"] = "example_project.settings" django.setup() + +# Project information project = "django-tenant-options" author = "Jack Linke" -copyright = "2024, Jack Linke" +copyright = f"{datetime.now().year}, {author}" + +# General configuration extensions = [ "sphinx.ext.autodoc", "sphinx.ext.napoleon", + "sphinx.ext.intersphinx", + "sphinx.ext.viewcode", "sphinx_click", "myst_parser", ] -autodoc_typehints = "description" + +# Any paths that contain templates here, relative to this directory. +# templates_path = ["_templates"] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ["_build"] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. html_theme = "furo" + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ["_static"] + +# -- Extension configuration ------------------------------------------------- + +# Napoleon settings +napoleon_google_docstring = True +napoleon_numpy_docstring = False +napoleon_include_init_with_doc = False +napoleon_include_private_with_doc = False +napoleon_include_special_with_doc = True +napoleon_use_admonition_for_examples = False +napoleon_use_admonition_for_notes = False +napoleon_use_admonition_for_references = False +napoleon_use_ivar = False +napoleon_use_param = True +napoleon_use_rtype = True +napoleon_preprocess_types = False +napoleon_type_aliases = None +napoleon_attr_annotations = True + +# Autodoc settings +autodoc_typehints = "description" +autodoc_default_options = { + "members": True, + "special-members": "__init__", + "exclude-members": "__weakref__", +} +autodoc_mock_imports = [ + "django", +] # Add any modules that might cause import errors during doc building + +# Intersphinx settings +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "django": ("https://docs.djangoproject.com/en/stable/", "https://docs.djangoproject.com/en/stable/_objects/"), +} + +# MyST Parser settings +myst_enable_extensions = [ + "amsmath", + "colon_fence", + "deflist", + "dollarmath", + "html_admonition", + "html_image", + "linkify", + "replacements", + "smartquotes", + "substitution", + "tasklist", +] + + +def project_django_models(app, what, name, obj, options, lines): # pylint: disable=W0613 disable=R0913 + """Process Django models for autodoc. + + From: https://djangosnippets.org/snippets/2533/ + """ + from django.db import models # pylint: disable=C0415 + + # Only look at objects that inherit from Django's base model class + if inspect.isclass(obj) and issubclass(obj, models.Model): + # Grab the field list from the meta class + fields = obj._meta.get_fields() # pylint: disable=W0212 + + for field in fields: + # If it's a reverse relation, skip it + if isinstance( + field, + ( + models.fields.related.ManyToOneRel, + models.fields.related.ManyToManyRel, + models.fields.related.OneToOneRel, + ), + ): + continue + + # Decode and strip any html out of the field's help text + help_text = strip_tags(field.help_text) if hasattr(field, "help_text") else None + + # Decode and capitalize the verbose name, for use if there isn't + # any help text + verbose_name = field.verbose_name if hasattr(field, "verbose_name") else "" + + if help_text: + # Add the model field to the end of the docstring as a param + # using the help text as the description + lines.append(f":param {field.attname}: {help_text}") + else: + # Add the model field to the end of the docstring as a param + # using the verbose name as the description + lines.append(f":param {field.attname}: {verbose_name}") + + # Add the field's type to the docstring + lines.append(f":type {field.attname}: {field.__class__.__name__}") + + # Return the extended docstring + return lines + + +def setup(app): + """Register the Django model processor with Sphinx.""" + app.connect("autodoc-process-docstring", project_django_models) diff --git a/docs/reference.md b/docs/reference.md index 3eba637..e3b5557 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -120,3 +120,9 @@ (`int`) Provide detailed output of the migration creation process. ``` + +### `removetriggers` + +```{eval-rst} +.. autoclass:: django_tenant_options.management.commands.removetriggers.Command +``` diff --git a/docs/requirements.txt b/docs/requirements.txt index 2cb004d..19c5964 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,10 @@ -furo==2024.8.6 -sphinx==8.1.3 +# Documentation dependencies +Sphinx==8.1.3 +sphinx-rtd-theme==2.0.0 sphinx-click==6.0.0 -myst_parser==2.0.0 +myst-parser==4.0 +furo==2024.8.6 +linkify-it-py==2.0.3 + +# Django and related packages (for autodoc) +Django==5.0.8 diff --git a/docs/usage.md b/docs/usage.md index 1a2133b..65cae80 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,12 +1,14 @@ -# Usage +# Usage Guide for django-tenant-options ## Installation +Install django-tenant-options using pip: + ```bash pip install django-tenant-options ``` -## Configuration +## Basic Configuration 1. Add `django_tenant_options` to your `INSTALLED_APPS` in `settings.py`: @@ -18,12 +20,327 @@ INSTALLED_APPS = [ ] ``` -2. Set the `DJANGO_TENANT_OPTIONS` dictionary in your `settings.py`: +2. Configure the required settings in your `settings.py`: ```python DJANGO_TENANT_OPTIONS = { - "TENANT_MODEL": "yourapp.Tenant", + "TENANT_MODEL": "yourapp.Tenant", # Required + + # Optional settings with their defaults + "TENANT_ON_DELETE": models.CASCADE, + "OPTION_ON_DELETE": models.CASCADE, + "TENANT_MODEL_RELATED_NAME": "%(app_label)s_%(class)s_related", + "TENANT_MODEL_RELATED_QUERY_NAME": "%(app_label)s_%(class)ss", + "ASSOCIATED_TENANTS_RELATED_NAME": "%(app_label)s_%(class)s_selections", + "ASSOCIATED_TENANTS_RELATED_QUERY_NAME": "%(app_label)s_%(class)ss_selected", + "OPTION_MODEL_RELATED_NAME": "%(app_label)s_%(class)s_related", + "OPTION_MODEL_RELATED_QUERY_NAME": "%(app_label)s_%(class)ss", + "DB_VENDOR_OVERRIDE": None, + "DISABLE_FIELD_FOR_DELETED_SELECTION": False, } ``` +## Core Concepts + +### Option Types + +django-tenant-options provides three types of options: + +1. **Mandatory Options** (`OptionType.MANDATORY`): + - Always available to all tenants + - Cannot be disabled or removed by tenants + - Example: Basic status options like "Active" or "Inactive" + +2. **Optional Options** (`OptionType.OPTIONAL`): + - Available to all tenants but can be enabled/disabled + - Tenants choose whether to use them + - Example: Additional status options like "On Hold" or "Under Review" + +3. **Custom Options** (`OptionType.CUSTOM`): + - Created by individual tenants + - Only available to the tenant that created them + - Example: Tenant-specific categories or statuses + +## Model Configuration + +### 1. Option Models + +Create your Option model by inheriting from `AbstractOption`: + +```python +from django_tenant_options.models import AbstractOption +from django_tenant_options.choices import OptionType + +class TaskPriorityOption(AbstractOption): + tenant_model = "yourapp.Tenant" # Your tenant model + selection_model = "yourapp.TaskPrioritySelection" + + # Define default options + default_options = { + "Critical": {"option_type": OptionType.OPTIONAL}, + "High": {"option_type": OptionType.MANDATORY}, + "Medium": {"option_type": OptionType.OPTIONAL}, + "Low": {}, # Defaults to OptionType.MANDATORY + } + + class Meta(AbstractOption.Meta): + verbose_name = "Task Priority Option" + verbose_name_plural = "Task Priority Options" +``` + +### 2. Selection Models + +Create your Selection model by inheriting from `AbstractSelection`: + +```python +from django_tenant_options.models import AbstractSelection + +class TaskPrioritySelection(AbstractSelection): + tenant_model = "yourapp.Tenant" + option_model = "yourapp.TaskPriorityOption" + + class Meta(AbstractSelection.Meta): + verbose_name = "Task Priority Selection" + verbose_name_plural = "Task Priority Selections" +``` + +### 3. Using Options in Your Models + +Add ForeignKey fields to your models to use the options: + +```python +from django.db import models + +class Task(models.Model): + title = models.CharField(max_length=100) + priority = models.ForeignKey( + "yourapp.TaskPriorityOption", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="tasks", + ) +``` + +### Resulting Model Relationships + +[![](https://mermaid.ink/img/pako:eNrFVcGOmzAQ_RXLe2lViEI2BLCilaqueumhlbK9VEiRYxyw1tjINu3SNP9eB0gIWWfVrioVLsO8mTdvzNjeQSIzChEkHGt9z3CucJkKYJ_WA95vtFGYmM-VYVIghHDv-NDCuy728CyXR-jubvC-WxnFRA4ELulzr2xZ16apzsF7bOgDKynIKKeGZmdQTs2aSEGU9a91vWlFUv3m7VlMlzVyEU6xGHk0_n4K2bsaXlkW8sqe_2sDD1RgYc5VvvwPrIhMlpgJB9dXTdWI6aNUlOXiE22Aaeu4BGD96CpvmOGO-hnVRLF2ENyFaivCjVSKScVMc0XElx7uZnckKWPE2MpbXHOz7qZQOxS3La5Lu0W4Y-GOE3IecFXEaZ6cS_NioX6XjKt0xnh7guUv33e1jgATBbUOPU4bRD3LHKDLZAd_CoMUAt-X1iqxaOzHNa4Ca3AIAaZQss6LnrMb2lfw_E3-aTVOIrrsdsyPub3RMyKwoVyKXAMjh_YH-lN8y_HH0VdVDSOdCm0aTi9_8ZZxjm62yeH1LCIfKbqJoqi3_R8sMwWaVU8ekVyqEZZhXWClcINACMLLAsPq_rsa0IMlVfZ0yewd0w5-Ck1B7TkEkTX7HZjCVOxtKK6NXDWCQGRUTT1YV5k9SftbCaIt5tp6Kyy-SVkeg-wnRDv4BFEwm02C-Xw6j4NgMY_iReDBBqJkPklub4M4moVJbOFw78GfLcF0EofJNFlEizAM4iCOw_1vIZE-UQ?type=png)](https://mermaid.live/edit#pako:eNrFVcGOmzAQ_RXLe2lViEI2BLCilaqueumhlbK9VEiRYxyw1tjINu3SNP9eB0gIWWfVrioVLsO8mTdvzNjeQSIzChEkHGt9z3CucJkKYJ_WA95vtFGYmM-VYVIghHDv-NDCuy728CyXR-jubvC-WxnFRA4ELulzr2xZ16apzsF7bOgDKynIKKeGZmdQTs2aSEGU9a91vWlFUv3m7VlMlzVyEU6xGHk0_n4K2bsaXlkW8sqe_2sDD1RgYc5VvvwPrIhMlpgJB9dXTdWI6aNUlOXiE22Aaeu4BGD96CpvmOGO-hnVRLF2ENyFaivCjVSKScVMc0XElx7uZnckKWPE2MpbXHOz7qZQOxS3La5Lu0W4Y-GOE3IecFXEaZ6cS_NioX6XjKt0xnh7guUv33e1jgATBbUOPU4bRD3LHKDLZAd_CoMUAt-X1iqxaOzHNa4Ca3AIAaZQss6LnrMb2lfw_E3-aTVOIrrsdsyPub3RMyKwoVyKXAMjh_YH-lN8y_HH0VdVDSOdCm0aTi9_8ZZxjm62yeH1LCIfKbqJoqi3_R8sMwWaVU8ekVyqEZZhXWClcINACMLLAsPq_rsa0IMlVfZ0yewd0w5-Ck1B7TkEkTX7HZjCVOxtKK6NXDWCQGRUTT1YV5k9SftbCaIt5tp6Kyy-SVkeg-wnRDv4BFEwm02C-Xw6j4NgMY_iReDBBqJkPklub4M4moVJbOFw78GfLcF0EofJNFlEizAM4iCOw_1vIZE-UQ) + +## Form Implementation + +### 1. User-Facing Forms + +For forms that allow users to select from tenant-specific options: + +```python +from django import forms +from django_tenant_options.forms import UserFacingFormMixin + +class TaskForm(UserFacingFormMixin, forms.ModelForm): + class Meta: + model = Task + fields = ["title", "priority"] +``` + +### 2. Option Management Forms + +For forms that allow tenants to create and manage custom options: + +```python +from django import forms +from django_tenant_options.forms import ( + OptionCreateFormMixin, + OptionUpdateFormMixin, +) + +class TaskPriorityCreateForm(OptionCreateFormMixin, forms.ModelForm): + class Meta: + model = TaskPriorityOption + fields = ["name"] + +class TaskPriorityUpdateForm(OptionUpdateFormMixin, forms.ModelForm): + class Meta: + model = TaskPriorityOption + fields = ["name"] +``` + +### 3. Selection Management Forms + +For forms that allow tenants to manage which options are available: + +```python +from django_tenant_options.forms import SelectionForm + +class TaskPrioritySelectionForm(SelectionForm): + class Meta: + model = TaskPrioritySelection +``` + +## View and Template Integration + +### 1. Views + +```python +from django.views.generic import CreateView, UpdateView +from django.contrib.auth.mixins import LoginRequiredMixin + +class TaskCreateView(LoginRequiredMixin, CreateView): + model = Task + form_class = TaskForm + template_name = "tasks/task_form.html" + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["tenant"] = self.request.user.tenant + return kwargs + +class TaskPriorityOptionCreateView(LoginRequiredMixin, CreateView): + model = TaskPriorityOption + form_class = TaskPriorityCreateForm + template_name = "tasks/priority_form.html" + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["tenant"] = self.request.user.tenant + return kwargs +``` + +### 2. Templates + +```html + +
+ {% csrf_token %} + {{ form.as_p }} + +
+ + +

Task Priorities

+ +``` + +## Database Operations + +### 1. Creating Database Triggers + +After setting up your models, create database triggers to ensure referential integrity: + +```bash +python manage.py maketriggers +python manage.py migrate +``` + +### 2. Synchronizing Default Options + +After updating default options in your models: + +```bash +python manage.py syncoptions +``` + +## Management Commands + +### 1. List Options + +To view all options in the database: + +```bash +python manage.py listoptions +``` + +### 2. Making Triggers + +Create database triggers with various options: + +```bash +# Create triggers for all models +python manage.py maketriggers + +# Create triggers for a specific app +python manage.py maketriggers --app yourapp + +# Create triggers for a specific model +python manage.py maketriggers --model yourapp.TaskPriorityOption + +# Force recreation of existing triggers +python manage.py maketriggers --force + +# Preview trigger creation without making changes +python manage.py maketriggers --dry-run --verbose +``` + +## Advanced Configuration + +### 1. Custom Form Fields + +You can customize how options are displayed in forms: + +```python +from django_tenant_options.form_fields import OptionsModelMultipleChoiceField + +class CustomOptionsField(OptionsModelMultipleChoiceField): + def label_from_instance(self, obj): + return f"{obj.name} - {obj.get_option_type_display()}" + +DJANGO_TENANT_OPTIONS = { + "DEFAULT_MULTIPLE_CHOICE_FIELD": CustomOptionsField, +} +``` + +### 2. Custom Managers and QuerySets + +Extend the default managers and querysets for additional functionality: + +```python +from django_tenant_options.models import OptionQuerySet, OptionManager + +class CustomOptionQuerySet(OptionQuerySet): + def active_mandatory(self): + return self.active().filter(option_type="dm") + +class CustomOptionManager(OptionManager): + def get_queryset(self): + return CustomOptionQuerySet(self.model, using=self._db) + +class TaskPriorityOption(AbstractOption): + objects = CustomOptionManager() + # ... rest of the model definition +``` + +### Performance Optimization + +1. **Querying Efficiency** + ```python + # Use select_related for foreign keys + TaskPriorityOption.objects.select_related('tenant').all() + ``` + +2. **Caching** + ```python + # Cache frequently used options + from django.core.cache import cache + + def get_tenant_options(tenant): + cache_key = f"tenant_{tenant.id}_options" + options = cache.get(cache_key) + if options is None: + options = list(tenant.options.active()) + cache.set(cache_key, options, timeout=3600) + return options + ``` + For more information on the available settings, see the [`app_settings.py` reference](https://django-tenant-options.readthedocs.io/en/latest/reference.html#module-django_tenant_options.app_settings).