From 0c9796875418776f2fbb9445fe98527f7dd53a43 Mon Sep 17 00:00:00 2001 From: jacklinke Date: Fri, 26 Jan 2024 12:31:24 -0500 Subject: [PATCH] Work toward a major update to improve docs, add hooks, and improve validation --- .github/ISSUE_TEMPLATE/bug_report.md | 37 ++ .github/ISSUE_TEMPLATE/feature_request.md | 20 + .github/workflows/django.yml | 38 ++ README.md | 429 +++++++++++++--------- flexible_list_of_values/__init__.py | 6 +- flexible_list_of_values/app_settings.py | 8 + flexible_list_of_values/exceptions.py | 29 +- flexible_list_of_values/forms.py | 77 ++-- flexible_list_of_values/models.py | 322 ++++++++++------ pyproject.toml | 23 +- tests/testapp/admin.py | 2 +- tests/testapp/forms.py | 4 +- tests/testapp/migrations/0001_initial.py | 210 ----------- tests/testapp/models.py | 4 +- tests/testapp/views.py | 20 +- 15 files changed, 651 insertions(+), 578 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/workflows/django.yml create mode 100644 flexible_list_of_values/app_settings.py delete mode 100644 tests/testapp/migrations/0001_initial.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..a248108 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,37 @@ +--- +name: Bug report +about: Create a report to help us improve the package +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Browser (please complete the following information):** + - Type [e.g. chrome, safari, etc] + - Version [e.g. 22] + +**Database (please complete the following information):** + - Device: [e.g. iPhone6] + - Database Version [e.g. 22] + - Running local, remote (e.g. DBaaS), or locally with provided docker-compose + - OS running on: [e.g. Ubuntu 20.04] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml new file mode 100644 index 0000000..78727b2 --- /dev/null +++ b/.github/workflows/django.yml @@ -0,0 +1,38 @@ +name: Django CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + black: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v4 + with: + python-version: 3.9 + - uses: actions/checkout@v3 + - run: python -m pip install black + - run: black -l 119 --check --diff . + + isort: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v4 + with: + python-version: 3.9 + - uses: actions/checkout@v3 + - run: python -m pip install isort + - run: isort --profile=black --line-length=119 . + + flake8: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v4 + with: + python-version: 3.9 + - uses: actions/checkout@v3 + - run: python -m pip install flake8 + - run: flake8 --max-line-length=119 diff --git a/README.md b/README.md index 1de14f2..6e2290f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Flexible and Extensible Lists of Values (LOV) for Django +*flexible-list-of-values* provides you a way to give your SaaS app's tenants customization options in its User-facing forms, but also lets you provide them with defaults - either mandatory or optional - to prevent each tenant having to "recreate the wheel". + > When adding customizability to a SaaS app, there are a variety of approaches and tools: > > - [Dynamic models](https://baserow.io/blog/how-baserow-lets-users-generate-django-models) and dynamic model fields @@ -9,10 +11,70 @@ Flexible and Extensible Lists of Values (LOV) for Django > - Adding [JSONField](https://docs.djangoproject.com/en/4.1/ref/models/fields/#jsonfield) to a model > > But these approaches can be a bit *too* flexible. Sometimes we want to provide guardrails for our tenants. -> -> *flexible-list-of-values* provides you a way to give your SaaS app's tenants customization options in its User-facing forms, but also lets you provide them with defaults - either mandatory or optional - to prevent each tenant having to "recreate the wheel". -*Note: The terms "entity" and "tenant" are used interchangeably in this document to refer to a model within your project that has associated Users, and which has "ownership" over the LOV value options provided to its Users* +*Note: The term "tenant" in this package to refers to a model within your project that has associated Users, and which has "ownership" over the LOV value options provided to its end-users/customers* + +## Contents + +- [flexible-list-of-values](#flexible-list-of-values) + - [Contents](#contents) + - [Potential Use-Cases](#potential-use-cases) + - [Example implementation](#example-implementation) + - [The Testapp Project](#the-testapp-project) + - [Setup with Docker Compose](#setup-with-docker-compose) + - [Working with the Testapp Project](#working-with-the-testapp-project) + - [Working with LOV Values in the Testapp Project](#working-with-lov-values-in-the-testapp-project) + - [Letting tenants select LOV value choices for their users](#letting-tenants-select-lov-value-choices-for-their-users) + - [Working with a tenant's LOV Selections](#working-with-a-tenants-lov-selections) + - [API](#api) + - [Model: AbstractLOVValue](#model-abstractlovvalue) + - [Fields](#fields) + - [Model attributes](#model-attributes) + - [Model Hooks](#model-hooks) + - [Manager and QuerySet Methods](#manager-and-queryset-methods) + - [Manager Hooks](#manager-hooks) + - [Model: AbstractLOVSelection](#model-abstractlovselection) + - [Fields](#fields-1) + - [Model attributes](#model-attributes-1) + - [Model Hooks](#model-hooks-1) + - [Manager and QuerySet Methods](#manager-and-queryset-methods-1) + - [Management Commands](#management-commands) + - [Settings](#settings) + + +## Potential Use-Cases + +The `flexible-list-of-values` package is a versatile tool for Django applications, offering dynamic and customizable management of lists of values (LOVs). This package is especially beneficial in scenarios where different users or groups within an application (referred to as tenants) require the ability to define and select from a set of predefined options that are then provided to their customers or end users in forms. Here are some use-cases: + +In a **Django-based project management application** for engineering firms: + +- **Tenant Structure**: Each engineering firm acts as a tenant, and its employees are the end-users. +- **Mandatory Task Statuses**: The application developer can set mandatory task status options (like "Assigned", "Started") that are always included in task-related forms for all employees of every tenant. +- **Optional Task Statuses**: The developer can also provide a list of optional task statuses (like "Awaiting Review", "On Hold") that each firm (tenant) can choose to include in these forms. +- **Custom Task Statuses**: Additionally, each firm can define its own set of task statuses (like "In Progress", "Review", "Done") tailored to their specific workflow needs. + + +In a scenario where an **HR management system** is used within a company: + +- **Tenant Structure**: The HR department is the tenant, and the various departments within the company are the end-users. +- **Mandatory Job Titles**: The application developer can specify mandatory job titles (like "Manager", "Team Lead") that each HR department must include in forms for departmental use. +- **Optional Job Titles**: The developer can also provide a list of optional job titles (like "Senior Analyst", "Project Coordinator") that the HR department can choose to include in the forms. +- **Custom Job Titles**: Each HR department has the flexibility to create their own custom job title options (like "Innovation Specialist", "Digital Strategist") to cater to the unique roles within their company. + +For **Marketplace Platforms**: + +- **Tenant Structure**: The vendor is the tenant, and visitors to the vendor's store are the end-users. +- **Dynamic Filtering Options**: As above, the application developer can provide mandatory/optional options that the vendor must/can use in forms that filter product searches based on these customizable attributes. And the vendor can supply their own custom search options as well. + +In **Learning Management Systems (LMS)**: + +- **Tenant Structure**: The school is the tenant, and instructors are are the users. +- **Course and Program Management**: The application developer can provide mandatory/optional options that schools must/can offer to instructors for use in forms that customize course types, credit values, or grading schemes when creating their courses. Each school can also provide it's own custom course types, credit values, or grading schemes options that instructor can choose from. + +In applications for **managing building permits** in a municipality: + +- **Tenant Structure**: Each municipality is a tenant, and businesses & homeowners requesting building permits are the end-users. +- **Permit Request Forms**: Municipalities can customize form options for it's users, creating it's own custom "property type" options that the users can select from. Municipalities can also choose any of the developer-provided optional "property type" options for the form field. And any developer-provided mandatory "property type" options will always be available for users to select. ## Example implementation @@ -25,9 +87,14 @@ You could hard-code some crop value choices using CharField and choices, or if y ![Descriptive Diagram](https://raw.githubusercontent.com/jacklinke/flexible-list-of-values/main/docs/media/FlexibleLOVDescription.png) -### Docker Compose +### The Testapp Project + +To help explain the package, we provide a demonstration project. Below, we describe how to set up the project using Docker Compose, + and how to work with the LOV Values and Selections. -This package comes with a demonstration project using docker compose. In order to utilize the project, we assume here that you have downloaded or cloned a copy of the [project code repository](https://github.com/jacklinke/flexible-list-of-values), and that you already have docker compose installed. If not, see [the compose docs](https://docs.docker.com/compose/install/). +#### Setup with Docker Compose + +To help you quickly test and understand the flexible-list-of-values package, we provide a Docker Compose setup with a demonstration project. In order to utilize the project, we assume here that you have downloaded or cloned a copy of the [project code repository](https://github.com/jacklinke/flexible-list-of-values), and that you already have docker compose installed. If not, see [the compose docs](https://docs.docker.com/compose/install/). Navigate to the "flexible-list-of-values" directory, and create a virtual environment to work from: @@ -66,7 +133,7 @@ If the user account you are logged in with belongs to a tenant, then [click here *See the Test Project in the [project repo](https://github.com/jacklinke/flexible-list-of-values) for full details.* -### The Testapp Project +#### Working with the Testapp Project Here is what is going on behind the scenes. @@ -101,7 +168,7 @@ class TenantCropLOVValue(AbstractLOVValue): "Fruit", "Herbs and Spices", and "Vegetable" are mandatory selections, but the others are provided to the Tenants as optional recommendations. Tenants can also add their own custom Values. """ - lov_entity_model = "testapp.Tenant" + lov_tenant_model = "testapp.Tenant" lov_selections_model = "testapp.TenantCropLOVSelection" lov_defaults = { @@ -131,7 +198,7 @@ class TenantCropLOVSelection(AbstractLOVSelection): of Crops that a Tenant's Users can select from in Forms """ lov_value_model = "testapp.TenantCropLOVValue" - lov_entity_model = "testapp.Tenant" + lov_tenant_model = "testapp.Tenant" class Meta(AbstractLOVSelection.Meta): verbose_name = "Tenant Crop Selection" @@ -158,47 +225,46 @@ For the example above, in a UserCrop form for Users of TenantA or TenantB ... - Both tenants can choose whether their Users will have the other values listed in `lov_defaults`. - And both tenants can create custom tenant-specific value options for their Users to choose from. -## Working with LOV Values +#### Working with LOV Values in the Testapp Project -View the available Values for this Tenant: +View the available Values for a Tenant: ```python tenant = Tenant.objects.first() -values_for_tenant = TenantCropLOVValue.objects.for_entity(tenant) +values_for_tenant = TenantCropLOVValue.objects.for_tenant(tenant) ``` Alternately: ```python tenant = Tenant.objects.first() -values_for_tenant = TenantCropLOVSelection.objects.values_for_entity(tenant) +values_for_tenant = TenantCropLOVSelection.objects.values_for_tenant(tenant) ``` View the selected Values for this Tenant: ```python tenant = Tenant.objects.first() -selected_values_for_entity = TenantCropLOVSelection.objects.selected_values_for_entity(tenant) +selected_values_for_tenant = TenantCropLOVSelection.objects.selected_values_for_tenant(tenant) ``` Create new custom Values for this Tenant: ```python tenant = Tenant.objects.first() -TenantCropLOVValue.objects.create_for_entity(tenant, "New Value for this Tenant") +TenantCropLOVValue.objects.create_for_tenant(tenant, "New Value for this Tenant") ``` Delete Values for this Tenant (only deletes custom values owned by this tenant) ```python tenant = Tenant.objects.first() -values = TenantCropLOVValue.objects.for_entity(entity=tenant).filter(name__icontains="Something") +values = TenantCropLOVValue.objects.for_tenant(tenant=tenant).filter(name__icontains="Something") for value in values: value.delete() ``` - -## Letting tenants select LOV value choices for their users +#### Letting tenants select LOV value choices for their users Tenants can select from among the Values available for this Tenant or create new Values @@ -220,7 +286,7 @@ class TenantCropValueCreateForm(LOVValueCreateFormMixin, forms.ModelForm): class Meta: model = TenantCropLOVValue - fields = ["name", "lov_entity", "value_type"] + fields = ["name", "lov_tenant", "value_type"] class TenantCropValueSelectionForm(LOVSelectionsModelForm): @@ -253,12 +319,12 @@ def lov_crop_value_create_view(request): template = "testapp/create_value.html" context = {} - # However you specify the current entity/tenant for the User submitting this form. + # However you specify the current tenant for the User submitting this form. # This is only an example. tenant = request.user.owned_tenants.first() - # Here we provide the User's entity, which the form will use to determine the available Values - form = TenantCropValueCreateForm(request.POST or None, lov_entity=tenant) + # Here we provide the User's associated tenant, which the form will use to determine the available Values + form = TenantCropValueCreateForm(request.POST or None, lov_tenant=tenant) if request.method == "POST": if form.is_valid(): @@ -267,7 +333,7 @@ def lov_crop_value_create_view(request): context["form"] = form # Provide the list of existing LOV Values for this Tenant - context["existing_values"] = TenantCropLOVValue.objects.for_entity(tenant) + context["existing_values"] = TenantCropLOVValue.objects.for_tenant(tenant) return TemplateResponse(request, template, context) @@ -281,25 +347,25 @@ def lov_tenant_crop_selection_view(request): template = "testapp/select_values.html" context = {} - # However you specify the current entity/tenant for the User submitting this form. + # However you specify the current tenant associated with the User submitting this form. # This is only an example. tenant = request.user.owned_tenants.first() - # Here we provide the entity - form = TenantCropValueSelectionForm(request.POST or None, lov_entity=tenant) + # Here we provide the tenant + form = TenantCropValueSelectionForm(request.POST or None, lov_tenant=tenant) if request.method == "POST": if form.is_valid(): form.save() # Update form's contents to ensure mandatory items are selected - form = TenantCropValueSelectionForm(None, lov_entity=tenant) + form = TenantCropValueSelectionForm(None, lov_tenant=tenant) context["form"] = form return TemplateResponse(request, template, context) ``` -## Working with a tenant's LOV Selections +#### Working with a tenant's LOV Selections A Tenant's Users make a choice from among the selected Values for this Tenant each time they fill out a UserCrop Selection Form. @@ -323,7 +389,7 @@ class UserCropSelectionForm(forms.ModelForm): super().__init__(*args, **kwargs) # Get all allowed values for this tenant if self.instance: - self.fields["crops"].queryset = TenantCropLOVSelection.objects.selections_for_entity(self.instance.tenant) + self.fields["crops"].queryset = TenantCropLOVSelection.objects.selections_for_tenant(self.instance.tenant) self.fields["user"].initial = self.user self.fields["tenant"].initial = self.instance.tenant else: @@ -348,7 +414,7 @@ def lov_user_crop_selection_view(request): template = "testapp/select_values.html" context = {} - # However you specify the current entity/tenant for the User submitting this form. + # However you specify the current tenant associated with the User submitting this form. # This is only an example. tenant = request.user.tenants.first() @@ -357,7 +423,7 @@ def lov_user_crop_selection_view(request): tenant=tenant, ) - # Here we provide the entity + # Here we provide the tenant form = UserCropSelectionForm(request.POST or None, instance=obj) if request.method == "POST": if form.is_valid(): @@ -369,9 +435,6 @@ def lov_user_crop_selection_view(request): Here, Users for TenantA who are filling out a UserCropSelectionForm form will see all mandatory values, the optional values that TenantA has selected, and any custom values TenantA has created. TenantB's users will see all mandatory values, the optional values that TenantB has selected, and any custom values TenantB has created. -## Management Commands - -- `update_lovs`: Synchronizes the `lov_defaults` in each model, if any, with the database. ## API @@ -381,8 +444,8 @@ Here, Users for TenantA who are filling out a UserCropSelectionForm form will se - **id**: default id - **name** (CharField): The name or title of the value to be used. -- **lov_entity** (FK): the owning entity for this value. If this is a default value, this field will be null. -- **lov_associated_entities** (M2M): all entities this value is associated with. (The reverse relationship on the entity model is all values selected for the entity) +- **lov_tenant** (FK): the owning tenant for this value. If this is a default value, this field will be null. +- **lov_associated_tenants** (M2M): all tenants this value is associated with. (The reverse relationship on the tenant model is all values selected for the tenant) - **value_type** (CharField): Any of - LOVValueType.MANDATORY - LOVValueType.OPTIONAL @@ -392,163 +455,175 @@ Here, Users for TenantA who are filling out a UserCropSelectionForm form will se #### Model attributes -
-
lov_defaults
-
- A dictionary of default mandatory and optional values from which an entity can select. See usage examples above.
- Default: {} -
- -
lov_entity_model
-
- Specifies the model class for the 'entity' in your project which can customize its Users' LOVs. Specify the string representation of the model class (e.g.: "entities.Entity").
- * Required -
- -
lov_entity_on_delete
-
- What should happen when the related entity instance is deleted.
- Default: models.CASCADE -
- -
lov_entity_model_related_name
-
- related_name for the related entity instance.
- Default: "%(app_label)s_%(class)s_related" -
- -
lov_entity_model_related_query_name
-
- related_query_name for the related entity instance.
- Default: "%(app_label)s_%(class)ss" -
- -
lov_selections_model
-
- Specifies the model class of the through-model between an Entity and a Value. Each instance of this through-model is an option that the tenant's users can choose from. Must be a concrete subclass of AbstractLOVSelection. Specify the string representation of the model class (e.g.: "entities.TenantCropLOVSelection").
- * Required -
- -
lov_associated_entities_related_name
-
- related_name for the M2M to the entity instance.
- Default: "%(app_label)s_%(class)s_related" -
- -
lov_associated_entities_related_query_name
-
- related_query_name for the M2M to the entity instance.
- Default: "%(app_label)s_%(class)ss" -
-
+ +- **`lov_defaults`** + A dictionary of default mandatory and optional values from which an tenant can select. See usage examples above. + *Default*: `{}` + +- **`lov_tenant_model`** + Specifies the model class for the 'tenant' in your project which can customize its Users' LOVs. Specify the string representation of the model class (e.g.: `"tenants.Tenant"`). + *Required* + +- **`lov_tenant_on_delete`** + What should happen when the related tenant instance is deleted. + *Default*: `models.CASCADE` + +- **`lov_tenant_model_related_name`** + `related_name` for the related tenant instance. + *Default*: `"%(app_label)s_%(class)s_related"` + +- **`lov_tenant_model_related_query_name`** + `related_query_name` for the related tenant instance. + *Default*: `"%(app_label)s_%(class)ss"` + +- **`lov_selections_model`** + Specifies the model class of the through-model between an Tenant and a Value. Each instance of this through-model is an option that the tenant's users can choose from. Must be a concrete subclass of AbstractLOVSelection. Specify the string representation of the model class (e.g.: `"tenants.TenantCropLOVSelection"`). + *Required* + +- **`lov_associated_tenants_related_name`** + `related_name` for the M2M to the tenant instance. + *Default*: `"%(app_label)s_%(class)s_related"` + +- **`lov_associated_tenants_related_query_name`** + `related_query_name` for the M2M to the tenant instance. + *Default*: `"%(app_label)s_%(class)ss"` + +#### Model Hooks + +The `AbstractLOVValue` model provides several hooks for extending or customizing its behavior. These hooks allow developers to add custom logic before and after saving an instance and to implement additional validation logic. + +- **`before_save(*args, **kwargs)`** + Called before an instance of `AbstractLOVValue` is saved. Override this method to add custom pre-save behavior. + + ```python + def before_save(self, *args, **kwargs): + # Custom pre-save logic here + ``` + +- **`after_save(*args, **kwargs)`** + Called after an instance of `AbstractLOVValue` is saved. Override this method to add custom post-save behavior. + + ```python + def after_save(self, *args, **kwargs): + # Custom post-save logic here + ``` #### Manager and QuerySet Methods -
-
for_entity(entity)
-
- Returns QuerySet of all available values for a given entity, including:
-
    -
  • all required default values
  • -
  • all non-required default values
  • -
  • all entity-specific values for this entity
  • -
