├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE.md ├── README.md ├── consistency_model ├── __init__.py ├── admin.py ├── management │ └── commands │ │ ├── consistency_model_check.py │ │ └── consistency_model_monitoring.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── settings.py └── tools.py ├── requirements ├── packaging.txt └── testing.txt ├── runtests.py ├── setup.cfg ├── setup.py ├── tests ├── conftest.py ├── custom_consistency │ ├── __init__.py │ └── models.py ├── models.py ├── subapp │ ├── __init__.py │ └── models.py ├── test_base.py ├── test_commands.py ├── test_custom_consistency.py └── test_generators.py ├── title-600.png ├── title-800.png └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.db 3 | *~ 4 | .* 5 | *.py.bak 6 | 7 | 8 | /site/ 9 | /htmlcov/ 10 | /coverage/ 11 | /build/ 12 | /dist/ 13 | /*.egg-info/ 14 | /env/ 15 | MANIFEST 16 | coverage.* 17 | 18 | !.github 19 | !.gitignore 20 | !.pre-commit-config.yaml 21 | local_*.sh 22 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/mirrors-mypy 3 | rev: v0.930 4 | hooks: 5 | - id: mypy 6 | - repo: https://github.com/psf/black 7 | rev: stable 8 | hooks: 9 | - id: black 10 | language_version: python3.6 -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | Copyright © 2011-present, [Encode OSS Ltd](https://www.encode.io/). 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 21 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://stand-with-ukraine.pp.ua) 2 | 3 | [![PyPI version fury.io](https://badge.fury.io/py/django-consistency-model.svg)](https://pypi.python.org/pypi/django-consistency-model/) 4 | [![PyPI pyversions](https://img.shields.io/pypi/pyversions/django-consistency-model.svg)](https://pypi.python.org/pypi/django-consistency-model/) 5 | [![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-consistency-model)](https://pypi.python.org/pypi/django-consistency-model/) 6 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 7 | 8 | # Django Consistency Model 9 | 10 | DCM is a set of tools that helps you to keep your data in your Django Models consistent. 11 | 12 | ![Django Consistency Model](https://github.com/occipital/django-consistency-model/blob/master/title-800.png) 13 | 14 | ## Motivation 15 | 16 | * You have a lot of legacy and inconsistent data in your project and you need to clean it out 17 | * You want to monitor the broken data 18 | * You are looking for a very simple solution. 19 | 20 | ## Quick Start 21 | 22 | Install the package: 23 | 24 | ```bash 25 | pip install django-consistency-model 26 | ``` 27 | 28 | Add new app into `INSTALLED_APPS`: 29 | 30 | ```python 31 | INSTALLED_APPS = ( 32 | # ... 33 | "consistency_model", 34 | ) 35 | ``` 36 | 37 | Add your first validator using decorator consistency_validator: 38 | 39 | ```python 40 | from decimal import Decimal 41 | from django.db import models 42 | from consistency_model import consistency_validator 43 | 44 | class Order(models.Model): 45 | total = models.DecimalField( 46 | default=Decimal("0.00"), decimal_places=2, max_digits=10 47 | ) 48 | refund = models.DecimalField( 49 | default=Decimal("0.00"), decimal_places=2, max_digits=10 50 | ) 51 | revenue = models.DecimalField( 52 | default=Decimal("0.00"), decimal_places=2, max_digits=10 53 | ) 54 | 55 | @consistency_validator 56 | def validate_revenue(self): 57 | assert self.revenue == self.total - self.refund, "revenue = total - refund" 58 | ``` 59 | 60 | Run command to check validators: 61 | 62 | ```bash 63 | ./manage.py consistency_model_check 64 | ``` 65 | 66 | ## What if I need to check more than one condition in one validator 67 | 68 | The first thing you may think of is using more than one validator, and it is common to have more than one validator (for example, one validator per field). 69 | 70 | Sometimes, you want to check more than one aspect in one validator or have a complex calculation you don't want to do for every validator. 71 | 72 | For those cases, you may want to use function `consistency_error`. It shows the system an error without raising an exception, so one validator can generate more than one error. 73 | 74 | ```python 75 | from decimal import Decimal 76 | 77 | from django.db import models 78 | 79 | from consistency_model import consistency_validator, consistency_error 80 | 81 | 82 | class Order(models.Model): 83 | total = models.DecimalField( 84 | default=Decimal("0.00"), decimal_places=2, max_digits=10 85 | ) 86 | refund = models.DecimalField( 87 | default=Decimal("0.00"), decimal_places=2, max_digits=10 88 | ) 89 | revenue = models.DecimalField( 90 | default=Decimal("0.00"), decimal_places=2, max_digits=10 91 | ) 92 | 93 | @consistency_validator 94 | def validate_total(self): 95 | assert self.total >= 0, "can't be negative" 96 | 97 | @consistency_validator 98 | def validate_revenue(self): 99 | if self.revenue < 0: 100 | consistency_error("can't be negative", "negative") 101 | 102 | if self.revenue != self.total - self.refund: 103 | consistency_error("revenue = total - refund", "formula") 104 | ``` 105 | 106 | As you can see, one validator (`validate_revenue`) checks two factors of the field revenue. 107 | 108 | The function `consistency_error` has two arguments - message and name(optional). The name is a unique value for the validator and will be used in monitoring. 109 | 110 | ## I don't want to check all of the data, but only one model instead. 111 | 112 | When you add a new validator, you don't want to check all the data. You want to test only one validator instead. 113 | 114 | Argument `--filter` can help you with that 115 | 116 | ```bash 117 | ./manage.py consistency_model_check --filter storeapp.Order.validate_revenue 118 | ``` 119 | 120 | Check only one model 121 | 122 | ```bash 123 | ./manage.py consistency_model_check --filter storeapp.Order 124 | ``` 125 | 126 | Check the model but excluding one validator. Argument `--exclude` excludes validator from validation circle. 127 | 128 | ```bash 129 | ./manage.py consistency_model_check --filter storeapp.Order --exclude storeapp.Order.validate_revenue 130 | ``` 131 | 132 | Check only one object. Using `--object` you can check a specific object in db. 133 | 134 | ```bash 135 | ./manage.py consistency_model_check --object storeapp.Order.56 136 | ``` 137 | 138 | You can combine `--object` with `--filter` and `--exclude` as well. 139 | 140 | ## I want to monitor my DB on consistency constantly. 141 | 142 | The idea of consistency monitoring is very simple. You add the command `consistency_model_monitoring` to your cron. The command checks DB and saves all of the errors in `ConsistencyFail`. Nothing is too complicated. 143 | 144 | As the result, you can see all of the inconsistency errors in admin panel. Or you can connect `pre_save` signal to `consistency_model.ConsistencyFail` and send an email notification in case of any new inconsistency. 145 | 146 | ## Monitoring configuration. 147 | 148 | A typical situation is when you don't want to monitor all the data but only recently added/updated data. By default, the system checks only 10k recent IDs, but you have a lot of flexibility to change that with function `register_consistency`. 149 | 150 | Let's take a look of how one can be used. 151 | 152 | For model `Order` you want to check only 10 last ids. 153 | 154 | ```python 155 | from consistency_model import register_consistency 156 | register_consistency(Order, limit=10) 157 | ``` 158 | 159 | `register_consistency` can be used as class decorator 160 | 161 | ```python 162 | from consistency_model import register_consistency 163 | 164 | @register_consistency(limit=10) 165 | class Order(models.Model): 166 | # ... 167 | ``` 168 | 169 | you can order not by id, but `modified_on` field 170 | 171 | ```python 172 | from consistency_model import register_consistency 173 | register_consistency(Order, order_by='modified_on') 174 | ``` 175 | 176 | you can use a consistency checker class to overwrite the whole query for consistency check 177 | 178 | ```python 179 | from django.db import models 180 | 181 | from consistency_model import register_consistency, ConsistencyChecker 182 | 183 | 184 | class Order(models.Model): 185 | is_legacy = models.BooleanField(dafult=False) 186 | # ... 187 | 188 | 189 | class OrderConsistencyChecker(ConsistencyChecker): 190 | limit = None # I don't want to have any limitation 191 | order_by = 'modified_on' 192 | 193 | def get_queryset(self): 194 | return self.cls.objects.filter(is_legacy=False) 195 | 196 | register_consistency(Order, OrderConsistencyChecker) 197 | ``` 198 | 199 | Again, it is possible to be used as class decorator for any on both classes. 200 | 201 | For Model: 202 | 203 | ```python 204 | from django.db import models 205 | 206 | from consistency_model import register_consistency, ConsistencyChecker 207 | 208 | 209 | class OrderConsistencyChecker(ConsistencyChecker): 210 | # ... 211 | 212 | @register_consistency(OrderConsistencyChecker) 213 | class Order(models.Model): 214 | is_legacy = models.BooleanField(dafult=False) 215 | # ... 216 | 217 | ``` 218 | 219 | For Checker: 220 | 221 | ```python 222 | from django.db import models 223 | 224 | from consistency_model import register_consistency, ConsistencyChecker 225 | 226 | 227 | class Order(models.Model): 228 | is_legacy = models.BooleanField(dafult=False) 229 | # ... 230 | 231 | 232 | @register_consistency(Order) 233 | class OrderConsistencyChecker(ConsistencyChecker): 234 | # ... 235 | 236 | ``` 237 | 238 | ## Settings 239 | 240 | `CONSISTENCY_DEFAULT_MONITORING_LIMIT` (default: `10_000`) - default limit rows per model 241 | 242 | `CONSISTENCY_DEFAULT_ORDER_BY` (default: `"-id"`) - defaul model ordering for monitoring 243 | 244 | `CONSISTENCY_DEFAULT_CHECKER` (default: `"consistency_model.tools.ConsistencyChecker"`) - default class for consistency monitoring 245 | 246 | If you have `pid` package installed, one will be used for monitoring command to prevent running multiple monitpring process. The following settings will be used for monitoring 247 | 248 | `CONSISTENCY_PID_MONITORING_FILENAME` (default: `"consistency_monitoring"`) 249 | 250 | `CONSISTENCY_PID_MONITORING_FOLDER` (default: `None`) - folder the pid file is stored. `tempfile.gettempdir()` is using if it is `None` 251 | 252 | ## Contributing 253 | 254 | We’re looking to grow the project and get more contributors. Feel free to submit bug reports, pull requests, and feature requests. 255 | 256 | Tools: 257 | 258 | * [tox](https://tox.wiki/en/latest/) 259 | * [pre-commit](https://pre-commit.com/) 260 | * [black](https://github.com/psf/black) 261 | -------------------------------------------------------------------------------- /consistency_model/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = "Django Consistency Model" 2 | __version__ = "0.1.1" 3 | __author__ = "Alex Liabakh" 4 | 5 | from .tools import ( 6 | ConsistencyChecker, 7 | register_consistency, 8 | consistency_error, 9 | consistency_validator, 10 | gen_validators_by_model, 11 | gen_validators_by_app, 12 | gen_validators_by_func, 13 | gen_validators, 14 | gen_consistency_errors, 15 | monitoring_iteration, 16 | ) 17 | -------------------------------------------------------------------------------- /consistency_model/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import ConsistencyFail 4 | 5 | 6 | class ConsistencyFailAdmin(admin.ModelAdmin): 7 | list_display = [ 8 | "created_on", 9 | "content_object", 10 | "validator_name", 11 | "resolved", 12 | "message", 13 | ] 14 | list_filter = ["resolved"] 15 | search_fields = ["validator_name"] 16 | 17 | 18 | admin.site.register(ConsistencyFail, ConsistencyFailAdmin) 19 | -------------------------------------------------------------------------------- /consistency_model/management/commands/consistency_model_check.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError 2 | from django.apps import apps 3 | 4 | from consistency_model import gen_consistency_errors, gen_validators 5 | 6 | 7 | class Command(BaseCommand): 8 | help = "Checks consistency of your data." 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument("--filter", type=str, nargs="*") 12 | parser.add_argument("--exclude", type=str, nargs="*") 13 | parser.add_argument("--object", type=str, nargs="?") 14 | 15 | def handle(self, *args, **options): 16 | validators = gen_validators(options["filter"]) if options["filter"] else None 17 | exclude_validators = ( 18 | gen_validators(options["exclude"]) if options["exclude"] else None 19 | ) 20 | if options.get("object"): 21 | object_str = options.get("object") 22 | if object_str.count(".") != 2: 23 | raise CommandError( 24 | "Wrong object value. The format should be app.model.pk" 25 | ) 26 | app, model, pk = object_str.split(".") 27 | objects = [ 28 | apps.get_model(app_label=app, model_name=model).objects.get(pk=pk) 29 | ] 30 | else: 31 | objects = None 32 | 33 | stats = {} 34 | for v_name, obj, message in gen_consistency_errors( 35 | validators, 36 | exclude_validators=exclude_validators, 37 | objects=objects, 38 | stats=stats, 39 | ): 40 | print("{} [{}] {}".format(v_name, obj.pk, message), file=self.stderr) 41 | 42 | print("\nStats:", file=self.stdout) 43 | print( 44 | "\n".join( 45 | [ 46 | "{}:{}".format(*a) 47 | for a in sorted(stats.items(), key=lambda a: a[1], reverse=True) 48 | ] 49 | ), 50 | file=self.stdout, 51 | ) 52 | -------------------------------------------------------------------------------- /consistency_model/management/commands/consistency_model_monitoring.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | 3 | try: 4 | from pid.decorator import pidfile 5 | except ImportError: 6 | 7 | def pidfile(*args, **kwargs): 8 | def _(f): 9 | return f 10 | 11 | return _ 12 | 13 | 14 | from django.core.management.base import BaseCommand 15 | 16 | from consistency_model import ( 17 | gen_validators, 18 | monitoring_iteration, 19 | ) 20 | from consistency_model.settings import PID_MONITORING_FILENAME, PID_MONITORING_FOLDER 21 | 22 | 23 | class Command(BaseCommand): 24 | help = "Cron Command for monotoring models consistency. All of the inconsistencies will be saved as ConsistencyFail object" 25 | 26 | def add_arguments(self, parser): 27 | parser.add_argument("--filter", type=str, nargs="*") 28 | parser.add_argument("--exclude", type=str, nargs="*") 29 | 30 | @pidfile( 31 | piddir=( 32 | PID_MONITORING_FOLDER if PID_MONITORING_FOLDER else tempfile.gettempdir() 33 | ), 34 | pidname=PID_MONITORING_FILENAME, 35 | ) 36 | def handle(self, *args, **options): 37 | 38 | validators = gen_validators(options["filter"]) if options["filter"] else None 39 | exclude_validators = ( 40 | gen_validators(options["exclude"]) if options["exclude"] else None 41 | ) 42 | monitoring_iteration(validators, exclude_validators) 43 | -------------------------------------------------------------------------------- /consistency_model/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.20 on 2021-06-21 09:20 2 | import django.db.models.deletion 3 | from django.db import migrations, models 4 | from django.db.models.indexes import Index 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ("contenttypes", "0002_remove_content_type_name"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="ConsistencyFail", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("created_on", models.DateTimeField(auto_now_add=True)), 29 | ("updated_on", models.DateTimeField(auto_now_add=True)), 30 | ("resolved_on", models.DateTimeField(null=True)), 31 | ("validator_name", models.CharField(max_length=500)), 32 | ("object_id", models.PositiveIntegerField()), 33 | ("message", models.TextField()), 34 | ("resolved", models.BooleanField(db_index=True, default=False)), 35 | ( 36 | "content_type", 37 | models.ForeignKey( 38 | on_delete=django.db.models.deletion.CASCADE, 39 | to="contenttypes.ContentType", 40 | ), 41 | ), 42 | ], 43 | ), 44 | migrations.AddIndex( 45 | "ConsistencyFail", 46 | Index( 47 | fields=["validator_name", "object_id", "resolved_on"], 48 | name="consistency_fail_vor", 49 | ), 50 | ), 51 | ] 52 | -------------------------------------------------------------------------------- /consistency_model/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-consistency-model/db835c02ee7c5e6f6d9836597eb154237cae7d9d/consistency_model/migrations/__init__.py -------------------------------------------------------------------------------- /consistency_model/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.contenttypes.fields import GenericForeignKey 3 | from django.contrib.contenttypes.models import ContentType 4 | from django.utils import timezone 5 | 6 | 7 | class ConsistencyFail(models.Model): 8 | """ 9 | The result of consistency_model_monitoring command 10 | """ 11 | 12 | created_on = models.DateTimeField(auto_now_add=True) 13 | updated_on = models.DateTimeField(auto_now_add=True) 14 | resolved_on = models.DateTimeField(null=True) 15 | validator_name = models.CharField(max_length=500) 16 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 17 | object_id = models.PositiveIntegerField() 18 | content_object = GenericForeignKey("content_type", "object_id") 19 | message = models.TextField() 20 | resolved = models.BooleanField(default=False, db_index=True) 21 | 22 | class Meta: 23 | indexes = [ 24 | models.Index( 25 | fields=["validator_name", "object_id", "resolved_on"], 26 | name="consistency_fail_vor", 27 | ) 28 | ] 29 | 30 | def resolve(self): 31 | assert not self.resolved 32 | self.resolved = True 33 | self.resolved_on = timezone.now() 34 | self.save() 35 | 36 | def update_message(self, msg): 37 | assert not self.resolved 38 | str_msg = str(msg) 39 | if self.message == str_msg: 40 | return 41 | self.message = str_msg 42 | self.updated_on = timezone.now() 43 | self.save() 44 | 45 | def __str__(self) -> str: 46 | return f"{self.validator_name}: {self.message}" + ( 47 | " - RESOLVED" if self.resolved else "" 48 | ) 49 | -------------------------------------------------------------------------------- /consistency_model/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | DEFAULT_MONITORING_LIMIT = getattr( 4 | settings, "CONSISTENCY_DEFAULT_MONITORING_LIMIT", 10_000 5 | ) 6 | DEFAULT_ORDER_BY = getattr(settings, "CONSISTENCY_DEFAULT_ORDER_BY", "-id") 7 | DEFAULT_CHECKER = getattr( 8 | settings, 9 | "CONSISTENCY_DEFAULT_CHECKER", 10 | "consistency_model.tools.ConsistencyChecker", 11 | ) 12 | 13 | PID_MONITORING_FILENAME = getattr( 14 | settings, "CONSISTENCY_PID_MONITORING_FILENAME", "consistency_monitoring" 15 | ) 16 | PID_MONITORING_FOLDER = getattr(settings, "CONSISTENCY_PID_MONITORING_FOLDER", None) 17 | -------------------------------------------------------------------------------- /consistency_model/tools.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from contextlib import contextmanager 3 | from typing import ( 4 | Any, 5 | Callable, 6 | Dict, 7 | Tuple, 8 | Union, 9 | Generator, 10 | Iterable, 11 | Optional, 12 | List, 13 | ) 14 | 15 | from django.apps import apps 16 | from django.db import models 17 | from django.db.models.base import Model 18 | from django.db.models.query import QuerySet 19 | from django.utils.module_loading import import_string 20 | 21 | from .settings import DEFAULT_MONITORING_LIMIT, DEFAULT_ORDER_BY, DEFAULT_CHECKER 22 | 23 | TValidators = Generator[ 24 | Tuple[Tuple[str, str], List[Callable[[Any], Optional[bool]]]], None, None 25 | ] 26 | 27 | # all of the validators available in the project. 28 | # (app: str, model: str) => [func, func, ...] 29 | VALIDATORS: Dict[Tuple[str, str], List[Callable[[Any], Optional[bool]]]] = defaultdict( 30 | list 31 | ) 32 | 33 | # all checkers in the system. 34 | # Model => ConsistencyChecker(Model) 35 | CONSISTENCY_CHECKERS = {} 36 | 37 | 38 | class ConsistencyChecker: 39 | """ 40 | the class explains how specific model should be monitored. 41 | """ 42 | 43 | limit = DEFAULT_MONITORING_LIMIT 44 | order_by = DEFAULT_ORDER_BY 45 | queryset = None 46 | 47 | def __init__(self, cls, **kwargs) -> None: 48 | self.cls = cls 49 | for k, v in kwargs.items(): 50 | setattr(self, k, v) 51 | 52 | def get_queryset(self): 53 | if self.queryset is not None: 54 | return self.queryset 55 | 56 | order_by = self.order_by 57 | if isinstance(order_by, str): 58 | order_by = (order_by,) 59 | 60 | return self.cls.objects.all().order_by(*order_by) 61 | 62 | def get_objects(self): 63 | queryset = self.get_queryset() 64 | 65 | if self.limit is None: 66 | return queryset 67 | 68 | return queryset[: self.limit] 69 | 70 | 71 | def _register_consistency(cls, cls_checker=None, **kwargs): 72 | if cls_checker is None: 73 | cls_checker = import_string(DEFAULT_CHECKER) 74 | new_checker = cls_checker(cls, **kwargs) 75 | 76 | assert issubclass(cls, Model) 77 | assert isinstance(new_checker, ConsistencyChecker) 78 | 79 | CONSISTENCY_CHECKERS[cls] = new_checker 80 | return new_checker 81 | 82 | 83 | def register_consistency(*args, **kwargs): 84 | """ 85 | assign ConsistencyChecker to the django Model. 86 | 87 | can be used asa decorator or a function. 88 | """ 89 | if not args or not issubclass(args[0], models.Model): 90 | 91 | def _(cls): 92 | _register_consistency(cls, *args, **kwargs) 93 | return cls 94 | 95 | return _ 96 | 97 | def _(cls_checker): 98 | _register_consistency(*args, cls_checker=cls_checker, **kwargs) 99 | return cls_checker 100 | 101 | _register_consistency(*args, **kwargs) 102 | return _ 103 | 104 | 105 | def get_register_consistency(cls): 106 | 107 | if cls in CONSISTENCY_CHECKERS: 108 | return CONSISTENCY_CHECKERS[cls] 109 | return _register_consistency(cls) 110 | 111 | 112 | _ERRORS = [] 113 | 114 | 115 | def consistency_error(message: Any = "", name: Optional[str] = None) -> None: 116 | """ 117 | for cases when validator can raise more than one error use this function. 118 | 119 | name is a unique value for the function where consistency_error is called. 120 | 121 | the name is using for consistency_model_monitoring command 122 | """ 123 | assert name is None or "." not in name, "(dot) can't be part of the name" 124 | global _ERRORS 125 | _ERRORS.append((message, name)) 126 | 127 | 128 | @contextmanager 129 | def trace_consistency_errors(): 130 | """ 131 | context manager for catching consistency_error calls 132 | """ 133 | global _ERRORS 134 | prev_errors = _ERRORS 135 | errors = _ERRORS = [] 136 | try: 137 | yield errors 138 | finally: 139 | _ERRORS = prev_errors 140 | 141 | 142 | def consistency_validator(func): 143 | """ 144 | decorator for model's method that register that function as consistency validator 145 | """ 146 | model = func.__qualname__.split(".")[0] 147 | app = func.__module__.split(".")[-2] 148 | 149 | VALIDATORS[(app, model)].append(func) 150 | return func 151 | 152 | 153 | def gen_validators_by_model(names: Union[Iterable[str], str]) -> TValidators: 154 | """ 155 | Generator of validators by model name(s). 156 | 157 | Model name is string in format "app.Model". 158 | 159 | @names - can be a string (one model name) or iterable of strings where each item is a model name 160 | """ 161 | if isinstance(names, str): 162 | names = [names] 163 | _names = [tuple(n.split(".")) for n in names] 164 | for name, list_funcs in VALIDATORS.items(): 165 | if name in _names: 166 | yield name, list_funcs 167 | 168 | 169 | def gen_validators_by_app(names: Union[Iterable[str], str]) -> TValidators: 170 | """ 171 | Generator of validators by app name(s). 172 | 173 | @names - can be a string (one app name) or iterable of strings where each item is a app name 174 | """ 175 | if isinstance(names, str): 176 | names = [names] 177 | names = set(names) 178 | for name, list_funcs in VALIDATORS.items(): 179 | if name[0] in names: 180 | yield name, list_funcs 181 | 182 | 183 | def gen_validators_by_func(names: Union[Iterable[str], str]) -> TValidators: 184 | """ 185 | Generator of validators by function(validator) name(s). 186 | 187 | Model name is string in format "app.Model". 188 | 189 | @names - can be a string (one model name) or iterable of strings where each item is a model name 190 | """ 191 | if isinstance(names, str): 192 | names = [names] 193 | _names = [tuple(n.split(".")) for n in names] 194 | 195 | funcs = defaultdict(set) 196 | for name in _names: 197 | funcs[name[:2]].add(name[2]) 198 | 199 | for name, list_funcs in VALIDATORS.items(): 200 | if name in funcs.keys(): 201 | filtered_list_funcs = [f for f in list_funcs if f.__name__ in funcs[name]] 202 | if filtered_list_funcs: 203 | yield name, filtered_list_funcs 204 | 205 | 206 | def gen_validators(names: Union[Iterable[str], str]) -> TValidators: 207 | """ 208 | Generator of validators by name(s). 209 | 210 | The name can be app name, model name or func name. 211 | 212 | @names - can be a string (one name) or iterable of strings where each item is a name 213 | """ 214 | if isinstance(names, str): 215 | names = [names] 216 | 217 | for name in names: 218 | count_dots = name.count(".") 219 | if not count_dots: 220 | yield from gen_validators_by_app(name) 221 | elif count_dots == 1: 222 | yield from gen_validators_by_model(name) 223 | elif count_dots == 2 or count_dots == 3: 224 | yield from gen_validators_by_func(name) 225 | else: 226 | raise ValueError(f'Unknow format for name "{name}"') 227 | 228 | 229 | def gen_consistency_errors( 230 | validators=None, 231 | objects=None, 232 | create_validators=None, 233 | exclude_validators=None, 234 | create_exclude_validators=None, 235 | stats=None, 236 | ) -> Generator[Tuple[str, Any, Any], None, None]: 237 | """ 238 | generates inconsistency errors based on project validators. 239 | 240 | @validators - filter validators you want to use for model validation. 241 | 242 | Can be set as: 243 | * generator from `gen_validators_*` 244 | * iterable of ((app:str, model:str), [func, func, ...]) 245 | * dict of (app: str, model: str) => [func, func, ...] 246 | * None (default) - all project validators 247 | 248 | @objects - objects we want to validate. All objects should be the same model 249 | 250 | Can be set as: 251 | * list/tuple of the objects 252 | * queryset 253 | 254 | @create_validators - (can't be set when validators are set) 255 | Generate validators using function gen_validators 256 | 257 | @exclude_validators the same as @validators, but works in oposite way. 258 | It describes which validators we don't want to use for validation. 259 | None means nothing is excluded. 260 | 261 | @create_exclude_validators the same @create_validators but for @exclude_validators 262 | 263 | @stats - link to an empty dict for collecting validation stats. 264 | Is using for commands. 265 | """ 266 | 267 | assert not ( 268 | validators is not None and create_validators is not None 269 | ), "can not set both validators and create_validators" 270 | 271 | if objects is None: 272 | objects_cls = None 273 | elif isinstance(objects, QuerySet): 274 | objects_cls = objects.model 275 | else: 276 | objects_cls = objects[0]._meta.model 277 | 278 | if create_validators is not None: 279 | validators = gen_validators(create_validators) 280 | 281 | if validators is None: 282 | validators = VALIDATORS 283 | 284 | if isinstance(validators, dict): 285 | validators = validators.items() 286 | 287 | if create_exclude_validators is not None: 288 | exclude_validators = gen_validators(create_exclude_validators) 289 | 290 | if exclude_validators is None: 291 | exclude_validators = {} 292 | 293 | if not isinstance(exclude_validators, dict): 294 | exclude_validators = dict(exclude_validators) 295 | 296 | for name, list_funcs in validators: 297 | if name in exclude_validators: 298 | exclude_funcs = exclude_validators[name] 299 | list_funcs = list(set(list_funcs).difference(set(exclude_funcs))) 300 | 301 | if not list_funcs: 302 | continue 303 | 304 | app_label, model = name 305 | cls_model = apps.get_model(app_label=app_label, model_name=model) 306 | 307 | if objects_cls is not None and cls_model != objects_cls: 308 | continue 309 | 310 | if objects_cls: 311 | objects_all = objects 312 | else: 313 | objects_all = get_register_consistency(cls_model).get_objects() 314 | 315 | for obj in objects_all: 316 | for func in list_funcs: 317 | with trace_consistency_errors() as errors: 318 | validator_name = "{}.{}.{}".format(app_label, model, func.__name__) 319 | check_stats_k = "check." + validator_name 320 | count_stats = True 321 | try: 322 | count_stats = not func(obj) 323 | except Exception as e: 324 | if stats is not None: 325 | stats_k = "ERR." + validator_name 326 | stats[stats_k] = stats.get(stats_k, 0) + 1 327 | stats[check_stats_k] = stats.get(check_stats_k, 0) + 1 328 | yield ( 329 | validator_name, 330 | obj, 331 | "{}:{}".format(e.__class__, e), 332 | ) 333 | finally: 334 | if count_stats and stats is not None: 335 | stats[check_stats_k] = stats.get(check_stats_k, 0) + 1 336 | for (message, name) in errors: 337 | if stats is not None: 338 | stats_k = "ERR." + validator_name 339 | stats[stats_k] = stats.get(stats_k, 0) + 1 340 | 341 | validator_name_message = validator_name 342 | if name: 343 | validator_name_message += "." + name 344 | yield (validator_name_message, obj, message) 345 | 346 | 347 | def monitoring_iteration(validators=None, exclude_validators=None) -> None: 348 | """ 349 | One iteration of monitoring that checks consistency using @validators and @exclude_validators 350 | and saves the result into ConsistencyFail model 351 | """ 352 | from .models import ConsistencyFail 353 | 354 | fail_ids = set() 355 | 356 | for validator_name, obj, message in gen_consistency_errors( 357 | validators, 358 | exclude_validators=exclude_validators, 359 | ): 360 | fail = ConsistencyFail.objects.filter( 361 | resolved=False, 362 | validator_name=validator_name, 363 | object_id=obj.id, 364 | ).first() 365 | if fail: 366 | fail.update_message(message) 367 | else: 368 | fail = ConsistencyFail.objects.create( 369 | validator_name=validator_name, 370 | content_object=obj, 371 | message=str(message), 372 | ) 373 | fail_ids.add(fail.id) 374 | 375 | for fail in ConsistencyFail.objects.filter(resolved=False): 376 | if fail.id in fail_ids: 377 | continue 378 | 379 | func_validators = gen_validators_by_func(fail.validator_name) 380 | for validator_name, obj, message in gen_consistency_errors( 381 | func_validators, objects=[fail.content_object] 382 | ): 383 | if fail.validator_name == validator_name: 384 | fail.update_message(message) 385 | break 386 | else: 387 | fail.resolve() 388 | -------------------------------------------------------------------------------- /requirements/packaging.txt: -------------------------------------------------------------------------------- 1 | # Wheel for PyPI installs. 2 | wheel>=0.35.1,<0.36 3 | 4 | # Twine for secured PyPI uploads. 5 | twine>=3.2.0,<3.3 6 | 7 | # Transifex client for managing translation resources. 8 | transifex-client>=0.13.12,<0.14 9 | -------------------------------------------------------------------------------- /requirements/testing.txt: -------------------------------------------------------------------------------- 1 | # Pytest for running the tests. 2 | pytest>=6.1,<7.0 3 | pytest-cov>=2.10.1,<3.0 4 | pytest-django>=4.1.0,<5.0 -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | import sys 3 | 4 | import pytest 5 | 6 | if __name__ == "__main__": 7 | if len(sys.argv) > 1: 8 | pytest_args = sys.argv[1:] 9 | else: 10 | pytest_args = [] 11 | 12 | sys.exit(pytest.main(["-vv", "-s"] + pytest_args)) 13 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import re 4 | import shutil 5 | import sys 6 | from io import open 7 | 8 | from setuptools import find_packages, setup 9 | 10 | CURRENT_PYTHON = sys.version_info[:2] 11 | REQUIRED_PYTHON = (3, 6) 12 | 13 | # This check and everything above must remain compatible with Python 2.7. 14 | if CURRENT_PYTHON < REQUIRED_PYTHON: 15 | sys.stderr.write( 16 | """ 17 | ========================== 18 | Unsupported Python version 19 | ========================== 20 | 21 | This version of the App requires Python {}.{}, but you're trying 22 | to install it on Python {}.{}. 23 | 24 | This may be because you are using a version of pip that doesn't 25 | understand the python_requires classifier. Make sure you 26 | have pip >= 9.0 and setuptools >= 24.2, then try again: 27 | 28 | $ python -m pip install --upgrade pip setuptools 29 | $ python -m pip install django-consistency-model 30 | 31 | This will install the latest version of the App which works on 32 | your version of Python. If you can't upgrade your pip (or Python), request 33 | an older version of the App: 34 | 35 | $ python -m pip install django-consistency-model 36 | """.format( 37 | *(REQUIRED_PYTHON + CURRENT_PYTHON) 38 | ) 39 | ) 40 | sys.exit(1) 41 | 42 | 43 | def read(f): 44 | return open(f, "r", encoding="utf-8").read() 45 | 46 | 47 | def get_version(package): 48 | """ 49 | Return package version as listed in `__version__` in `init.py`. 50 | """ 51 | init_py = open(os.path.join(package, "__init__.py")).read() 52 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) 53 | 54 | 55 | version = get_version("consistency_model") 56 | 57 | 58 | if sys.argv[-1] == "publish": 59 | if os.system("pip freeze | grep twine"): 60 | print("twine not installed.\nUse `pip install twine`.\nExiting.") 61 | sys.exit() 62 | os.system("python setup.py sdist bdist_wheel") 63 | if os.system("twine check dist/*"): 64 | print("twine check failed. Packages might be outdated.") 65 | print("Try using `pip install -U twine wheel`.\nExiting.") 66 | sys.exit() 67 | os.system("twine upload dist/*") 68 | print("You probably want to also tag the version now:") 69 | print(" git tag -a %s -m 'version %s'; git push --tags" % (version, version)) 70 | shutil.rmtree("dist") 71 | shutil.rmtree("build") 72 | shutil.rmtree("django_consistency_model.egg-info") 73 | sys.exit() 74 | 75 | 76 | setup( 77 | name="django-consistency-model", 78 | version=version, 79 | url="https://github.com/occipital/django-consistency-model", 80 | license="BSD", 81 | description="DCM is a set of tools that helps you to keep your data in your Django Models consistent.", 82 | long_description=read("README.md"), 83 | long_description_content_type="text/markdown", 84 | author="Alex Liabakh", 85 | author_email="alex.lyabakh@occipital.com", 86 | packages=[ 87 | "consistency_model", 88 | "consistency_model.management", 89 | "consistency_model.management.commands", 90 | "consistency_model.migrations", 91 | ], 92 | include_package_data=True, 93 | install_requires=[ 94 | "django>=2.2", 95 | ], 96 | python_requires=">=3.6", 97 | zip_safe=False, 98 | classifiers=[ 99 | "Development Status :: 5 - Production/Stable", 100 | "Environment :: Web Environment", 101 | "Framework :: Django", 102 | "Framework :: Django :: 2.2", 103 | "Framework :: Django :: 3.0", 104 | "Framework :: Django :: 3.1", 105 | "Framework :: Django :: 3.2", 106 | "Framework :: Django :: 4.0", 107 | "Intended Audience :: Developers", 108 | "License :: OSI Approved :: BSD License", 109 | "Operating System :: OS Independent", 110 | "Programming Language :: Python", 111 | "Programming Language :: Python :: 3", 112 | "Programming Language :: Python :: 3.6", 113 | "Programming Language :: Python :: 3.7", 114 | "Programming Language :: Python :: 3.8", 115 | "Programming Language :: Python :: 3.9", 116 | "Programming Language :: Python :: 3.10", 117 | "Programming Language :: Python :: 3 :: Only", 118 | "Topic :: Internet :: WWW/HTTP", 119 | ], 120 | ) 121 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import django 5 | 6 | 7 | def pytest_configure(config): 8 | from django.conf import settings 9 | 10 | # USE_L10N is deprecated, and will be removed in Django 5.0. 11 | use_l10n = {"USE_L10N": True} if django.VERSION < (4, 0) else {} 12 | settings.configure( 13 | DEBUG_PROPAGATE_EXCEPTIONS=True, 14 | DATABASES={ 15 | "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}, 16 | }, 17 | SITE_ID=1, 18 | SECRET_KEY="not very secret in tests", 19 | USE_I18N=True, 20 | STATIC_URL="/static/", 21 | ROOT_URLCONF="tests.urls", 22 | TEMPLATES=[ 23 | { 24 | "BACKEND": "django.template.backends.django.DjangoTemplates", 25 | "APP_DIRS": True, 26 | "OPTIONS": { 27 | "debug": True, # We want template errors to raise 28 | }, 29 | }, 30 | ], 31 | MIDDLEWARE=( 32 | "django.middleware.common.CommonMiddleware", 33 | "django.contrib.sessions.middleware.SessionMiddleware", 34 | "django.contrib.auth.middleware.AuthenticationMiddleware", 35 | "django.contrib.messages.middleware.MessageMiddleware", 36 | ), 37 | INSTALLED_APPS=( 38 | "django.contrib.admin", 39 | "django.contrib.auth", 40 | "django.contrib.contenttypes", 41 | "django.contrib.sessions", 42 | "django.contrib.sites", 43 | "django.contrib.staticfiles", 44 | "consistency_model", 45 | "tests", 46 | "tests.subapp", 47 | "tests.custom_consistency", 48 | ), 49 | PASSWORD_HASHERS=("django.contrib.auth.hashers.MD5PasswordHasher",), 50 | **use_l10n, 51 | ) 52 | -------------------------------------------------------------------------------- /tests/custom_consistency/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-consistency-model/db835c02ee7c5e6f6d9836597eb154237cae7d9d/tests/custom_consistency/__init__.py -------------------------------------------------------------------------------- /tests/custom_consistency/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | 4 | from consistency_model import ( 5 | consistency_validator, 6 | register_consistency, 7 | ConsistencyChecker, 8 | ) 9 | 10 | 11 | @register_consistency(limit=1, order_by="-created_on") 12 | class OrderWithLastCheck(models.Model): 13 | created_on = models.DateTimeField(default=timezone.now) 14 | name = models.CharField(max_length=100) 15 | total_items = models.IntegerField(default=0) 16 | 17 | @consistency_validator 18 | def validate_total_items(self): 19 | assert self.total_items >= 0, "can't be negative" 20 | 21 | 22 | class OrderWithSkipCheck(models.Model): 23 | created_on = models.DateTimeField(default=timezone.now) 24 | name = models.CharField(max_length=100) 25 | total_items = models.IntegerField(default=0) 26 | skip_consistency_check = models.BooleanField(default=False) 27 | 28 | @consistency_validator 29 | def validate_total_items(self): 30 | assert self.total_items >= 0, "can't be negative" 31 | 32 | 33 | @register_consistency(OrderWithSkipCheck) 34 | class OrderWithSkipCheckConsistency(ConsistencyChecker): 35 | def get_queryset(self): 36 | queryset = super().get_queryset() 37 | return queryset.exclude(skip_consistency_check=True) 38 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from django.db import models 4 | from django.utils import timezone 5 | 6 | from consistency_model import consistency_validator, consistency_error 7 | 8 | 9 | class Order(models.Model): 10 | """ 11 | 12 | total - the amount that initially was charged 13 | refund - the amount was later refunded 14 | revenue - the total revenue from that order 15 | 16 | """ 17 | 18 | created_on = models.DateTimeField(default=timezone.now) 19 | total = models.DecimalField( 20 | default=Decimal("0.00"), decimal_places=2, max_digits=10 21 | ) 22 | refund = models.DecimalField( 23 | default=Decimal("0.00"), decimal_places=2, max_digits=10 24 | ) 25 | revenue = models.DecimalField( 26 | default=Decimal("0.00"), decimal_places=2, max_digits=10 27 | ) 28 | 29 | @consistency_validator 30 | def validate_total(self): 31 | assert self.total >= 0, "can't be negative" 32 | 33 | @consistency_validator 34 | def validate_revenue(self): 35 | if self.revenue < 0: 36 | consistency_error("can't be negative", "negative") 37 | 38 | if self.revenue != self.total - self.refund: 39 | consistency_error("revenue = total - refund", "formula") 40 | 41 | 42 | class OrderItem(models.Model): 43 | order = models.ForeignKey(Order, on_delete=models.CASCADE) 44 | name = models.CharField(max_length=10) 45 | price = models.DecimalField(decimal_places=2, max_digits=10) 46 | 47 | @consistency_validator 48 | def validate_price(self): 49 | assert self.total >= 0, "can't be negative" 50 | -------------------------------------------------------------------------------- /tests/subapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-consistency-model/db835c02ee7c5e6f6d9836597eb154237cae7d9d/tests/subapp/__init__.py -------------------------------------------------------------------------------- /tests/subapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | 4 | from consistency_model import consistency_validator 5 | 6 | 7 | class Store(models.Model): 8 | """ 9 | All items in stock are in here 10 | 11 | """ 12 | 13 | created_on = models.DateTimeField(default=timezone.now) 14 | name = models.CharField(max_length=100) 15 | total_items = models.IntegerField(default=0) 16 | 17 | @consistency_validator 18 | def validate_total_items(self): 19 | assert self.total_items >= 0, "can't be negative" 20 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from tests.models import Order 3 | from tests.subapp.models import Store 4 | 5 | from consistency_model import ( 6 | gen_consistency_errors, 7 | gen_validators_by_model, 8 | gen_validators_by_func, 9 | ) 10 | 11 | 12 | class TestOrderValidators(TestCase): 13 | def assertEqualErrors(self, generated, tested): 14 | generated = [(g[0], g[1].pk, g[2]) for g in generated] 15 | self.assertEqual(generated, tested) 16 | 17 | def setUp(self) -> None: 18 | Order.objects.create(total=5, refund=0, revenue=5) 19 | Order.objects.create(total=5, refund=5, revenue=0) 20 | Order.objects.create(total=5, refund=2, revenue=3) 21 | 22 | def test_all_data_is_good(self): 23 | 24 | order_validators = gen_validators_by_model("tests.Order") 25 | errors = gen_consistency_errors(order_validators) 26 | assert not len(list(errors)) 27 | 28 | def test_one_fail_data(self): 29 | last_order = Order.objects.all().last() 30 | last_order.revenue = 100 31 | last_order.save() 32 | 33 | order_validators = gen_validators_by_model("tests.Order") 34 | self.assertEqualErrors( 35 | gen_consistency_errors(order_validators), 36 | [ 37 | ( 38 | "tests.Order.validate_revenue.formula", 39 | last_order.pk, 40 | "revenue = total - refund", 41 | ) 42 | ], 43 | ) 44 | 45 | def test_one_fail_data_but_excluded(self): 46 | last_order = Order.objects.all().last() 47 | last_order.revenue = 100 48 | last_order.save() 49 | 50 | order_validators = gen_validators_by_model("tests.Order") 51 | order_validate_revenue_validators = gen_validators_by_func( 52 | "tests.Order.validate_revenue" 53 | ) 54 | self.assertEqualErrors( 55 | gen_consistency_errors( 56 | order_validators, exclude_validators=order_validate_revenue_validators 57 | ), 58 | [], 59 | ) 60 | self.assertEqualErrors( 61 | gen_consistency_errors( 62 | order_validators, 63 | create_exclude_validators="tests.Order.validate_revenue", 64 | ), 65 | [], 66 | ) 67 | self.assertEqualErrors( 68 | gen_consistency_errors( 69 | order_validators, 70 | exclude_validators=dict(order_validate_revenue_validators), 71 | ), 72 | [], 73 | ) 74 | 75 | def test_test_fail_with_unhandled_exception(self): 76 | last_order = Order.objects.all().last() 77 | last_order.total = -100 78 | last_order.save() 79 | 80 | order_validators = gen_validators_by_func("tests.Order.validate_total") 81 | self.assertEqualErrors( 82 | gen_consistency_errors(order_validators), 83 | [ 84 | ( 85 | "tests.Order.validate_total", 86 | last_order.pk, 87 | ":can't be negative", 88 | ), 89 | ], 90 | ) 91 | 92 | def test_multiple_failed_data_in_one_validator(self): 93 | last_order = Order.objects.all().last() 94 | last_order.revenue = -100 95 | last_order.save() 96 | 97 | order_validators = gen_validators_by_func("tests.Order.validate_revenue") 98 | self.assertEqualErrors( 99 | gen_consistency_errors(order_validators), 100 | [ 101 | ( 102 | "tests.Order.validate_revenue.negative", 103 | last_order.pk, 104 | "can't be negative", 105 | ), 106 | ( 107 | "tests.Order.validate_revenue.formula", 108 | last_order.pk, 109 | "revenue = total - refund", 110 | ), 111 | ], 112 | ) 113 | 114 | def test_all_good_using_query(self): 115 | self.assertEqualErrors( 116 | gen_consistency_errors(objects=Order.objects.all()), 117 | [], 118 | ) 119 | 120 | def test_test_fail_using_query(self): 121 | last_order = Order.objects.all().last() 122 | last_order.total = -100 123 | last_order.save() 124 | 125 | self.assertEqualErrors( 126 | gen_consistency_errors(objects=Order.objects.all()), 127 | [ 128 | ( 129 | "tests.Order.validate_total", 130 | last_order.pk, 131 | ":can't be negative", 132 | ), 133 | ( 134 | "tests.Order.validate_revenue.formula", 135 | last_order.pk, 136 | "revenue = total - refund", 137 | ), 138 | ], 139 | ) 140 | 141 | self.assertEqualErrors( 142 | gen_consistency_errors(objects=Order.objects.exclude(id=last_order.pk)), 143 | [], 144 | ) 145 | 146 | def test_all_good_using_list(self): 147 | self.assertEqualErrors( 148 | gen_consistency_errors(objects=list(Order.objects.all())), 149 | [], 150 | ) 151 | 152 | def test_fail_using_list(self): 153 | last_order = Order.objects.all().last() 154 | last_order.total = -100 155 | last_order.save() 156 | 157 | self.assertEqualErrors( 158 | gen_consistency_errors(objects=[last_order]), 159 | [ 160 | ( 161 | "tests.Order.validate_total", 162 | last_order.pk, 163 | ":can't be negative", 164 | ), 165 | ( 166 | "tests.Order.validate_revenue.formula", 167 | last_order.pk, 168 | "revenue = total - refund", 169 | ), 170 | ], 171 | ) 172 | 173 | self.assertEqualErrors( 174 | gen_consistency_errors(objects=[Order.objects.all().first()]), 175 | [], 176 | ) 177 | 178 | 179 | class TestSubAppStorage(TestCase): 180 | def setUp(self) -> None: 181 | Store.objects.create(name="blocks", total_items=15) 182 | 183 | def test_all_data_is_good(self): 184 | 185 | order_validators = gen_validators_by_model("subapp.Store") 186 | errors = gen_consistency_errors(order_validators) 187 | assert not len(list(errors)) 188 | -------------------------------------------------------------------------------- /tests/test_commands.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | from django.test import TestCase 4 | from django.core.management import call_command 5 | 6 | from tests.models import Order 7 | from tests.subapp.models import Store 8 | from consistency_model.models import ConsistencyFail 9 | 10 | 11 | def call_command_stdout(*args): 12 | out = StringIO() 13 | err = StringIO() 14 | call_command(*args, stdout=out, stderr=err) 15 | return out.getvalue(), err.getvalue() 16 | 17 | 18 | class TestCheck(TestCase): 19 | def setUp(self) -> None: 20 | Order.objects.create(total=5, refund=0, revenue=5) 21 | Order.objects.create(total=5, refund=5, revenue=0) 22 | Order.objects.create(total=5, refund=2, revenue=3) 23 | 24 | Store.objects.create(name="blocks", total_items=15) 25 | Store.objects.create(name="tools", total_items=10) 26 | 27 | def test_all_objects(self): 28 | out, err = call_command_stdout("consistency_model_check") 29 | assert not err 30 | assert "tests.Order.validate_total" in out 31 | assert "tests.Order.validate_revenue" in out 32 | assert "subapp.Store.validate_total_items" in out 33 | 34 | def test_one_obj_fail_found(self): 35 | obj = Store.objects.get(name="tools") 36 | obj.total_items = -10 37 | obj.save() 38 | 39 | out, err = call_command_stdout("consistency_model_check") 40 | 41 | assert ( 42 | "subapp.Store.validate_total_items [{}] :can't be negative".format( 43 | obj.id 44 | ) 45 | in err 46 | ) 47 | 48 | def test_check_only_orders(self): 49 | obj = Store.objects.get(name="tools") 50 | obj.total_items = -10 51 | obj.save() 52 | 53 | out, err = call_command_stdout( 54 | "consistency_model_check", "--filter", "tests.Order" 55 | ) 56 | assert not err 57 | 58 | out, err = call_command_stdout( 59 | "consistency_model_check", "--filter", "tests.Order", "subapp" 60 | ) 61 | assert err 62 | 63 | def test_exclude_subapp(self): 64 | obj = Store.objects.get(name="tools") 65 | obj.total_items = -10 66 | obj.save() 67 | 68 | out, err = call_command_stdout("consistency_model_check", "--exclude", "subapp") 69 | assert not err 70 | 71 | out, err = call_command_stdout( 72 | "consistency_model_check", "--exclude", "tests.Order" 73 | ) 74 | assert err 75 | 76 | out, err = call_command_stdout( 77 | "consistency_model_check", 78 | "--exclude", 79 | "tests.Order", 80 | "subapp.Store.validate_total_items", 81 | ) 82 | assert not err 83 | 84 | def test_single_object(self): 85 | obj = Store.objects.get(name="tools") 86 | obj.total_items = -10 87 | obj.save() 88 | 89 | valid_obj = Store.objects.get(name="blocks") 90 | 91 | out, err = call_command_stdout( 92 | "consistency_model_check", 93 | "--object", 94 | "subapp.Store.{}".format(valid_obj.pk), 95 | ) 96 | assert not err 97 | 98 | out, err = call_command_stdout( 99 | "consistency_model_check", "--object", "subapp.Store.{}".format(obj.pk) 100 | ) 101 | assert err 102 | 103 | out, err = call_command_stdout( 104 | "consistency_model_check", 105 | "--exclude", 106 | "subapp.Store.validate_total_items", 107 | "--object", 108 | "subapp.Store.{}".format(obj.pk), 109 | ) 110 | assert not err 111 | 112 | 113 | class TestMonitoring(TestCase): 114 | def setUp(self) -> None: 115 | Order.objects.create(total=5, refund=0, revenue=5) 116 | Order.objects.create(total=5, refund=5, revenue=0) 117 | Order.objects.create(total=5, refund=2, revenue=3) 118 | 119 | Store.objects.create(name="blocks", total_items=15) 120 | Store.objects.create(name="tools", total_items=10) 121 | 122 | def assertUnresolvedFails(self, check): 123 | unresolved_checks = [ 124 | (f.validator_name, f.object_id) 125 | for f in ConsistencyFail.objects.filter(resolved=False) 126 | ] 127 | self.assertEqual(unresolved_checks, check) 128 | 129 | def test_all_data_is_good(self): 130 | call_command("consistency_model_monitoring") 131 | self.assertUnresolvedFails([]) 132 | 133 | def test_store_fail(self): 134 | obj1 = Store.objects.get(name="tools") 135 | obj1.total_items = -10 136 | obj1.save() 137 | 138 | call_command("consistency_model_monitoring") 139 | self.assertUnresolvedFails([("subapp.Store.validate_total_items", obj1.pk)]) 140 | 141 | call_command("consistency_model_monitoring") 142 | self.assertUnresolvedFails([("subapp.Store.validate_total_items", obj1.pk)]) 143 | 144 | obj2 = Store.objects.get(name="blocks") 145 | obj2.total_items = -15 146 | obj2.save() 147 | 148 | call_command("consistency_model_monitoring") 149 | self.assertUnresolvedFails( 150 | [ 151 | ("subapp.Store.validate_total_items", obj1.pk), 152 | ("subapp.Store.validate_total_items", obj2.pk), 153 | ] 154 | ) 155 | 156 | call_command("consistency_model_monitoring") 157 | self.assertUnresolvedFails( 158 | [ 159 | ("subapp.Store.validate_total_items", obj1.pk), 160 | ("subapp.Store.validate_total_items", obj2.pk), 161 | ] 162 | ) 163 | 164 | obj1.total_items = 5 165 | obj1.save() 166 | 167 | call_command("consistency_model_monitoring") 168 | self.assertUnresolvedFails( 169 | [ 170 | ("subapp.Store.validate_total_items", obj2.pk), 171 | ] 172 | ) 173 | 174 | call_command("consistency_model_monitoring") 175 | self.assertUnresolvedFails( 176 | [ 177 | ("subapp.Store.validate_total_items", obj2.pk), 178 | ] 179 | ) 180 | 181 | obj2.total_items = 5 182 | obj2.save() 183 | 184 | call_command("consistency_model_monitoring") 185 | self.assertUnresolvedFails([]) 186 | 187 | def test_order_fail_with_named_errors(self): 188 | obj1, obj2, obj3 = list(Order.objects.all()) 189 | 190 | obj1.revenue = -10 191 | obj1.save() 192 | 193 | call_command("consistency_model_monitoring") 194 | self.assertUnresolvedFails( 195 | [ 196 | ("tests.Order.validate_revenue.negative", obj1.pk), 197 | ("tests.Order.validate_revenue.formula", obj1.pk), 198 | ] 199 | ) 200 | 201 | call_command("consistency_model_monitoring") 202 | self.assertUnresolvedFails( 203 | [ 204 | ("tests.Order.validate_revenue.negative", obj1.pk), 205 | ("tests.Order.validate_revenue.formula", obj1.pk), 206 | ] 207 | ) 208 | 209 | obj2.revenue = -20 210 | obj2.save() 211 | 212 | call_command("consistency_model_monitoring") 213 | self.assertUnresolvedFails( 214 | [ 215 | ("tests.Order.validate_revenue.negative", obj1.pk), 216 | ("tests.Order.validate_revenue.formula", obj1.pk), 217 | ("tests.Order.validate_revenue.negative", obj2.pk), 218 | ("tests.Order.validate_revenue.formula", obj2.pk), 219 | ] 220 | ) 221 | 222 | obj1.revenue = 100 223 | obj1.save() 224 | 225 | call_command("consistency_model_monitoring") 226 | self.assertUnresolvedFails( 227 | [ 228 | ("tests.Order.validate_revenue.formula", obj1.pk), 229 | ("tests.Order.validate_revenue.negative", obj2.pk), 230 | ("tests.Order.validate_revenue.formula", obj2.pk), 231 | ] 232 | ) 233 | 234 | obj1.revenue = 5 235 | obj1.save() 236 | 237 | call_command("consistency_model_monitoring") 238 | self.assertUnresolvedFails( 239 | [ 240 | ("tests.Order.validate_revenue.negative", obj2.pk), 241 | ("tests.Order.validate_revenue.formula", obj2.pk), 242 | ] 243 | ) 244 | 245 | obj2.revenue = 0 246 | obj2.save() 247 | 248 | call_command("consistency_model_monitoring") 249 | self.assertUnresolvedFails([]) 250 | -------------------------------------------------------------------------------- /tests/test_custom_consistency.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from consistency_model import ( 4 | gen_consistency_errors, 5 | ) 6 | 7 | from tests.custom_consistency.models import OrderWithLastCheck, OrderWithSkipCheck 8 | 9 | 10 | class TestOrderWithLastCheck(TestCase): 11 | def setUp(self) -> None: 12 | OrderWithLastCheck.objects.create(name="First", total_items=10) 13 | OrderWithLastCheck.objects.create(name="Last", total_items=10) 14 | 15 | def test_all_good(self): 16 | errors = gen_consistency_errors( 17 | create_validators="custom_consistency.OrderWithLastCheck", 18 | ) 19 | assert not list(errors) 20 | 21 | def test_first_will_not_be_checked(self): 22 | order = OrderWithLastCheck.objects.get(name="First") 23 | order.total_items = -10 24 | order.save() 25 | 26 | errors = gen_consistency_errors( 27 | create_validators="custom_consistency.OrderWithLastCheck", 28 | ) 29 | assert not list(errors) 30 | 31 | def test_last_will_not_be_checked(self): 32 | order = OrderWithLastCheck.objects.get(name="Last") 33 | order.total_items = -10 34 | order.save() 35 | 36 | errors = gen_consistency_errors( 37 | create_validators="custom_consistency.OrderWithLastCheck", 38 | ) 39 | assert list(errors) 40 | 41 | 42 | class TestOrderWithSkipCheck(TestCase): 43 | def setUp(self) -> None: 44 | OrderWithSkipCheck.objects.create(name="First", total_items=10) 45 | OrderWithSkipCheck.objects.create(name="Last", total_items=10) 46 | 47 | def test_all_good(self): 48 | errors = gen_consistency_errors( 49 | create_validators="custom_consistency.OrderWithSkipCheck", 50 | ) 51 | assert not list(errors) 52 | 53 | def test_fail_if_not_skip(self): 54 | order = OrderWithSkipCheck.objects.first() 55 | order.total_items = -10 56 | order.save() 57 | 58 | errors = gen_consistency_errors( 59 | create_validators="custom_consistency.OrderWithSkipCheck", 60 | ) 61 | assert list(errors) 62 | 63 | def test_not_fail_if_skip(self): 64 | order = OrderWithSkipCheck.objects.first() 65 | order.total_items = -10 66 | order.skip_consistency_check = True 67 | order.save() 68 | 69 | errors = gen_consistency_errors( 70 | create_validators="custom_consistency.OrderWithSkipCheck", 71 | ) 72 | assert not list(errors) 73 | -------------------------------------------------------------------------------- /tests/test_generators.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from consistency_model import ( 4 | gen_validators_by_model, 5 | gen_validators_by_app, 6 | gen_validators_by_func, 7 | gen_validators, 8 | ) 9 | 10 | 11 | class TestGenerators(TestCase): 12 | def assertEqualValidators(self, generated, tested): 13 | generated = [(g[0], set([f.__name__ for f in g[1]])) for g in generated] 14 | self.assertEqual(generated, tested) 15 | 16 | def test_by_model(self): 17 | self.assertEqualValidators( 18 | gen_validators_by_model("tests.Order"), 19 | [(("tests", "Order"), {"validate_total", "validate_revenue"})], 20 | ) 21 | self.assertEqualValidators( 22 | gen_validators_by_model("subapp.Store"), 23 | [(("subapp", "Store"), {"validate_total_items"})], 24 | ) 25 | self.assertEqualValidators( 26 | gen_validators_by_model("tests.Unknown"), 27 | [], 28 | ) 29 | self.assertEqualValidators( 30 | gen_validators_by_model(["tests.Order", "subapp.Store"]), 31 | [ 32 | (("tests", "Order"), {"validate_total", "validate_revenue"}), 33 | (("subapp", "Store"), {"validate_total_items"}), 34 | ], 35 | ) 36 | 37 | def test_by_app(self): 38 | self.assertEqualValidators( 39 | gen_validators_by_app("tests"), 40 | [ 41 | (("tests", "Order"), {"validate_total", "validate_revenue"}), 42 | (("tests", "OrderItem"), {"validate_price"}), 43 | ], 44 | ) 45 | self.assertEqualValidators( 46 | gen_validators_by_app("subapp"), 47 | [(("subapp", "Store"), {"validate_total_items"})], 48 | ) 49 | self.assertEqualValidators( 50 | gen_validators_by_app("unknown"), 51 | [], 52 | ) 53 | self.assertEqualValidators( 54 | gen_validators_by_app(["tests", "subapp"]), 55 | [ 56 | (("tests", "Order"), {"validate_total", "validate_revenue"}), 57 | (("tests", "OrderItem"), {"validate_price"}), 58 | (("subapp", "Store"), {"validate_total_items"}), 59 | ], 60 | ) 61 | 62 | def test_by_func(self): 63 | self.assertEqualValidators( 64 | gen_validators_by_func("tests.Order.validate_total"), 65 | [(("tests", "Order"), {"validate_total"})], 66 | ) 67 | 68 | self.assertEqualValidators( 69 | gen_validators_by_func("subapp.Store.validate_total_items"), 70 | [(("subapp", "Store"), {"validate_total_items"})], 71 | ) 72 | self.assertEqualValidators( 73 | gen_validators_by_func("tests.Store.unknown"), 74 | [], 75 | ) 76 | self.assertEqualValidators( 77 | gen_validators_by_func("tests.Unknown.validate_total_items"), 78 | [], 79 | ) 80 | self.assertEqualValidators( 81 | gen_validators_by_func( 82 | ["tests.Order.validate_total", "tests.Order.validate_revenue"] 83 | ), 84 | [(("tests", "Order"), {"validate_total", "validate_revenue"})], 85 | ) 86 | self.assertEqualValidators( 87 | gen_validators_by_func( 88 | [ 89 | "tests.Order.validate_total", 90 | "tests.Order.validate_revenue", 91 | "subapp.Store.validate_total_items", 92 | ] 93 | ), 94 | [ 95 | (("tests", "Order"), {"validate_total", "validate_revenue"}), 96 | (("subapp", "Store"), {"validate_total_items"}), 97 | ], 98 | ) 99 | 100 | def test_by_name(self): 101 | self.assertEqualValidators( 102 | gen_validators("tests.Order"), 103 | [(("tests", "Order"), {"validate_total", "validate_revenue"})], 104 | ) 105 | self.assertEqualValidators( 106 | gen_validators(["tests.Order", "subapp.Store.validate_total_items"]), 107 | [ 108 | (("tests", "Order"), {"validate_total", "validate_revenue"}), 109 | (("subapp", "Store"), {"validate_total_items"}), 110 | ], 111 | ) 112 | self.assertEqualValidators( 113 | gen_validators( 114 | ["tests.Order", "subapp.Store.validate_total_items", "tests"] 115 | ), 116 | [ 117 | (("tests", "Order"), {"validate_total", "validate_revenue"}), 118 | (("subapp", "Store"), {"validate_total_items"}), 119 | (("tests", "Order"), {"validate_total", "validate_revenue"}), 120 | (("tests", "OrderItem"), {"validate_price"}), 121 | ], 122 | ) 123 | -------------------------------------------------------------------------------- /title-600.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-consistency-model/db835c02ee7c5e6f6d9836597eb154237cae7d9d/title-600.png -------------------------------------------------------------------------------- /title-800.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-consistency-model/db835c02ee7c5e6f6d9836597eb154237cae7d9d/title-800.png -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | {py36,py37,py38,py39}-django22, 4 | {py36,py37,py38,py39}-django31, 5 | {py36,py37,py38,py39,py310}-django32, 6 | {py38,py39,py310}-{django40,djangomain}, 7 | base, 8 | 9 | [travis:env] 10 | DJANGO = 11 | 2.2: django22 12 | 3.1: django31 13 | 3.2: django32 14 | 4.0: django40 15 | main: djangomain 16 | 17 | [testenv] 18 | commands = python -W error::DeprecationWarning -W error::PendingDeprecationWarning runtests.py {posargs} 19 | envdir = {toxworkdir}/venvs/{envname} 20 | setenv = 21 | PYTHONDONTWRITEBYTECODE=1 22 | PYTHONWARNINGS=once 23 | deps = 24 | django22: Django>=2.2,<3.0 25 | django31: Django>=3.1,<3.2 26 | django32: Django>=3.2,<4.0 27 | django40: Django>=4.0,<5.0 28 | djangomain: https://github.com/django/django/archive/main.tar.gz 29 | -rrequirements/testing.txt 30 | 31 | [testenv:base] 32 | ; Ensure optional dependencies are not required 33 | deps = 34 | django 35 | -rrequirements/testing.txt 36 | 37 | [testenv:py38-djangomain] 38 | ignore_outcome = true 39 | 40 | [testenv:py39-djangomain] 41 | ignore_outcome = true 42 | 43 | [testenv:py310-djangomain] 44 | ignore_outcome = true 45 | --------------------------------------------------------------------------------