Skip to content

Commit 3937356

Browse files
committed
Merge pull request #99 from macro1/populate-history-command
Populate history command
2 parents fd92098 + a56aeed commit 3937356

File tree

8 files changed

+247
-3
lines changed

8 files changed

+247
-3
lines changed

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
author_email='[email protected]',
1414
maintainer='Trey Hunner',
1515
url='https://github.com/treyhunner/django-simple-history',
16-
packages=["simple_history"],
16+
packages=["simple_history", "simple_history.management", "simple_history.management.commands"],
1717
classifiers=[
1818
"Development Status :: 5 - Production/Stable",
1919
"Framework :: Django",

simple_history/management/__init__.py

Whitespace-only changes.

simple_history/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
try:
2+
from django.utils.timezone import now
3+
except ImportError: # pragma: no cover
4+
from datetime import datetime
5+
now = datetime.now
6+
from django.db import transaction
7+
8+
9+
class NotHistorical(TypeError):
10+
"""No related history model found."""
11+
12+
13+
def get_history_model_for_model(model):
14+
"""Find the history model for a given app model."""
15+
try:
16+
manager_name = model._meta.simple_history_manager_attribute
17+
except AttributeError:
18+
raise NotHistorical("Cannot find a historical model for "
19+
"{model}.".format(model=model))
20+
return getattr(model, manager_name).model
21+
22+
23+
def bulk_history_create(model, history_model):
24+
"""Save a copy of all instances to the historical model."""
25+
historical_instances = [
26+
history_model(
27+
history_date=getattr(instance, '_history_date', now()),
28+
history_user=getattr(instance, '_history_user', None),
29+
**dict((field.attname, getattr(instance, field.attname))
30+
for field in instance._meta.fields)
31+
) for instance in model.objects.all()]
32+
try:
33+
history_model.objects.bulk_create(historical_instances)
34+
except AttributeError: # pragma: no cover
35+
# bulk_create was added in Django 1.4, handle legacy versions
36+
with transaction.commit_on_success():
37+
for instance in historical_instances:
38+
instance.save()
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from optparse import make_option
2+
from django.core.management.base import BaseCommand, CommandError
3+
from django.db.models.loading import get_model
4+
5+
from ... import models
6+
from . import _populate_utils as utils
7+
8+
9+
class Command(BaseCommand):
10+
args = "<app.model app.model ...>"
11+
help = ("Populates the corresponding HistoricalRecords field with "
12+
"the current state of all instances in a model")
13+
14+
COMMAND_HINT = "Please specify a model or use the --auto option"
15+
MODEL_NOT_FOUND = "Unable to find model"
16+
MODEL_NOT_HISTORICAL = "No history model found"
17+
NO_REGISTERED_MODELS = "No registered models were found\n"
18+
START_SAVING_FOR_MODEL = "Saving historical records for {model}\n"
19+
DONE_SAVING_FOR_MODEL = "Finished saving historical records for {model}\n"
20+
EXISTING_HISTORY_FOUND = "Existing history found, skipping model"
21+
INVALID_MODEL_ARG = "An invalid model was specified"
22+
23+
option_list = BaseCommand.option_list + (
24+
make_option(
25+
'--auto',
26+
action='store_true',
27+
dest='auto',
28+
default=False,
29+
help="Automatically search for models with the "
30+
"HistoricalRecords field type",
31+
),
32+
)
33+
34+
def handle(self, *args, **options):
35+
to_process = set()
36+
37+
if args:
38+
for model_pair in self._handle_model_list(*args):
39+
to_process.add(model_pair)
40+
41+
elif options['auto']:
42+
for model in models.registered_models.values():
43+
try: # avoid issues with mutli-table inheritance
44+
history_model = utils.get_history_model_for_model(model)
45+
except utils.NotHistorical:
46+
continue
47+
to_process.add((model, history_model))
48+
if not to_process:
49+
self.stdout.write(self.NO_REGISTERED_MODELS)
50+
51+
else:
52+
self.stdout.write(self.COMMAND_HINT)
53+
54+
self._process(to_process)
55+
56+
def _handle_model_list(self, *args):
57+
failing = False
58+
for natural_key in args:
59+
try:
60+
model, history = self._model_from_natural_key(natural_key)
61+
except ValueError as e:
62+
failing = True
63+
self.stderr.write("{error}\n".format(error=e))
64+
else:
65+
if not failing:
66+
yield (model, history)
67+
if failing:
68+
raise CommandError(self.INVALID_MODEL_ARG)
69+
70+
def _model_from_natural_key(self, natural_key):
71+
try:
72+
app_label, model = natural_key.split(".", 1)
73+
except ValueError:
74+
model = None
75+
else:
76+
try:
77+
model = get_model(app_label, model)
78+
# Django 1.7 raises a LookupError
79+
except LookupError: # pragma: no cover
80+
model = None
81+
if not model:
82+
raise ValueError(self.MODEL_NOT_FOUND +
83+
" < {model} >\n".format(model=natural_key))
84+
try:
85+
history_model = utils.get_history_model_for_model(model)
86+
except utils.NotHistorical:
87+
raise ValueError(self.MODEL_NOT_HISTORICAL +
88+
" < {model} >\n".format(model=natural_key))
89+
return model, history_model
90+
91+
def _process(self, to_process):
92+
for model, history_model in to_process:
93+
if history_model.objects.count():
94+
self.stderr.write("{msg} {model}\n".format(
95+
msg=self.EXISTING_HISTORY_FOUND,
96+
model=model,
97+
))
98+
continue
99+
self.stdout.write(self.START_SAVING_FOR_MODEL.format(model=model))
100+
utils.bulk_history_create(model, history_model)
101+
self.stdout.write(self.DONE_SAVING_FOR_MODEL.format(model=model))
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .test_models import *
2+
from .test_commands import *
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from contextlib import contextmanager
2+
from six.moves import cStringIO as StringIO
3+
from datetime import datetime
4+
from django.test import TestCase
5+
from django.core import management
6+
from simple_history import models as sh_models
7+
from simple_history.management.commands import populate_history
8+
9+
from .. import models
10+
11+
12+
@contextmanager
13+
def replace_registry(new_value=None):
14+
hidden_registry = sh_models.registered_models
15+
sh_models.registered_models = new_value or {}
16+
try:
17+
yield
18+
except Exception:
19+
raise
20+
finally:
21+
sh_models.registered_models = hidden_registry
22+
23+
24+
class TestPopulateHistory(TestCase):
25+
command_name = 'populate_history'
26+
command_error = (management.CommandError, SystemExit)
27+
28+
def test_no_args(self):
29+
out = StringIO()
30+
management.call_command(self.command_name,
31+
stdout=out, stderr=StringIO())
32+
self.assertIn(populate_history.Command.COMMAND_HINT, out.getvalue())
33+
34+
def test_bad_args(self):
35+
test_data = (
36+
(populate_history.Command.MODEL_NOT_HISTORICAL, ("tests.place",)),
37+
(populate_history.Command.MODEL_NOT_FOUND, ("invalid.model",)),
38+
(populate_history.Command.MODEL_NOT_FOUND, ("bad_key",)),
39+
)
40+
for msg, args in test_data:
41+
out = StringIO()
42+
self.assertRaises(self.command_error, management.call_command,
43+
self.command_name, *args,
44+
stdout=StringIO(), stderr=out)
45+
self.assertIn(msg, out.getvalue())
46+
47+
def test_auto_populate(self):
48+
models.Poll.objects.create(question="Will this populate?",
49+
pub_date=datetime.now())
50+
models.Poll.history.all().delete()
51+
management.call_command(self.command_name, auto=True,
52+
stdout=StringIO(), stderr=StringIO())
53+
self.assertEqual(models.Poll.history.all().count(), 1)
54+
55+
def test_specific_populate(self):
56+
models.Poll.objects.create(question="Will this populate?",
57+
pub_date=datetime.now())
58+
models.Poll.history.all().delete()
59+
models.Book.objects.create(isbn="9780007117116")
60+
models.Book.history.all().delete()
61+
management.call_command(self.command_name, "tests.book",
62+
stdout=StringIO(), stderr=StringIO())
63+
self.assertEqual(models.Book.history.all().count(), 1)
64+
self.assertEqual(models.Poll.history.all().count(), 0)
65+
66+
def test_failing_wont_save(self):
67+
models.Poll.objects.create(question="Will this populate?",
68+
pub_date=datetime.now())
69+
models.Poll.history.all().delete()
70+
self.assertRaises(self.command_error,
71+
management.call_command, self.command_name,
72+
"tests.poll", "tests.invalid_model",
73+
stdout=StringIO(), stderr=StringIO())
74+
self.assertEqual(models.Poll.history.all().count(), 0)
75+
76+
def test_multi_table(self):
77+
data = {'rating': 5, 'name': "Tea 'N More"}
78+
models.Restaurant.objects.create(**data)
79+
models.Restaurant.updates.all().delete()
80+
management.call_command(self.command_name, 'tests.restaurant',
81+
stdout=StringIO(), stderr=StringIO())
82+
update_record = models.Restaurant.updates.all()[0]
83+
for attr, value in data.items():
84+
self.assertEqual(getattr(update_record, attr), value)
85+
86+
def test_existing_objects(self):
87+
data = {'rating': 5, 'name': "Tea 'N More"}
88+
out = StringIO()
89+
models.Restaurant.objects.create(**data)
90+
pre_call_count = models.Restaurant.updates.count()
91+
management.call_command(self.command_name, 'tests.restaurant',
92+
stdout=StringIO(), stderr=out)
93+
self.assertEqual(models.Restaurant.updates.count(), pre_call_count)
94+
self.assertIn(populate_history.Command.EXISTING_HISTORY_FOUND,
95+
out.getvalue())
96+
97+
def test_no_historical(self):
98+
out = StringIO()
99+
with replace_registry():
100+
management.call_command(self.command_name, auto=True,
101+
stdout=out)
102+
self.assertIn(populate_history.Command.NO_REGISTERED_MODELS,
103+
out.getvalue())

simple_history/tests/tests.py renamed to simple_history/tests/tests/test_models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@
1717

1818
from simple_history.models import HistoricalRecords
1919
from simple_history import register
20-
from .models import (
20+
from ..models import (
2121
AdminProfile, Bookcase, MultiOneToOne, Poll, Choice, Restaurant, Person,
2222
FileModel, Document, Book, HistoricalPoll, Library, State, AbstractBase,
2323
ConcreteAttr, ConcreteUtil, SelfFK, Temperature, WaterLevel,
2424
ExternalModel1, ExternalModel3, UnicodeVerboseName
2525
)
26-
from .external.models import ExternalModel2, ExternalModel4
26+
from ..external.models import ExternalModel2, ExternalModel4
2727

2828
today = datetime(2021, 1, 1, 10, 0)
2929
tomorrow = today + timedelta(days=1)

0 commit comments

Comments
 (0)