-
- -
create_for_entity(entity, name: str)
-
- Creates a new selectable Value for the provided entity. -
- -
create_mandatory(name: str)
-
- Creates a new Value (selected for all entities). -
- -
create_optional(name: str)
-
- Creates a new selectable optional Value (selectable by all entities). -
-
+- **`for_tenant(tenant)`** + Returns QuerySet of all available values for a given tenant, including: + - all required default values + - all non-required default values + - all tenant-specific values for this tenant + +- **`create_for_tenant(tenant, name: str)`** + Creates a new selectable Value for the provided tenant. + +- **`create_mandatory(name: str)`** + Creates a new Value (selected for all tenants). + +- **`create_optional(name: str)`** + Creates a new selectable optional Value (selectable by all tenants). +#### Manager Hooks + +The `LOVValueManager` provides hooks for customizing the behavior of creating new instances. These hooks are useful for adding custom logic before and after creating a new `LOVValue`. + +- **`before_create(*args, **kwargs)`** + Called before a new instance of `LOVValue` is created. Override this method to add custom pre-create behavior. + + ```python + def before_create(self, *args, **kwargs): + # Custom pre-create logic here + ``` + +- **`after_create(obj, *args, **kwargs)`** + Called after a new instance of `LOVValue` is created. Override this method to add custom post-create behavior. + + ```python + def after_create(self, obj, *args, **kwargs): + # Custom post-create logic here + ``` ### Model: AbstractLOVSelection -This is a through-model from an concrete LOVValue model instance to an entity model instance representing the value selections an entity has made. +This is a through-model from an concrete LOVValue model instance to a tenant model instance representing the value selections a tenant has made. #### Fields - **id**: default id -- **lov_entity** (FK): the entity this selection is associated with. +- **lov_tenant** (FK): the tenant this selection is associated with. - **lov_value** (FK): the value this selection is associated with. #### Model attributes -
-
lov_entity_model
-
- Specifies the model class for the 'entity' in your project which can customize its Users' LOVs. Specify the string representation of the model class (e.g.: "entities.Entity").
- * Required -
- -
lov_entity_on_delete
-
- What should happen when the related entity instance is deleted.
- Default: models.CASCADE -
- -
lov_entity_model_related_name
-
- related_name for the related entity instance.
- Default: "%(app_label)s_%(class)s_related" -
- -
lov_entity_model_related_query_name
-
- related_query_name for the related entity instance.
- Default: "%(app_label)s_%(class)ss" -
- - -
lov_value_model
-
- Specifies the model class for the concrete (not abstract!) subclass of AbstractLOVValue. Specify the string representation of the model class (e.g.: "contacts.TenantCropLOVSelection").
- * Required -
- -
lov_value_model_related_name
-
- related_name for the related concrete subclassed AbstractLOVValue instance.
- Default: "%(app_label)s_%(class)s_related" -
- -
lov_value_model_related_query_name
-
- related_query_name for the related concrete subclassed AbstractLOVValue instance.
- Default: "%(app_label)s_%(class)ss" -
-
+- **`lov_tenant_model`** + Specifies the model class for the 'tenant' in your project which can customize its Users' LOVs. Specify the string representation of the model class (e.g.: `"tenants.Tenant"`). + *Required* + +- **`lov_tenant_on_delete`** + What should happen when the related tenant instance is deleted. + *Default*: `models.CASCADE` + +- **`lov_tenant_model_related_name`** + `related_name` for the related tenant instance. + *Default*: `"%(app_label)s_%(class)s_related"` + +- **`lov_tenant_model_related_query_name`** + `related_query_name` for the related tenant instance. + *Default*: `"%(app_label)s_%(class)ss"` + +- **`lov_value_model`** + Specifies the model class for the concrete (not abstract!) subclass of AbstractLOVValue. Specify the string representation of the model class (e.g.: `"contacts.TenantCropLOVSelection"`). + *Required* + +- **`lov_value_model_related_name`** + `related_name` for the related concrete subclassed AbstractLOVValue instance. + *Default*: `"%(app_label)s_%(class)s_related"` + +- **`lov_value_model_related_query_name`** + `related_query_name` for the related concrete subclassed AbstractLOVValue instance. + *Default*: `"%(app_label)s_%(class)ss"` + +#### Model Hooks + +The `AbstractLOVSelection` model provides several hooks for extending or customizing its behavior. These hooks allow developers to add custom logic before and after saving an instance and to implement additional validation logic. + +- **`before_save(*args, **kwargs)`** + Called before an instance of `AbstractLOVValue` is saved. Override this method to add custom pre-save behavior. + + ```python + def before_save(self, *args, **kwargs): + # Custom pre-save logic here + ``` + +- **`after_save(*args, **kwargs)`** + Called after an instance of `AbstractLOVValue` is saved. Override this method to add custom post-save behavior. + + ```python + def after_save(self, *args, **kwargs): + # Custom post-save logic here + ``` #### Manager and QuerySet Methods -
-
values_for_entity(entity)
-
- Returns QuerySet of all available values for a given entity, including:
-
    -
  • all required default values
  • -
  • all non-required default values
  • -
  • all entity-specific values for this entity
  • -
-
- -
selected_values_for_entity(entity)
-
- Returns QuerySet of all selected values for a given entity, including:
-
    -
  • all required default values
  • -
  • all selected non-required default values
  • -
  • all selected entity-specific values for this entity
  • -
-
-
+ +- **`values_for_tenant(tenant)`** + Returns QuerySet of all *available* values for a given tenant, including: + - all required default values + - all non-required default values + - all tenant-specific values for this tenant + +- **`selected_values_for_tenant(tenant)`** + Returns QuerySet of all *selected* values for a given tenant, including: + - all required default values + - all *selected* non-required default values + - all *selected* tenant-specific values for this tenant + +## Management Commands + +- `update_lovs`: Synchronizes the `lov_defaults` in each model, if any, with the database. + +## Settings + +- `LOV_MODEL_BASE`: Defaults to `django.db.models.base.ModelBase`, but can be overridden with a different class as the base for AbstractLOVValue and AbstractLOVSelection models. diff --git a/flexible_list_of_values/__init__.py b/flexible_list_of_values/__init__.py index bb3d083..9015ce3 100644 --- a/flexible_list_of_values/__init__.py +++ b/flexible_list_of_values/__init__.py @@ -5,6 +5,6 @@ class LOVValueType(models.TextChoices): """Allowable selections for List Of Values Value Types""" - MANDATORY = "dm", _("Default Mandatory") # Entity can see, but not change this value - OPTIONAL = "do", _("Default Optional") # Entity can see and select/unselect this value - CUSTOM = "cu", _("Custom") # Entity created and can select/unselect this value + MANDATORY = "dm", _("Default Mandatory") # Tenant can see, but not change this value + OPTIONAL = "do", _("Default Optional") # Tenant can see and select/unselect this value + CUSTOM = "cu", _("Custom") # Tenant created and can select/unselect this value diff --git a/flexible_list_of_values/app_settings.py b/flexible_list_of_values/app_settings.py new file mode 100644 index 0000000..1bb7e48 --- /dev/null +++ b/flexible_list_of_values/app_settings.py @@ -0,0 +1,8 @@ +from django.conf import settings +from django.db.models.base import ModelBase + + +# There is likely no reason ever to change the model base, but it is provided as an setting +# here for completeness. +LOV_MODEL_BASE = getattr(settings, 'LOV_MODEL_BASE', ModelBase) + diff --git a/flexible_list_of_values/exceptions.py b/flexible_list_of_values/exceptions.py index da752aa..9b73670 100644 --- a/flexible_list_of_values/exceptions.py +++ b/flexible_list_of_values/exceptions.py @@ -1,19 +1,22 @@ class ModelClassParsingError(Exception): - """ - Used when a model class cannot be parsed from the value provided - """ - pass + """Used when a model class cannot be parsed from the value provided.""" + + def __init__(self, message="Model class cannot be parsed from the value provided"): + self.message = message + super().__init__(self.message) class IncorrectSubclassError(Exception): - """ - Used when the value of `lov_value_model` is not a subclass of the correct abstract model - """ - pass + """Used when the value of `lov_value_model` is not a subclass of the correct abstract model.""" + + def __init__(self, message="Incorrect subclass usage for lov_value_model"): + self.message = message + super().__init__(self.message) + +class NoTenantProvidedFromViewError(Exception): + """Used when a view does not pass an lov_tenant instance from the view to a form.""" -class NoEntityProvidedFromViewError(Exception): - """ - Used when a view does not pass an lov_entity instance from the view to a form - """ - pass + def __init__(self, message="The view did not pass an lov_tenant instance to the form"): + self.message = message + super().__init__(self.message) diff --git a/flexible_list_of_values/forms.py b/flexible_list_of_values/forms.py index 99cd43d..d40273f 100644 --- a/flexible_list_of_values/forms.py +++ b/flexible_list_of_values/forms.py @@ -5,7 +5,7 @@ from django.db import IntegrityError, transaction from . import LOVValueType -from .exceptions import NoEntityProvidedFromViewError +from .exceptions import NoTenantProvidedFromViewError logger = logging.getLogger("flexible_list_of_values") @@ -14,68 +14,74 @@ class LOVFormBaseMixin: - """ - Checks that we have a valid lov_entity value passed in from the view - """ + """Checks that we have a valid lov_tenant value passed in from the view.""" def __init__(self, *args, **kwargs): - self.lov_entity = kwargs.pop("lov_entity", None) - if not self.lov_entity: - raise NoEntityProvidedFromViewError("No lov_entity model class was provided to the form from the view") + self.lov_tenant = kwargs.pop("lov_tenant", None) + if not self.lov_tenant: + raise NoTenantProvidedFromViewError( + "No lov_tenant model class was provided to the form from the view. " + "Ensure that 'lov_tenant' is passed as an argument." + ) super().__init__(*args, **kwargs) class LOVValueCreateFormMixin(LOVFormBaseMixin): - """ - Used in forms that allow an entity to create a new custom value. + """Used in forms that allow an tenant to create a new custom value. - It requires an `lov_entity` argument to be passed from the view. This should be an instance of the model - class provided in the lov_entity_model parameter of the concrete LOVValueModel. + It requires an `lov_tenant` argument to be passed from the view. This should be an instance of the model + class provided in the lov_tenant_model parameter of the concrete LOVValueModel. Usage: - class MyValueCreateForm(EntityValueCreateFormMixin, forms.ModelForm): + class MyValueCreateForm(TenantValueCreateFormMixin, forms.ModelForm): class Meta: model = MyConcreteLOVValueModel def my_values_view(request): - form = MyValueCreateForm(request.POST, lov_entity=request.user.tenant) + form = MyValueCreateForm(request.POST, lov_tenant=request.user.tenant) """ + + def get_value_type_widget(self): + """Returns the widget to use for the value_type field.""" + return HiddenInput() + + def get_lov_tenant_widget(self): + """Returns the widget to use for the lov_tenant field.""" + return HiddenInput() def __init__(self, *args, **kwargs): + """Sets the value_type and lov_tenant fields to the correct values.""" super().__init__(*args, **kwargs) # Set the value_type field value to LOVValueType.CUSTOM and use a HiddenInput widget - self.fields["value_type"].widget = HiddenInput() + self.fields["value_type"].widget = self.get_value_type_widget() self.fields["value_type"].initial = LOVValueType.CUSTOM - # Set the lov_entity field value to the entity instance provided from the view + # Set the lov_tenant field value to the tenant instance provided from the view # and use a HiddenInput widget - self.fields["lov_entity"].widget = HiddenInput() - self.fields["lov_entity"].initial = self.lov_entity + self.fields["lov_tenant"].widget = self.get_lov_tenant_widget() + self.fields["lov_tenant"].initial = self.lov_tenant def clean(self): + """Ensures that the value_type and lov_tenant fields are correct even if the HiddenInput widget was manipulated.""" cleaned_data = super().clean() - # # if this Item already exists - # if self.instance: - # # ensure value_type and lov_entity is correct even if HiddenField was manipulated - # cleaned_data["value_type"] = LOVValueType.CUSTOM - # cleaned_data["lov_entity"] = self.lov_entity - - # ensure value_type and lov_entity is correct even if HiddenField was manipulated + # ensure value_type and lov_tenant is correct even if HiddenField was manipulated cleaned_data["value_type"] = LOVValueType.CUSTOM - cleaned_data["lov_entity"] = self.lov_entity + cleaned_data["lov_tenant"] = self.lov_tenant return cleaned_data class LOVModelMultipleChoiceField(forms.ModelMultipleChoiceField): - """ - Displays objects and shows which are mandatory - """ + """Displays objects and shows which are mandatory.""" def label_from_instance(self, obj): + """Displays objects and shows which are mandatory. + + Override this method to change the display of the objects in the field. + """ if obj.value_type == LOVValueType.MANDATORY: return f"{obj.name} (mandatory)" return obj.name @@ -100,19 +106,20 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Get all allowed values for this tenant - self.fields["lov_selections"].queryset = self.lov_selection_model.objects.values_for_entity(self.lov_entity) + self.fields["lov_selections"].queryset = self.lov_selection_model.objects.values_for_tenant(self.lov_tenant) # Make sure the current selections (and mandatory values) are selected in the form - self.fields["lov_selections"].initial = self.lov_selection_model.objects.selected_values_for_entity( - self.lov_entity + self.fields["lov_selections"].initial = self.lov_selection_model.objects.selected_values_for_tenant( + self.lov_tenant ) self.fields["lov_selections"].widget.attrs["size"] = "10" def clean(self): + """Ensures that the lov_selections field is correct even if the HiddenInput widget was manipulated.""" cleaned_data = super().clean() - # ensure value_type and lov_selections contain mandatory items even if HiddenField was manipulated + # ensure lov_selections contain mandatory items even if HiddenField was manipulated cleaned_lov_selections = ( cleaned_data["lov_selections"] | self.lov_value_model.objects.filter(value_type=LOVValueType.MANDATORY) ).distinct() @@ -127,8 +134,8 @@ def save(self, *args, **kwargs): try: with transaction.atomic(): for selection in self.cleaned_data["lov_selections"]: - self.lov_selection_model.objects.update_or_create(lov_entity=self.lov_entity, lov_value=selection) + self.lov_selection_model.objects.update_or_create(lov_tenant=self.lov_tenant, lov_value=selection) self.lov_selection_model.objects.filter(lov_value__in=self.removed_selections).delete() except IntegrityError as e: - logger.warning(f"Problem creating or deleting selections for {self.lov_entity}: {e}") - super().__init__(*args, **kwargs, lov_entity=self.lov_entity) + logger.warning(f"Problem creating or deleting selections for {self.lov_tenant}: {e}") + super().save(*args, **kwargs) diff --git a/flexible_list_of_values/models.py b/flexible_list_of_values/models.py index 5bcea41..27f5054 100644 --- a/flexible_list_of_values/models.py +++ b/flexible_list_of_values/models.py @@ -1,71 +1,80 @@ +import logging + from django.apps import apps -from django.db import models +from django.core.exceptions import ValidationError +from django.db import models, IntegrityError, transaction from django.db.models import Q -from django.db.models.base import ModelBase from django.db.models.constraints import CheckConstraint, UniqueConstraint from django.db.models.functions import Lower from django.utils import timezone from django.utils.translation import gettext_lazy as _ from . import LOVValueType +from .app_settings import LOV_MODEL_BASE as ModelBase from .exceptions import IncorrectSubclassError +logger = logging.getLogger(__name__) + -class LOVEntityModelBase(ModelBase): +class LOVTenantModelBase(ModelBase): """ - Models extending LOVEntityModelBase get a ForeignKey to the model class specified in lov_entity_model + Models extending LOVTenantModelBase get a ForeignKey to the model class specified in lov_tenant_model """ def __new__(cls, name, bases, attrs, **kwargs): - model = super().__new__(cls, name, bases, attrs, **kwargs) - - for base in bases: - if base.__name__ == "AbstractLOVSelection" or base.__name__ == "AbstractLOVValue": - ConcreteLOVModel = model - - if not ConcreteLOVModel._meta.abstract and ConcreteLOVModel.lov_entity_model is not None: - ConcreteLOVModel.add_to_class( - "lov_entity", - models.ForeignKey( - ConcreteLOVModel.lov_entity_model, - on_delete=ConcreteLOVModel.lov_entity_on_delete, - related_name=ConcreteLOVModel.lov_entity_model_related_name, - related_query_name=ConcreteLOVModel.lov_entity_model_related_query_name, - blank=True, - null=True, - ), - ) - else: - raise IncorrectSubclassError( - "lov_value_model must be specified for concrete subclasses of AbstractLOVSelection" - ) - - return model - - -class LOVValueModelBase(LOVEntityModelBase): + try: + model = super().__new__(cls, name, bases, attrs, **kwargs) + + for base in bases: + if base.__name__ == "AbstractLOVSelection" or base.__name__ == "AbstractLOVValue": + ConcreteLOVModel = model + + if not ConcreteLOVModel._meta.abstract and ConcreteLOVModel.lov_tenant_model is not None: + ConcreteLOVModel.add_to_class( + "lov_tenant", + models.ForeignKey( + ConcreteLOVModel.lov_tenant_model, + on_delete=ConcreteLOVModel.lov_tenant_on_delete, + related_name=ConcreteLOVModel.lov_tenant_model_related_name, + related_query_name=ConcreteLOVModel.lov_tenant_model_related_query_name, + blank=True, + null=True, + ), + ) + else: + raise IncorrectSubclassError( + "lov_value_model must be specified for concrete subclasses of AbstractLOVSelection" + ) + + return model + except IncorrectSubclassError as e: + logger.error(f"Incorrect subclass usage in {name}: {e.message}") + raise e + + +class LOVValueModelBase(LOVTenantModelBase): """ - Models extending EntityAndValueModelBase get a ForeignKey to the model class specified in lov_entity_model - (inherited from LOVEntityModelBase), and a ManyToManyField to the model specified in lov_value_model + Models extending TenantAndValueModelBase get a ForeignKey to the model class specified in lov_tenant_model + (inherited from LOVTenantModelBase), and a ManyToManyField to the model specified in lov_value_model Used to set up the ManyToManyField from concrete classes of AbstractLOVSelection to concrete classes of AbstractLOVValue. Within the model, set the `lov_value_model` parameter to the concrete class inheriting AbstractLOVValue - and `lov_entity_model` to the model entity which is associated with both LOV concrete classes. + and `lov_tenant_model` to the model tenant which is associated with both LOV concrete classes. For instance: class ConcreteLOVValue(AbstractLOVValue): - lov_entity_model = SomeModel - # or `lov_entity_model = "myapp.SomeModel"` + lov_tenant_model = SomeModel + # or `lov_tenant_model = "myapp.SomeModel"` class ConcreteLOVSelection(AbstractLOVSelection): lov_value_model = ConcreteLOVValue # or `lov_value_model = "myapp.ConcreteLOVValue"` - lov_entity_model = SomeModel - # or `lov_entity_model = "myapp.SomeModel"` + lov_tenant_model = SomeModel + # or `lov_tenant_model = "myapp.SomeModel"` """ def __new__(cls, name, bases, attrs, **kwargs): @@ -78,16 +87,16 @@ def __new__(cls, name, bases, attrs, **kwargs): if ( not ConcreteLOVValueModel._meta.abstract and ConcreteLOVValueModel.lov_selections_model is not None - and ConcreteLOVValueModel.lov_entity_model is not None + and ConcreteLOVValueModel.lov_tenant_model is not None ): ConcreteLOVValueModel.add_to_class( - "lov_associated_entities", + "lov_associated_tenants", models.ManyToManyField( - ConcreteLOVValueModel.lov_entity_model, + ConcreteLOVValueModel.lov_tenant_model, through=ConcreteLOVValueModel.lov_selections_model, - through_fields=("lov_value", "lov_entity"), - related_name=ConcreteLOVValueModel.lov_associated_entities_related_name, # "selected" - related_query_name=ConcreteLOVValueModel.lov_associated_entities_related_query_name, + through_fields=("lov_value", "lov_tenant"), + related_name=ConcreteLOVValueModel.lov_associated_tenants_related_name, # "selected" + related_query_name=ConcreteLOVValueModel.lov_associated_tenants_related_query_name, ), ) else: @@ -98,29 +107,29 @@ def __new__(cls, name, bases, attrs, **kwargs): return model -class LOVSelectionModelBase(LOVEntityModelBase): +class LOVSelectionModelBase(LOVTenantModelBase): """ - Models extending EntityAndValueModelBase get a ForeignKey to the model class specified in lov_entity_model - (inherited from LOVEntityModelBase), and a ManyToManyField to the model specified in lov_value_model + Models extending TenantAndValueModelBase get a ForeignKey to the model class specified in lov_tenant_model + (inherited from LOVTenantModelBase), and a ManyToManyField to the model specified in lov_value_model Used to set up the ManyToManyField from concrete classes of AbstractLOVSelection to concrete classes of AbstractLOVValue. Within the model, set the `lov_value_model` parameter to the concrete class inheriting AbstractLOVValue - and `lov_entity_model` to the model entity which is associated with both LOV concrete classes. + and `lov_tenant_model` to the model tenant which is associated with both LOV concrete classes. For instance: class ConcreteLOVValue(AbstractLOVValue): - lov_entity_model = SomeModel - # or `lov_entity_model = "myapp.SomeModel"` + lov_tenant_model = SomeModel + # or `lov_tenant_model = "myapp.SomeModel"` class ConcreteLOVSelection(AbstractLOVSelection): lov_value_model = ConcreteLOVValue # or `lov_value_model = "myapp.ConcreteLOVValue"` - lov_entity_model = SomeModel - # or `lov_entity_model = "myapp.SomeModel"` + lov_tenant_model = SomeModel + # or `lov_tenant_model = "myapp.SomeModel"` """ def __new__(cls, name, bases, attrs, **kwargs): @@ -158,17 +167,17 @@ class LOVValueQuerySet(models.QuerySet): Custom QuerySet for LOVValue models """ - def for_entity(self, entity): + def for_tenant(self, tenant): """ - Returns all available values for a given entity, including: + Returns all available values for a given tenant, including: - all required default values - all non-required default values - - all entity-specific values for this entity + - all tenant-specific values for this tenant """ return self.model.objects.filter( Q(value_type=LOVValueType.MANDATORY) | Q(value_type=LOVValueType.OPTIONAL) - | Q(value_type=LOVValueType.CUSTOM, lov_entity=entity) + | Q(value_type=LOVValueType.CUSTOM, lov_tenant=tenant) ) @@ -179,44 +188,61 @@ class LOVValueManager(models.Manager): def get_queryset(self): return super().get_queryset().filter(deleted__isnull=True) + + + def before_create(self, *args, **kwargs): + """ + Hook method called before creating a new instance. + Can be overridden in subclasses for custom behavior. + """ + pass - def create_for_entity(self, entity, name: str): - """Provided an entity and a value name, creates the new value for that entity""" - self.create(lov_entity=entity, name=name, value_type=LOVValueType.CUSTOM) + def after_create(self, obj, *args, **kwargs): + """ + Hook method called after a new instance is created. + Can be overridden in subclasses for custom behavior. + """ + pass + + def create_for_tenant(self, tenant, name: str): + """Provided an tenant and a value name, creates the new value for that tenant""" + self.before_create(tenant=tenant, name=name) + obj = self.create(lov_tenant=tenant, name=name, value_type=LOVValueType.CUSTOM) + self.after_create(obj, tenant=tenant, name=name) + return obj def create_mandatory(self, name: str): - """Provided a value name, creates the new value (selected for all entities)""" + """Provided a value name, creates the new value (selected for all tenants)""" self.create(name=name, value_type=LOVValueType.MANDATORY) def create_optional(self, name: str): - """Provided a value name, creates the new optional value (selectable by all entities)""" - self.create(name=name, value_type=LOVValueType.OPTIONAL) - - def _create_default_option(self, item_name, item_values_dict=dict): - """ + """Provided a value name, creates the new optional value (selectable by all tenants)""" + self.create(name=name, value_type=LOVValueType.OPTIONAL) + + def _create_default_option(self, item_name, item_values_dict): + """Create a default option for a given item_name and item_values_dict. + It should not be necessary, but this method can be overridden in a subclassed Manager if you need to modify how subclassed concrete instances are created. """ + try: + with transaction.atomic(): + value_type = item_values_dict.get("value_type", LOVValueType.MANDATORY) - value_type = LOVValueType.MANDATORY - - # If value_type key is present, validate that it is LOVValueType.MANDATORY or LOVValueType.OPTIONAL - # LOVValueType.CUSTOM cannot be used in defaults - for key, value in item_values_dict.items(): - if key == "value_type": - value_type = value - if not (value == LOVValueType.MANDATORY or value == LOVValueType.OPTIONAL): - raise Exception( + if value_type not in [LOVValueType.MANDATORY, LOVValueType.OPTIONAL]: + raise ValueError( f"LOVValue defaults must be of type `LOVValueType.MANDATORY` or `LOVValueType.OPTIONAL`. " - f"For {item_name} you specified {key} = {value}." - ) - - obj, created = self.model.objects.update_or_create( - name=item_name, - value_type=value_type, - # defaults=item_values_dict, # ToDo: Add options as needed - defaults=None, - ) + f"For {item_name} you specified value_type = {value_type}.") + + obj, created = self.model.objects.update_or_create( + name=item_name, + defaults={'value_type': value_type}, + ) + return obj, created + except IntegrityError as e: + # Handle the integrity error, e.g., log it or raise a custom exception + logger.error(f"Integrity error while creating default option '{item_name}': {e}") + raise def _import_defaults(self): """ @@ -233,11 +259,13 @@ def _import_defaults(self): Note, it is not necessary to specify `{"value_type": LOVValueType.MANDATORY}` since options are mandatory by default. You could set the dict to `{}` and the value model instance will be set to mandatory. - """ for item_name, item_values_dict in self.model.lov_defaults.items(): - self.model.objects._create_default_option(item_name, item_values_dict) - + try: + self.model.objects._create_default_option(item_name, item_values_dict) + except Exception as e: + logger.error(f"Error importing default '{item_name}': {e}") + class AbstractLOVValue(models.Model, metaclass=LOVValueModelBase): """ @@ -250,15 +278,15 @@ class AbstractLOVValue(models.Model, metaclass=LOVValueModelBase): lov_defaults = {} - lov_entity_model = None - lov_entity_on_delete = models.CASCADE - lov_entity_model_related_name = "%(app_label)s_%(class)s_related" - lov_entity_model_related_query_name = "%(app_label)s_%(class)ss" + lov_tenant_model = None + lov_tenant_on_delete = models.CASCADE + lov_tenant_model_related_name = "%(app_label)s_%(class)s_related" + lov_tenant_model_related_query_name = "%(app_label)s_%(class)ss" lov_selections_model = None - lov_associated_entities_related_name = "%(app_label)s_%(class)s_selections" - lov_associated_entities_related_query_name = "%(app_label)s_%(class)ss_selected" + lov_associated_tenants_related_name = "%(app_label)s_%(class)s_selections" + lov_associated_tenants_related_query_name = "%(app_label)s_%(class)ss_selected" value_type = models.CharField( _("Option Type"), @@ -283,19 +311,19 @@ class Meta: verbose_name = _("List of Values Option") verbose_name_plural = _("List of Values Options") constraints = [ - # A particular entity cannot have more than one value with the same name + # A particular tenant cannot have more than one value with the same name UniqueConstraint( Lower("name"), - "lov_entity", + "lov_tenant", name="%(app_label)s_%(class)s_name_val", ), - # Values of type LOVValueType.CUSTOM must have a specified entity - # Values of type LOVValueType.MANDATORY and LOVValueType.OPTIONAL must not have a specified entity + # Values of type LOVValueType.CUSTOM must have a specified tenant + # Values of type LOVValueType.MANDATORY and LOVValueType.OPTIONAL must not have a specified tenant CheckConstraint( - check=Q(value_type=LOVValueType.CUSTOM, lov_entity__isnull=False) - | Q(value_type=LOVValueType.MANDATORY, lov_entity__isnull=True) - | Q(value_type=LOVValueType.OPTIONAL, lov_entity__isnull=True), - name="%(app_label)s_%(class)s_no_mandatory_entity", + check=Q(value_type=LOVValueType.CUSTOM, lov_tenant__isnull=False) + | Q(value_type=LOVValueType.MANDATORY, lov_tenant__isnull=True) + | Q(value_type=LOVValueType.OPTIONAL, lov_tenant__isnull=True), + name="%(app_label)s_%(class)s_no_mandatory_tenant", ), ] abstract = True @@ -322,6 +350,34 @@ def get_concrete_subclasses(cls): if issubclass(model, cls) and model is not cls and not model._meta.abstract: result.append(model) return result + + def before_save(self, *args, **kwargs): + """ + Hook method called before saving an instance. + Subclasses can override this to add custom pre-save behavior. + """ + pass + + def after_save(self, *args, **kwargs): + """ + Hook method called after saving an instance. + Subclasses can override this to add custom post-save behavior. + """ + pass + + def save(self, *args, **kwargs): + """Call full_clean() before saving""" + self.full_clean() + self.before_save(*args, **kwargs) + super().save(*args, **kwargs) + self.after_save(*args, **kwargs) + + def clean(self): + """Check that the model is configured correctly.""" + if self.lov_tenant_model is None: + raise ValidationError("LOV tenant model cannot be None.") + if self.lov_selections_model is None: + raise ValidationError("LOV selections model cannot be None.") class LOVSelectionQuerySet(models.QuerySet): @@ -338,30 +394,30 @@ class LOVSelectionManager(models.Manager): def get_queryset(self): return super().get_queryset() - def values_for_entity(self, entity): + def values_for_tenant(self, tenant): """ Returns a QuerySet of the AbstractLOVValue subclassed model with all *available* - values for a given entity, including: + values for a given tenant, including: - all required default values - all non-required default values - - all entity-specific values + - all tenant-specific values """ try: ValuesModel = apps.get_model(self.model.lov_value_model) - return ValuesModel.objects.for_entity(entity=entity) + return ValuesModel.objects.for_tenant(tenant=tenant) except LookupError: # no such model in this application return None - def selected_values_for_entity(self, entity): + def selected_values_for_tenant(self, tenant): """ Returns a QuerySet of the AbstractLOVValue subclassed model with all *selected* - values for a given entity, including: + values for a given tenant, including: - all mandatory default values - - all entity-selected optional default values - - all entity-selected custom values + - all tenant-selected optional default values + - all tenant-selected custom values """ try: @@ -372,16 +428,16 @@ def selected_values_for_entity(self, entity): "value_type": LOVValueType.MANDATORY, } - # For LOVSelections that are Optional and belong to our entity, return the associated LOVValue instances + # For LOVSelections that are Optional and belong to our tenant, return the associated LOVValue instances optional = { "value_type": LOVValueType.OPTIONAL, - "lov_associated_entities": entity, + "lov_associated_tenants": tenant, } - # For LOVSelections that are Custom and belong to our entity, return the associated LOVValue instances + # For LOVSelections that are Custom and belong to our tenant, return the associated LOVValue instances custom = { "value_type": LOVValueType.CUSTOM, - "lov_associated_entities": entity, + "lov_associated_tenants": tenant, } return ValuesModel.objects.filter(Q(**mandatory) | Q(**optional) | Q(**custom)) @@ -393,15 +449,15 @@ def selected_values_for_entity(self, entity): class AbstractLOVSelection(models.Model, metaclass=LOVSelectionModelBase): """ - Identifies all selected LOV Values for a given entity, which it's users can then choose from + Identifies all selected LOV Values for a given tenant, which it's users can then choose from - A single entity can select multiple LOV Options + A single tenant can select multiple LOV Options """ - lov_entity_model = None - lov_entity_on_delete = models.CASCADE - lov_entity_model_related_name = "%(app_label)s_%(class)s_related" - lov_entity_model_related_query_name = "%(app_label)s_%(class)ss" + lov_tenant_model = None + lov_tenant_on_delete = models.CASCADE + lov_tenant_model_related_name = "%(app_label)s_%(class)s_related" + lov_tenant_model_related_query_name = "%(app_label)s_%(class)ss" lov_value_model = None lov_value_on_delete = models.CASCADE @@ -417,9 +473,37 @@ class Meta: verbose_name_plural = _("List of Values Selections") constraints = [ UniqueConstraint( - "lov_entity", + "lov_tenant", "lov_value", name="%(app_label)s_%(class)s_name_val", ), ] abstract = True + + def before_save(self, *args, **kwargs): + """ + Hook method called before saving an instance. + Subclasses can override this to add custom pre-save behavior. + """ + pass + + def after_save(self, *args, **kwargs): + """ + Hook method called after saving an instance. + Subclasses can override this to add custom post-save behavior. + """ + pass + + def save(self, *args, **kwargs): + """Call full_clean() before saving""" + self.full_clean() + self.before_save(*args, **kwargs) + super().save(*args, **kwargs) + self.after_save(*args, **kwargs) + + def clean(self): + """Check that the model is configured correctly.""" + if self.lov_tenant_model is None: + raise ValidationError("LOV tenant model cannot be None.") + if self.lov_value_model is None: + raise ValidationError("LOV value model cannot be None.") diff --git a/pyproject.toml b/pyproject.toml index 22dd5d1..f784ab6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,23 +1,26 @@ [tool.poetry] name = "flexible-list-of-values" -version = "0.2.0" +version = "0.3.0b1" description = "Flexible Lists of Values (LOV) for Django" authors = ["Jack Linke "] license = "MIT" readme = "README.md" -packages = [{include = "flexible_list_of_values"}] +packages = [{ include = "flexible_list_of_values" }] homepage = "https://github.com/jacklinke/flexible-list-of-values" repository = "https://github.com/jacklinke/flexible-list-of-values" -keywords = ["django",] +keywords = ["django"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 3 - Beta", "Environment :: Web Environment", "Framework :: Django", "Framework :: Django :: 3.2", "Framework :: Django :: 4", "Framework :: Django :: 4.0", "Framework :: Django :: 4.1", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5", + "Framework :: Django :: 5.0", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", @@ -25,14 +28,14 @@ classifiers = [ ] [tool.poetry.dependencies] -python = "^3.8" +python = "^3.9" django = ">=3.2" [tool.poetry.group.dev] optional = true [tool.poetry.group.dev.dependencies] -black = {version = ">=22.1.0", allow-prereleases = true} +black = { version = ">=22.1.0", allow-prereleases = true } pytest = "^7" django-extensions = "^3" werkzeug = "^2" @@ -40,3 +43,11 @@ werkzeug = "^2" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 120 +target-version = ["py39", "py310", "py311", "py312"] + +[tool.ruff] +line-length = 120 +target-version = "py311" diff --git a/tests/testapp/admin.py b/tests/testapp/admin.py index 9f1483b..1bf6f1c 100644 --- a/tests/testapp/admin.py +++ b/tests/testapp/admin.py @@ -10,7 +10,7 @@ class TenantAdmin(admin.ModelAdmin): @admin.register(TenantCropLOVSelection) class TenantCropLOVSelectionAdmin(admin.ModelAdmin): - list_display = ["lov_value", "lov_entity"] + list_display = ["lov_value", "lov_tenant"] @admin.register(TenantCropLOVValue) diff --git a/tests/testapp/forms.py b/tests/testapp/forms.py index ef4183d..e32781c 100644 --- a/tests/testapp/forms.py +++ b/tests/testapp/forms.py @@ -15,7 +15,7 @@ class TenantCropValueCreateForm(LOVValueCreateFormMixin, forms.ModelForm): class Meta: model = TenantCropLOVValue - fields = ["name", "lov_entity", "value_type"] + fields = ["name", "lov_tenant", "value_type"] class TenantCropValueSelectionForm(LOVSelectionsModelForm): @@ -45,7 +45,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Get all allowed values for this tenant if self.instance: - self.fields["crops"].queryset = TenantCropLOVSelection.objects.selected_values_for_entity( + self.fields["crops"].queryset = TenantCropLOVSelection.objects.selected_values_for_tenant( self.instance.tenant ) self.fields["user"].initial = self.user diff --git a/tests/testapp/migrations/0001_initial.py b/tests/testapp/migrations/0001_initial.py deleted file mode 100644 index 1070e19..0000000 --- a/tests/testapp/migrations/0001_initial.py +++ /dev/null @@ -1,210 +0,0 @@ -# Generated by Django 4.1.5 on 2023-01-13 18:01 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import django.db.models.functions.text - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="Tenant", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=100, verbose_name="Tenant Name")), - ( - "owner", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="owned_tenants", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "users", - models.ManyToManyField( - blank=True, related_name="tenants", to=settings.AUTH_USER_MODEL - ), - ), - ], - ), - migrations.CreateModel( - name="TenantCropLOVSelection", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "lov_entity", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="%(app_label)s_%(class)s_related", - related_query_name="%(app_label)s_%(class)ss", - to="testapp.tenant", - ), - ), - ], - options={ - "verbose_name": "Tenant Crop Selection", - "verbose_name_plural": "Tenant Crop Selections", - "abstract": False, - }, - ), - migrations.CreateModel( - name="TenantCropLOVValue", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "value_type", - models.CharField( - blank=True, - choices=[ - ("dm", "Default Mandatory"), - ("do", "Default Optional"), - ("cu", "Custom"), - ], - default="do", - max_length=3, - verbose_name="Option Type", - ), - ), - ("name", models.CharField(max_length=100, verbose_name="Value Name")), - ( - "deleted", - models.DateTimeField( - blank=True, help_text="When was this value deleted?", null=True - ), - ), - ( - "lov_associated_entities", - models.ManyToManyField( - related_name="%(app_label)s_%(class)s_selections", - related_query_name="%(app_label)s_%(class)ss_selected", - through="testapp.TenantCropLOVSelection", - to="testapp.tenant", - ), - ), - ( - "lov_entity", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="%(app_label)s_%(class)s_related", - related_query_name="%(app_label)s_%(class)ss", - to="testapp.tenant", - ), - ), - ], - options={ - "verbose_name": "Tenant Crop Value", - "verbose_name_plural": "Tenant Crop Values", - "ordering": ["name"], - "abstract": False, - }, - ), - migrations.CreateModel( - name="UserCrop", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("crops", models.ManyToManyField(to="testapp.tenantcroplovvalue")), - ( - "tenant", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="user_crops", - to="testapp.tenant", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="user_crops", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - migrations.AddField( - model_name="tenantcroplovselection", - name="lov_value", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="%(app_label)s_%(class)s_related", - related_query_name="%(app_label)s_%(class)ss", - to="testapp.tenantcroplovvalue", - ), - ), - migrations.AddConstraint( - model_name="tenantcroplovvalue", - constraint=models.UniqueConstraint( - django.db.models.functions.text.Lower("name"), - models.F("lov_entity"), - name="testapp_tenantcroplovvalue_name_val", - ), - ), - migrations.AddConstraint( - model_name="tenantcroplovvalue", - constraint=models.CheckConstraint( - check=models.Q( - models.Q(("lov_entity__isnull", False), ("value_type", "cu")), - models.Q(("lov_entity__isnull", True), ("value_type", "dm")), - models.Q(("lov_entity__isnull", True), ("value_type", "do")), - _connector="OR", - ), - name="testapp_tenantcroplovvalue_no_mandatory_entity", - ), - ), - migrations.AddConstraint( - model_name="tenantcroplovselection", - constraint=models.UniqueConstraint( - models.F("lov_entity"), - models.F("lov_value"), - name="testapp_tenantcroplovselection_name_val", - ), - ), - ] diff --git a/tests/testapp/models.py b/tests/testapp/models.py index 18fc0b9..835f324 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -30,7 +30,7 @@ class TenantCropLOVValue(AbstractLOVValue): to the Tenants as optional recommendations. Tenants can also add their own custom Values. """ - lov_entity_model = "testapp.Tenant" + lov_tenant_model = "testapp.Tenant" lov_selections_model = "testapp.TenantCropLOVSelection" lov_defaults = { @@ -71,7 +71,7 @@ class TenantCropLOVSelection(AbstractLOVSelection): """ lov_value_model = "testapp.TenantCropLOVValue" - lov_entity_model = "testapp.Tenant" + lov_tenant_model = "testapp.Tenant" class Meta(AbstractLOVSelection.Meta): verbose_name = "Tenant Crop Selection" diff --git a/tests/testapp/views.py b/tests/testapp/views.py index 7d754c4..8c112a9 100644 --- a/tests/testapp/views.py +++ b/tests/testapp/views.py @@ -25,12 +25,12 @@ def lov_crop_value_create_view(request): template = "testapp/create_value.html" context = {} - # However you specify the current entity/tenant for the User submitting this form. + # However you specify the current tenant associated with the User submitting this form. # This is only an example. tenant = request.user.owned_tenants.first() - # Here we provide the User's entity, which the form will use to determine the available Values - form = TenantCropValueCreateForm(request.POST or None, lov_entity=tenant) + # Here we provide the User's tenant, which the form will use to determine the available Values + form = TenantCropValueCreateForm(request.POST or None, lov_tenant=tenant) if request.method == "POST": if form.is_valid(): @@ -39,7 +39,7 @@ def lov_crop_value_create_view(request): context["form"] = form # Provide the list of existing LOV Values for this Tenant - context["existing_values"] = TenantCropLOVValue.objects.for_entity(tenant) + context["existing_values"] = TenantCropLOVValue.objects.for_tenant(tenant) return TemplateResponse(request, template, context) @@ -53,18 +53,18 @@ def lov_tenant_crop_selection_view(request): template = "testapp/select_values.html" context = {} - # However you specify the current entity/tenant for the User submitting this form. + # However you specify the current tenant associated with the User submitting this form. # This is only an example. tenant = request.user.owned_tenants.first() - # Here we provide the entity - form = TenantCropValueSelectionForm(request.POST or None, lov_entity=tenant) + # Here we provide the tenant + form = TenantCropValueSelectionForm(request.POST or None, lov_tenant=tenant) if request.method == "POST": if form.is_valid(): form.save() # Update form's contents to ensure mandatory items are selected - form = TenantCropValueSelectionForm(None, lov_entity=tenant) + form = TenantCropValueSelectionForm(None, lov_tenant=tenant) context["form"] = form @@ -80,7 +80,7 @@ def lov_user_crop_selection_view(request): template = "testapp/select_values.html" context = {} - # However you specify the current entity/tenant for the User submitting this form. + # However you specify the current tenant associated with the User submitting this form. # This is only an example. tenant = request.user.tenants.first() @@ -89,7 +89,7 @@ def lov_user_crop_selection_view(request): tenant=tenant, ) - # Here we provide the entity + # Here we provide the tenant form = UserCropSelectionForm(request.POST or None, instance=obj) if request.method == "POST": if form.is_valid():