├── .coveragerc
├── .github
└── workflows
│ └── python-app.yml
├── .gitignore
├── CHANGES.rst
├── LICENSE
├── MANIFEST.in
├── README.rst
├── dev-requirements.txt
├── doc
├── TODO
└── activated_mixin_listing.png
├── pytest.ini
├── requirements.txt
├── runtests.py
├── setup.py
├── tox.ini
└── tracking_fields
├── __init__.py
├── admin.py
├── decorators.py
├── locale
└── fr
│ └── LC_MESSAGES
│ ├── django.mo
│ └── django.po
├── migrations
├── 0001_initial.py
├── 0002_auto_20160209_0356.py
├── 0003_auto_20220309_0347.py
└── __init__.py
├── models.py
├── settings.py
├── templates
└── tracking_fields
│ └── admin
│ ├── change_form_object.html
│ ├── change_list_event.html
│ └── filter.html
├── tests
├── __init__.py
├── admin.py
├── models.py
├── settings.py
├── tests.py
└── urls.py
└── tracking.py
/.coveragerc:
--------------------------------------------------------------------------------
1 | [report]
2 | omit =
3 | tracking_fields/tests/*
4 | /home/*/.virtualenvs/*
5 | /home/travis/virtualenv/*
--------------------------------------------------------------------------------
/.github/workflows/python-app.yml:
--------------------------------------------------------------------------------
1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python
2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
3 |
4 | name: Python application
5 |
6 | on:
7 | push:
8 | branches: [ master ]
9 | pull_request:
10 | branches: [ master ]
11 |
12 | jobs:
13 | tests_python:
14 | name: Test on Python ${{ matrix.python_version }} and Django ${{ matrix.django_version }}
15 | runs-on: ubuntu-latest
16 | strategy:
17 | matrix:
18 | django_version: [ '3.2', '4.0', '4.1', '4.2' ]
19 | python_version: [ '3.7', '3.8', '3.9', '3.10', '3.11' ]
20 | exclude:
21 | - django_version: '3.2'
22 | python_version: '3.11'
23 |
24 | - django_version: '4.0'
25 | python_version: '3.11'
26 |
27 | - django_version: '4.1'
28 | python_version: '3.11'
29 |
30 | - django_version: '4.0'
31 | python_version: '3.7'
32 |
33 | - django_version: '4.1'
34 | python_version: '3.7'
35 |
36 | - django_version: '4.2'
37 | python_version: '3.7'
38 | env:
39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
40 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
41 |
42 | steps:
43 | - uses: actions/checkout@v2
44 | - name: Set up Python ${{ matrix.python_version }}
45 | uses: actions/setup-python@v2
46 | with:
47 | python-version: ${{ matrix.python_version }}
48 | - name: Cache pip
49 | uses: actions/cache@v2
50 | with:
51 | # This path is specific to Ubuntu
52 | path: ~/.cache/pip
53 | key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ matrix.django_version }}
54 | - name: Install dependencies
55 | run: |
56 | python -m pip install --upgrade pip
57 | pip install -e .
58 | pip install -U -r dev-requirements.txt
59 | pip install -U Django~=${{ matrix.django_version }}
60 | - name: isort
61 | run: |
62 | isort tracking_fields --profile black --skip migrations
63 | - name: Lint with black
64 | run: |
65 | black tracking_fields --exclude "migrations"
66 | - name: Lint with flake8
67 | run: |
68 | flake8 --ignore=E501,W504 tracking_fields
69 | - name: Test Django
70 | run: |
71 | python -W error::DeprecationWarning -W error::PendingDeprecationWarning runtests.py --fast --coverage
72 | - name: Coverage
73 | if: ${{ success() }}
74 | run: |
75 | coveralls
76 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *~
2 | \#*
3 | .#*
4 | *pyc
5 |
6 | /dist
7 | /*.egg-info
8 | /.coverage
9 | /htmlcov
10 | /.idea
11 |
--------------------------------------------------------------------------------
/CHANGES.rst:
--------------------------------------------------------------------------------
1 | =========
2 | Changelog
3 | =========
4 |
5 | 1.4.1 (unreleased)
6 | ------------------
7 |
8 | *
9 |
10 | 1.4.0 (2023-06-15)
11 | ------------------
12 |
13 | * Drop Django < 3.2 support
14 | * Add Django 4.2 support
15 | * Do not store M2M managers on the instance as original values.
16 | * Fix TrackerEventUserFilter performance.
17 |
18 | 1.3.7 (2022-04-07)
19 | ------------------
20 |
21 | * Do not tracked deferred related fields.
22 |
23 | 1.3.6 (2022-03-16)
24 | ------------------
25 |
26 | * Fix error on admin related to filtering content from deleted user
27 | * Fix deprecation warnings for Django 5.0.
28 | * Django 3.2 and 4.0 compatibility
29 |
30 | 1.3.5 (2022-03-09)
31 | ------------------
32 |
33 | * Increase max_length of TrackedFieldModification.field from 40 to 250
34 |
35 | 1.3.4 (2021-11-24)
36 | ------------------
37 |
38 | * Bulk create TrackedFieldModification
39 |
40 | 1.3.3 (2021-10-25)
41 | ------------------
42 |
43 | * Fix tracking of models with uuid ids
44 |
45 | 1.3.2 (2021-09-01)
46 | ------------------
47 |
48 | * Fix related event when there is no backward relation.
49 |
50 | 1.3.1 (2021-02-19)
51 | ------------------
52 |
53 | * Added `get_object_model_verbose_name`.
54 |
55 | 1.3.0 (2021-02-19)
56 | ------------------
57 |
58 | * Added `get_object_model` on `TrackingEvent` to be able to get model class in templates.
59 | * Fix deprecation warnings for Django 4.0.
60 | * Drop support for Django 2.0 and 2.1.
61 |
62 | 1.2.1 (2020-10-20)
63 | ------------------
64 |
65 | * Deferred fields are not tracked to avoid additional requests.
66 |
67 | 1.2.0 (2020-05-07)
68 | ------------------
69 |
70 | * fix 'str' object has no attribute 'name' #6
71 | * Django 3.0 compatibility
72 | * Drop support for Django 1.11
73 |
74 | 1.1.2 (2019-09-11)
75 | ------------------
76 |
77 | * added serialization for xworkflow StateWrapper
78 |
79 | 1.1.1 (2019-01-25)
80 | ------------------
81 |
82 | * Optimize admin user lookup
83 |
84 | 1.1.0 (2019-01-24)
85 | ------------------
86 |
87 | * Compatibility with Django 1.11 to 2.1
88 | * Compatibility droped for earlier versions
89 |
90 | 1.0.6
91 | -----
92 |
93 | * Fix unicode error in admin with Python 3.4 and django_cuser
94 |
95 | 1.0.5
96 | -----
97 |
98 | * Fix MANIFEST
99 |
100 | 1.0.4
101 | -----
102 |
103 | * Order TrackingEvent by -date
104 |
105 | 1.0.3
106 | -----
107 |
108 | * Fix MANIFEST
109 |
110 | 1.0.2
111 | -----
112 |
113 | * Include migrations in MANIFEST
114 |
115 | 1.0.0
116 | -----
117 |
118 | * Initial release
119 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 |
635 | Copyright (C)
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include LICENSE
2 | include README.rst
3 | include CHANGES.rst
4 | include requirements.txt
5 | include dev-requirements.txt
6 | recursive-include doc *
7 | recursive-include tracking_fields/templates *.html
8 | recursive-include tracking_fields/locale *
9 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | ===============
2 | Tracking Fields
3 | ===============
4 |
5 | .. image:: https://travis-ci.org/makinacorpus/django-tracking-fields.png
6 | :target: https://travis-ci.org/makinacorpus/django-tracking-fields
7 |
8 | .. image:: https://coveralls.io/repos/makinacorpus/django-tracking-fields/badge.png?branch=master
9 | :target: https://coveralls.io/r/makinacorpus/django-tracking-fields?branch=master
10 |
11 |
12 | A Django app allowing the tracking of objects field in the admin site.
13 |
14 | Requirements
15 | ------------
16 |
17 | * Django 2.2: See older versions for earlier version of Django.
18 | * django-cuser: Only if you want to track which user made the modifications.
19 |
20 | Quick start
21 | -----------
22 |
23 | 1. Add "tracking_fields" to your INSTALLED_APPS settings.
24 |
25 | 2. Add the ``tracking_fields.decorators.track`` decorator to your models with the fields you want to track as parameters::
26 |
27 | @track('test', 'm2m')
28 | class MyModel(models.Model):
29 | test = models.BooleanField('Test', default=True)
30 | m2m = models.ManyToManyField(SubModelTest, null=True)
31 |
32 | 3. Your objects are now tracked. See the admin site for the tracking information.
33 |
34 | 4. If you want to track who does the changes, please install the ``django-cuser`` app.
35 |
36 | 5. You can also track fields of related objects::
37 |
38 | class MyModel(models.Model):
39 | test = models.BooleanField('Test', default=True)
40 |
41 | @track('related__test')
42 | class MyOtherModel(models.Model):
43 | related = models.ForeignKey(MyModel)
44 |
45 |
46 | 6. You can run the tests with ``tox`` (make sure to have ``django-cuser`` installed).
47 |
48 | Upgrades from 0.1 or 1.0.1
49 | ==========================
50 |
51 | The change to UUID is a mess to do in a migration. The migrations have thus been squashed. You can either alter your fields by hand and do a fake migration afterward or remove your tracking fields tables and run migrations again::
52 |
53 | ./manage.py migrate --fake tracking_fields zero
54 | ./manage.py migrate tracking_fields
55 |
56 | FAQ
57 | ===
58 |
59 | * Why does my relationship change create two events ?
60 |
61 | Please see https://docs.djangoproject.com/en/1.7/ref/models/relations/#direct-assignment
62 |
63 |
64 | AUTHORS
65 | =======
66 |
67 | * Yann FOUILLAT (alias Gagaro)
68 |
69 | |makinacom|_
70 |
71 | .. |makinacom| image:: http://depot.makina-corpus.org/public/logo.gif
72 | .. _makinacom: http://www.makina-corpus.com
73 |
74 |
75 | =======
76 | LICENSE
77 | =======
78 |
79 | * GPLv3+
80 |
--------------------------------------------------------------------------------
/dev-requirements.txt:
--------------------------------------------------------------------------------
1 | coverage
2 | pillow
3 | django-cuser
4 | pytest
5 | pytest-django
6 | pytest-cov
7 |
8 | # PEP8 code linting, which we run on all commits.
9 | flake8
10 | flake8-tidy-imports
11 | pycodestyle
12 | coveralls
13 | argparse
14 | black
15 | # Sort and lint imports
16 | isort
17 |
--------------------------------------------------------------------------------
/doc/TODO:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/makinacorpus/django-tracking-fields/6395159f8be96b2f1e414b0c03d9e089061524f0/doc/TODO
--------------------------------------------------------------------------------
/doc/activated_mixin_listing.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/makinacorpus/django-tracking-fields/6395159f8be96b2f1e414b0c03d9e089061524f0/doc/activated_mixin_listing.png
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | DJANGO_SETTINGS_MODULE = tracking_fields.tests.settings
3 | python_files = tests.py test_*.py *_tests.py
4 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | django-cuser
2 | pytz
3 |
--------------------------------------------------------------------------------
/runtests.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python
2 | # From https://github.com/encode/django-rest-framework/blob/master/runtests.py
3 | from __future__ import print_function
4 |
5 | import subprocess
6 | import sys
7 |
8 | import pytest
9 |
10 | PYTEST_ARGS = {
11 | 'default': [],
12 | 'fast': ['-q'],
13 | }
14 |
15 | FLAKE8_ARGS = ['tracking_fields']
16 |
17 | ISORT_ARGS = ['--recursive', '--check-only', '--diff', '-p', 'tracking_fields', 'tracking_fields']
18 |
19 |
20 | def exit_on_failure(ret, message=None):
21 | if ret:
22 | sys.exit(ret)
23 |
24 |
25 | def flake8_main(args):
26 | print('Running flake8 code linting')
27 | ret = subprocess.call(['flake8'] + args)
28 | print('flake8 failed' if ret else 'flake8 passed')
29 | return ret
30 |
31 |
32 | def isort_main(args):
33 | print('Running isort code checking')
34 | ret = subprocess.call(['isort'] + args)
35 |
36 | if ret:
37 | print('isort failed: Some modules have incorrectly ordered imports. Fix by running `isort --recursive .`')
38 | else:
39 | print('isort passed')
40 |
41 | return ret
42 |
43 |
44 | def split_class_and_function(string):
45 | class_string, function_string = string.split('.', 1)
46 | return "%s and %s" % (class_string, function_string)
47 |
48 |
49 | def is_function(string):
50 | # `True` if it looks like a test function is included in the string.
51 | return string.startswith('test_') or '.test_' in string
52 |
53 |
54 | def is_class(string):
55 | # `True` if first character is uppercase - assume it's a class name.
56 | return string[0] == string[0].upper()
57 |
58 |
59 | if __name__ == "__main__":
60 | try:
61 | sys.argv.remove('--nolint')
62 | except ValueError:
63 | run_flake8 = True
64 | run_isort = True
65 | else:
66 | run_flake8 = False
67 | run_isort = False
68 |
69 | try:
70 | sys.argv.remove('--lintonly')
71 | except ValueError:
72 | run_tests = True
73 | else:
74 | run_tests = False
75 |
76 | try:
77 | sys.argv.remove('--fast')
78 | except ValueError:
79 | style = 'default'
80 | else:
81 | style = 'fast'
82 | run_flake8 = False
83 | run_isort = False
84 |
85 | if len(sys.argv) > 1:
86 | pytest_args = sys.argv[1:]
87 | first_arg = pytest_args[0]
88 |
89 | try:
90 | pytest_args.remove('--coverage')
91 | except ValueError:
92 | pass
93 | else:
94 | pytest_args = [
95 | '--cov', '.',
96 | '--cov-report', 'xml',
97 | ] + pytest_args
98 |
99 | if first_arg.startswith('-'):
100 | # `runtests.py [flags]`
101 | pytest_args = ['tracking_fields/tests'] + pytest_args
102 | elif is_class(first_arg) and is_function(first_arg):
103 | # `runtests.py TestCase.test_function [flags]`
104 | expression = split_class_and_function(first_arg)
105 | pytest_args = ['tracking_fields/tests', '-k', expression] + pytest_args[1:]
106 | elif is_class(first_arg) or is_function(first_arg):
107 | # `runtests.py TestCase [flags]`
108 | # `runtests.py test_function [flags]`
109 | pytest_args = ['tracking_fields/tests', '-k', pytest_args[0]] + pytest_args[1:]
110 | else:
111 | pytest_args = PYTEST_ARGS[style]
112 |
113 | if run_tests:
114 | exit_on_failure(pytest.main(pytest_args))
115 |
116 | if run_flake8:
117 | exit_on_failure(flake8_main(FLAKE8_ARGS))
118 |
119 | if run_isort:
120 | exit_on_failure(isort_main(ISORT_ARGS))
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import codecs
2 | import os
3 | from os import path
4 | from setuptools import setup, find_packages
5 |
6 |
7 | def read(*parts):
8 | return codecs.open(path.join(path.dirname(__file__), *parts),
9 | encoding='utf-8').read()
10 |
11 | # allow setup.py to be run from any path
12 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir)))
13 |
14 | setup(
15 | name='django-tracking-fields',
16 | version='1.4.1dev',
17 | packages=find_packages(),
18 | include_package_data=True,
19 | license='GPLv3+',
20 | description=(
21 | 'A Django app allowing the tracking of objects field '
22 | 'in the admin site.'
23 | ),
24 | long_description=u'\n\n'.join((
25 | read('README.rst'),
26 | read('CHANGES.rst'))),
27 | url='https://github.com/makinacorpus/django-tracking-fields',
28 | author='Yann Fouillat (alias Gagaro)',
29 | author_email='yann.fouillat@makina-corpus.com',
30 | classifiers=[
31 | 'Environment :: Web Environment',
32 | 'Framework :: Django',
33 | 'Intended Audience :: Developers',
34 | 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
35 | 'Operating System :: OS Independent',
36 | 'Programming Language :: Python',
37 | 'Programming Language :: Python :: 3.5',
38 | 'Programming Language :: Python :: 3.6',
39 | 'Topic :: Internet :: WWW/HTTP',
40 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
41 | ],
42 | )
43 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist =
3 | {py37}-django-{32}
4 | {py38}-django-{32,40,41,42}
5 | {py39}-django-{32,40,41,42}
6 | {py310}-django-{32,40,41,42}
7 | {py311}-django-{32,40,41,42}
8 |
9 | [testenv]
10 | commands = ./runtests.py --fast --coverage {posargs}
11 | envdir = {toxworkdir}/venvs/{envname}
12 | setenv =
13 | PYTHONDONTWRITEBYTECODE=1
14 | PYTHONWARNINGS=once
15 | DJANGO_SETTINGS_MODULE=tracking_fields.tests.settings
16 | deps =
17 | django-32: Django>=3.2,<4.0
18 | django-40: Django>=4.0,<4.1
19 | django-41: Django>=4.1,<4.2
20 | django-42: Django>=4.2,<5.0
21 | -r requirements.txt
22 | -r dev-requirements.txt
23 |
24 | [testenv:base]
25 | ; Ensure optional dependencies are not required
26 | deps =
27 | django
28 | -r requirements.txt
29 |
--------------------------------------------------------------------------------
/tracking_fields/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/makinacorpus/django-tracking-fields/6395159f8be96b2f1e414b0c03d9e089061524f0/tracking_fields/__init__.py
--------------------------------------------------------------------------------
/tracking_fields/admin.py:
--------------------------------------------------------------------------------
1 | from urllib.parse import quote
2 |
3 | from django.contrib import admin
4 | from django.contrib.contenttypes.models import ContentType
5 | from django.core.exceptions import ObjectDoesNotExist
6 | from django.shortcuts import get_object_or_404
7 | from django.utils.translation import gettext_lazy as _
8 |
9 | from tracking_fields.models import TrackedFieldModification, TrackingEvent
10 |
11 |
12 | class TrackedObjectMixinAdmin(admin.ModelAdmin):
13 | """
14 | Use this mixin to add a "Tracking" button
15 | next to history one on tracked object
16 | """
17 |
18 | class Meta:
19 | abstract = True
20 |
21 | change_form_template = "tracking_fields/admin/change_form_object.html"
22 |
23 | def change_view(self, request, object_id, form_url="", extra_context=None):
24 | extra_context = extra_context or {}
25 | if object_id is not None:
26 | extra_context["tracking_opts"] = TrackingEvent._meta
27 | opts = self.model._meta
28 | content_type = ContentType.objects.get(
29 | app_label=opts.app_label,
30 | model=opts.model_name,
31 | )
32 | extra_context["tracking_value"] = quote(
33 | "{0}:{1}".format(content_type.pk, object_id)
34 | )
35 | return super(TrackedObjectMixinAdmin, self).change_view(
36 | request, object_id, form_url, extra_context
37 | )
38 |
39 |
40 | class TrackerEventListFilter(admin.SimpleListFilter):
41 | """Hidden filter used to get history of a particular object."""
42 |
43 | title = _("Object")
44 | parameter_name = "object"
45 | template = "tracking_fields/admin/filter.html" # Empty template
46 |
47 | def lookups(self, request, model_admin):
48 | qs = model_admin.get_queryset(request)
49 | objects = qs.values(
50 | "object_content_type",
51 | "object_id",
52 | )
53 | lookups = {}
54 | for obj in objects:
55 | value = "{0}:{1}".format(obj["object_content_type"], obj["object_id"])
56 | lookups[value] = value
57 | return [(lookup[0], lookup[1]) for lookup in lookups.items()]
58 |
59 | def queryset(self, request, queryset):
60 | if self.value() is None:
61 | return queryset
62 | value = self.value().split(":")
63 | return queryset.filter(object_content_type_id=value[0], object_id=value[1])
64 |
65 |
66 | class TrackerEventUserFilter(admin.SimpleListFilter):
67 | """Filter on users."""
68 |
69 | title = _("User")
70 | parameter_name = "user"
71 |
72 | def lookups(self, request, model_admin):
73 | qs = model_admin.get_queryset(request)
74 | users = qs.values(
75 | "user_content_type",
76 | "user_id",
77 | ).order_by().distinct()
78 | lookups = {}
79 | for user in users:
80 | if user["user_content_type"] is None:
81 | continue
82 | value = "{0}:{1}".format(user["user_content_type"], user["user_id"])
83 | try:
84 | user_obj = ContentType.objects.get_for_id(
85 | user["user_content_type"]
86 | ).get_object_for_this_type(pk=user["user_id"])
87 | lookups[value] = getattr(user_obj, "username", str(user_obj))
88 | except ObjectDoesNotExist:
89 | lookups[value] = f""
90 | return [(lookup[0], lookup[1]) for lookup in lookups.items()]
91 |
92 | def queryset(self, request, queryset):
93 | if self.value() is None:
94 | return queryset
95 | value = self.value().split(":")
96 | if len(value) == 2:
97 | return queryset.filter(user_content_type_id=value[0], user_id=value[1])
98 | return queryset
99 |
100 |
101 | class TrackedFieldModificationAdmin(admin.TabularInline):
102 | can_delete = False
103 | model = TrackedFieldModification
104 | readonly_fields = (
105 | "field",
106 | "old_value",
107 | "new_value",
108 | )
109 |
110 | def has_add_permission(self, request, obj=None):
111 | return False
112 |
113 |
114 | class TrackingEventAdmin(admin.ModelAdmin):
115 | date_hierarchy = "date"
116 | list_display = ("date", "action", "object", "object_repr")
117 | list_filter = (
118 | "action",
119 | TrackerEventUserFilter,
120 | TrackerEventListFilter,
121 | )
122 | search_fields = (
123 | "object_repr",
124 | "user_repr",
125 | )
126 | readonly_fields = (
127 | "date",
128 | "action",
129 | "object",
130 | "object_repr",
131 | "user",
132 | "user_repr",
133 | )
134 | inlines = (TrackedFieldModificationAdmin,)
135 | change_list_template = "tracking_fields/admin/change_list_event.html"
136 |
137 | def changelist_view(self, request, extra_context=None):
138 | """Get object currently tracked and add a button to get back to it"""
139 | extra_context = extra_context or {}
140 | if "object" in request.GET.keys():
141 | value = request.GET["object"].split(":")
142 | content_type = get_object_or_404(
143 | ContentType,
144 | id=value[0],
145 | )
146 | tracked_object = get_object_or_404(
147 | content_type.model_class(),
148 | id=value[1],
149 | )
150 | extra_context["tracked_object"] = tracked_object
151 | extra_context["tracked_object_opts"] = tracked_object._meta
152 | return super(TrackingEventAdmin, self).changelist_view(request, extra_context)
153 |
154 |
155 | admin.site.register(TrackingEvent, TrackingEventAdmin)
156 |
--------------------------------------------------------------------------------
/tracking_fields/decorators.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | from django.contrib.contenttypes.models import ContentType
4 | from django.db.models import ManyToManyField
5 | from django.db.models.signals import m2m_changed, post_init, post_save, pre_delete
6 | from django.urls import reverse
7 |
8 | from tracking_fields.tracking import (
9 | tracking_delete,
10 | tracking_init,
11 | tracking_m2m,
12 | tracking_save,
13 | )
14 |
15 |
16 | def _add_signals_to_cls(cls):
17 | # Use repr(cls) to be sure to bound the callback
18 | # only once for each class
19 | post_init.connect(
20 | tracking_init,
21 | sender=cls,
22 | dispatch_uid=repr(cls),
23 | )
24 | post_save.connect(
25 | tracking_save,
26 | sender=cls,
27 | dispatch_uid=repr(cls),
28 | )
29 | pre_delete.connect(
30 | tracking_delete,
31 | sender=cls,
32 | dispatch_uid=repr(cls),
33 | )
34 |
35 |
36 | def _track_class_related_field(cls, field):
37 | """Track a field on a related model"""
38 | # field = field on current model
39 | # related_field = field on related model
40 | (field, related_field) = field.split("__", 1)
41 | field_obj = cls._meta.get_field(field)
42 | related_cls = field_obj.remote_field.model
43 | related_name = field_obj.remote_field.get_accessor_name()
44 |
45 | if not hasattr(related_cls, "_tracked_related_fields"):
46 | setattr(related_cls, "_tracked_related_fields", {})
47 | if related_field not in related_cls._tracked_related_fields.keys():
48 | related_cls._tracked_related_fields[related_field] = []
49 |
50 | # There can be several field from different or same model
51 | # related to a single model.
52 | # Thus _tracked_related_fields will be of the form:
53 | # {
54 | # 'field name on related model': [
55 | # ('field name on current model', 'field name to current model'),
56 | # ('field name on another model', 'field name to another model'),
57 | # ...
58 | # ],
59 | # ...
60 | # }
61 |
62 | related_cls._tracked_related_fields[related_field].append((field, related_name))
63 | _add_signals_to_cls(related_cls)
64 | # Detect m2m fields changes
65 | if isinstance(related_cls._meta.get_field(related_field), ManyToManyField):
66 | m2m_changed.connect(
67 | tracking_m2m,
68 | sender=getattr(related_cls, related_field).through,
69 | dispatch_uid=repr(related_cls),
70 | )
71 |
72 |
73 | def _track_class_field(cls, field):
74 | """Track a field on the current model"""
75 | if "__" in field:
76 | _track_class_related_field(cls, field)
77 | return
78 | # Will raise FieldDoesNotExist if there is an error
79 | cls._meta.get_field(field)
80 | # Detect m2m fields changes
81 | if isinstance(cls._meta.get_field(field), ManyToManyField):
82 | m2m_changed.connect(
83 | tracking_m2m,
84 | sender=getattr(cls, field).through,
85 | dispatch_uid=repr(cls),
86 | )
87 |
88 |
89 | def _track_class(cls, fields):
90 | """Track fields on the specified model"""
91 | # Small tests to ensure everything is all right
92 | assert not getattr(cls, "_is_tracked", False)
93 |
94 | for field in fields:
95 | _track_class_field(cls, field)
96 |
97 | _add_signals_to_cls(cls)
98 |
99 | # Mark the class as tracked
100 | cls._is_tracked = True
101 | # Do not directly track related fields (tracked on related model)
102 | # or m2m fields (tracked by another signal)
103 | cls._tracked_fields = [field for field in fields if "__" not in field]
104 |
105 |
106 | def _add_get_tracking_url(cls):
107 | """Add a method to get the tracking url of an object."""
108 |
109 | def get_tracking_url(self):
110 | """return url to tracking view in admin panel"""
111 | url = reverse("admin:tracking_fields_trackingevent_changelist")
112 | object_id = "{0}%3A{1}".format(
113 | ContentType.objects.get_for_model(self).pk, self.pk
114 | )
115 | return "{0}?object={1}".format(url, object_id)
116 |
117 | if not hasattr(cls, "get_tracking_url"):
118 | setattr(cls, "get_tracking_url", get_tracking_url)
119 |
120 |
121 | def track(*fields):
122 | """
123 | Decorator used to track changes on Model's fields.
124 |
125 | :Example:
126 | >>> @track('name')
127 | ... class Human(models.Model):
128 | ... name = models.CharField(max_length=30)
129 | """
130 |
131 | def inner(cls):
132 | _track_class(cls, fields)
133 | _add_get_tracking_url(cls)
134 | return cls
135 |
136 | return inner
137 |
--------------------------------------------------------------------------------
/tracking_fields/locale/fr/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/makinacorpus/django-tracking-fields/6395159f8be96b2f1e414b0c03d9e089061524f0/tracking_fields/locale/fr/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/tracking_fields/locale/fr/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: django-tracking-fields\n"
9 | "Report-Msgid-Bugs-To: \n"
10 | "POT-Creation-Date: 2014-10-29 10:55+0100\n"
11 | "PO-Revision-Date: 2014-10-29 10:55+0100\n"
12 | "Last-Translator: Gagaro \n"
13 | "Language-Team: django-tracking-fields \n"
14 | "Language: fr\n"
15 | "MIME-Version: 1.0\n"
16 | "Content-Type: text/plain; charset=UTF-8\n"
17 | "Content-Transfer-Encoding: 8bit\n"
18 | "Plural-Forms: nplurals=2; plural=(n > 1);\n"
19 | "X-Generator: Poedit 1.5.4\n"
20 |
21 | #: admin.py:37
22 | msgid "Object"
23 | msgstr "Objet"
24 |
25 | #: models.py:18
26 | msgid "Tracking event"
27 | msgstr "Événement de suivi"
28 |
29 | #: models.py:19
30 | msgid "Tracking events"
31 | msgstr "Événements de suivi"
32 |
33 | #: models.py:22
34 | msgid "Create"
35 | msgstr "Créer"
36 |
37 | #: models.py:23
38 | msgid "Update"
39 | msgstr "Modifier"
40 |
41 | #: models.py:24
42 | msgid "Delete"
43 | msgstr "Supprimer"
44 |
45 | #: models.py:25
46 | msgid "Add"
47 | msgstr "Ajouter"
48 |
49 | #: models.py:26
50 | #| msgid "Remove"
51 | msgctxt "Remove from something"
52 | msgid "Remove"
53 | msgstr "Enlever"
54 |
55 | #: models.py:27
56 | msgid "Clear"
57 | msgstr "Tout enlever"
58 |
59 | #: models.py:30
60 | msgid "Date"
61 | msgstr "Date"
62 |
63 | #: models.py:33
64 | msgid "Action"
65 | msgstr "Action"
66 |
67 | #: models.py:44
68 | msgid "Object representation"
69 | msgstr "Représentation de l'objet"
70 |
71 | #: models.py:46
72 | msgid "Object representation, useful if the object is deleted later."
73 | msgstr "Représentation de l'objet, utile si l'objet est supprimé plus tard."
74 |
75 | #: models.py:61
76 | msgid "User representation"
77 | msgstr "Représentation de l'utilisateur"
78 |
79 | #: models.py:63
80 | msgid "User representation, useful if the user is deleted later."
81 | msgstr ""
82 | "Représentation de l'utilisateur, utile si l'utilisateur est supprimé plus "
83 | "tard."
84 |
85 | #: models.py:72
86 | msgid "Tracking field modification"
87 | msgstr "Suivi de modification du champ"
88 |
89 | #: models.py:73
90 | msgid "Tracking field modifications"
91 | msgstr "Suivi de modification des champs"
92 |
93 | #: models.py:76
94 | msgid "Event"
95 | msgstr "Événement"
96 |
97 | #: models.py:79
98 | msgid "Field"
99 | msgstr "Champ"
100 |
101 | #: models.py:81
102 | msgid "Old value"
103 | msgstr "Ancienne valeur"
104 |
105 | #: models.py:82 models.py:88
106 | msgid "JSON serialized"
107 | msgstr "JSON sérialisé"
108 |
109 | #: models.py:87
110 | msgid "New value"
111 | msgstr "Nouvelle valeur"
112 |
113 | #: templates/tracking_fields/admin/change_form.html:8
114 | msgid "Tracking"
115 | msgstr "Suivi"
116 |
--------------------------------------------------------------------------------
/tracking_fields/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import unicode_literals
3 |
4 | from django.db import models, migrations
5 | import uuid
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | dependencies = [
11 | ('contenttypes', '0002_remove_content_type_name'),
12 | ]
13 |
14 | operations = [
15 | migrations.CreateModel(
16 | name='TrackedFieldModification',
17 | fields=[
18 | ('id', models.UUIDField(default=uuid.uuid4, serialize=False, editable=False, primary_key=True)),
19 | ('field', models.CharField(verbose_name='Field', max_length=40, editable=False)),
20 | ('old_value', models.TextField(help_text='JSON serialized', verbose_name='Old value', null=True, editable=False)),
21 | ('new_value', models.TextField(help_text='JSON serialized', verbose_name='New value', null=True, editable=False)),
22 | ],
23 | options={
24 | 'verbose_name': 'Tracking field modification',
25 | 'verbose_name_plural': 'Tracking field modifications',
26 | },
27 | ),
28 | migrations.CreateModel(
29 | name='TrackingEvent',
30 | fields=[
31 | ('id', models.UUIDField(default=uuid.uuid4, serialize=False, editable=False, primary_key=True)),
32 | ('date', models.DateTimeField(auto_now_add=True, verbose_name='Date')),
33 | ('action', models.CharField(verbose_name='Action', max_length=6, editable=False, choices=[('CREATE', 'Create'), ('UPDATE', 'Update'), ('DELETE', 'Delete'), ('ADD', 'Add'), ('REMOVE', 'Remove'), ('CLEAR', 'Clear')])),
34 | ('object_id', models.PositiveIntegerField(null=True, editable=False)),
35 | ('object_repr', models.CharField(help_text='Object representation, useful if the object is deleted later.', verbose_name='Object representation', max_length=250, editable=False)),
36 | ('user_id', models.PositiveIntegerField(null=True, editable=False)),
37 | ('user_repr', models.CharField(help_text='User representation, useful if the user is deleted later.', verbose_name='User representation', max_length=250, editable=False)),
38 | ('object_content_type', models.ForeignKey(related_name='tracking_object_content_type', editable=False, to='contenttypes.ContentType', on_delete=models.CASCADE)),
39 | ('user_content_type', models.ForeignKey(related_name='tracking_user_content_type', editable=False, to='contenttypes.ContentType', null=True, on_delete=models.CASCADE)),
40 | ],
41 | options={
42 | 'verbose_name': 'Tracking event',
43 | 'verbose_name_plural': 'Tracking events',
44 | },
45 | ),
46 | migrations.AddField(
47 | model_name='trackedfieldmodification',
48 | name='event',
49 | field=models.ForeignKey(related_name='fields', editable=False, to='tracking_fields.TrackingEvent', verbose_name='Event', on_delete=models.CASCADE),
50 | ),
51 | ]
52 |
--------------------------------------------------------------------------------
/tracking_fields/migrations/0002_auto_20160209_0356.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import unicode_literals
3 |
4 | from django.db import migrations
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = [
10 | ('tracking_fields', '0001_initial'),
11 | ]
12 |
13 | operations = [
14 | migrations.AlterModelOptions(
15 | name='trackingevent',
16 | options={'verbose_name': 'Tracking event', 'ordering': ['-date'], 'verbose_name_plural': 'Tracking events'},
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/tracking_fields/migrations/0003_auto_20220309_0347.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.0.6 on 2022-03-09 03:47
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('tracking_fields', '0002_auto_20160209_0356'),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name='trackedfieldmodification',
15 | name='field',
16 | field=models.CharField(editable=False, max_length=250, verbose_name='Field'),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/tracking_fields/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/makinacorpus/django-tracking-fields/6395159f8be96b2f1e414b0c03d9e089061524f0/tracking_fields/migrations/__init__.py
--------------------------------------------------------------------------------
/tracking_fields/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | import uuid
4 |
5 | try:
6 | from django.contrib.contenttypes.fields import GenericForeignKey
7 | except ImportError:
8 | from django.contrib.contenttypes.generic import GenericForeignKey
9 |
10 | from django.contrib.contenttypes.models import ContentType
11 | from django.db import models
12 | from django.utils.translation import gettext_lazy as _
13 | from django.utils.translation import pgettext_lazy
14 |
15 | # Used for object modifications
16 | CREATE = "CREATE"
17 | UPDATE = "UPDATE"
18 | DELETE = "DELETE"
19 | # Used for m2m modifications
20 | ADD = "ADD"
21 | REMOVE = "REMOVE"
22 | CLEAR = "CLEAR"
23 |
24 |
25 | class TrackingEvent(models.Model):
26 | ACTIONS = (
27 | (CREATE, _("Create")),
28 | (UPDATE, _("Update")),
29 | (DELETE, _("Delete")),
30 | (ADD, _("Add")),
31 | (REMOVE, pgettext_lazy("Remove from something", "Remove")),
32 | (CLEAR, _("Clear")),
33 | )
34 |
35 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
36 |
37 | date = models.DateTimeField(_("Date"), auto_now_add=True, editable=False)
38 |
39 | action = models.CharField(
40 | _("Action"), max_length=6, choices=ACTIONS, editable=False
41 | )
42 |
43 | object_content_type = models.ForeignKey(
44 | ContentType,
45 | related_name="tracking_object_content_type",
46 | editable=False,
47 | on_delete=models.CASCADE,
48 | )
49 | object_id = models.PositiveIntegerField(editable=False, null=True)
50 | object = GenericForeignKey("object_content_type", "object_id")
51 |
52 | object_repr = models.CharField(
53 | _("Object representation"),
54 | help_text=_("Object representation, useful if the object is deleted later."),
55 | max_length=250,
56 | editable=False,
57 | )
58 |
59 | user_content_type = models.ForeignKey(
60 | ContentType,
61 | related_name="tracking_user_content_type",
62 | editable=False,
63 | null=True,
64 | on_delete=models.CASCADE,
65 | )
66 | user_id = models.PositiveIntegerField(editable=False, null=True)
67 | user = GenericForeignKey("user_content_type", "user_id")
68 |
69 | user_repr = models.CharField(
70 | _("User representation"),
71 | help_text=_("User representation, useful if the user is deleted later."),
72 | max_length=250,
73 | editable=False,
74 | )
75 |
76 | class Meta:
77 | verbose_name = _("Tracking event")
78 | verbose_name_plural = _("Tracking events")
79 | ordering = ["-date"]
80 |
81 | def get_object_model(self):
82 | if self.object_id is None:
83 | return None
84 | return self.object._meta.model
85 |
86 | def get_object_model_verbose_name(self):
87 | if self.object_id is None:
88 | return None
89 | return self.object._meta.verbose_name
90 |
91 |
92 | class TrackedFieldModification(models.Model):
93 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
94 |
95 | event = models.ForeignKey(
96 | TrackingEvent,
97 | verbose_name=_("Event"),
98 | related_name="fields",
99 | editable=False,
100 | on_delete=models.CASCADE,
101 | )
102 |
103 | field = models.CharField(_("Field"), max_length=250, editable=False)
104 |
105 | old_value = models.TextField(
106 | _("Old value"),
107 | help_text=_("JSON serialized"),
108 | null=True,
109 | editable=False,
110 | )
111 |
112 | new_value = models.TextField(
113 | _("New value"),
114 | help_text=_("JSON serialized"),
115 | null=True,
116 | editable=False,
117 | )
118 |
119 | class Meta:
120 | verbose_name = _("Tracking field modification")
121 | verbose_name_plural = _("Tracking field modifications")
122 |
--------------------------------------------------------------------------------
/tracking_fields/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for django_test project.
3 |
4 | For more information on this file, see
5 | https://docs.djangoproject.com/en/1.8/topics/settings/
6 |
7 | For the full list of settings and their values, see
8 | https://docs.djangoproject.com/en/1.8/ref/settings/
9 | """
10 |
11 | # This settings file is used to generate migrations
12 |
13 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
14 | import os
15 |
16 | BASE_DIR = os.path.dirname(os.path.dirname(__file__))
17 |
18 |
19 | # Quick-start development settings - unsuitable for production
20 | # See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/
21 |
22 | # SECURITY WARNING: keep the secret key used in production secret!
23 | SECRET_KEY = "%h13inv2($))j26yv825+*0kpmes3sf4ohy*@#83a07^rck$kl"
24 |
25 | # SECURITY WARNING: don't run with debug turned on in production!
26 | DEBUG = True
27 |
28 | # Application definition
29 |
30 | INSTALLED_APPS = (
31 | "django.contrib.auth",
32 | "django.contrib.contenttypes",
33 | "django.contrib.sessions",
34 | "django.contrib.messages",
35 | "django.contrib.staticfiles",
36 | "tracking_fields",
37 | )
38 |
--------------------------------------------------------------------------------
/tracking_fields/templates/tracking_fields/admin/change_form_object.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/change_form.html" %}
2 | {% load i18n admin_urls %}
3 |
4 | {% block object-tools-items %}
5 | {% if tracking_opts %}
6 |
7 | {% url tracking_opts|admin_urlname:'changelist' as tracking_url %}
8 | {% trans "Tracking" %}
9 |
10 | {% endif %}
11 | {{ block.super }}
12 | {% endblock %}
13 |
--------------------------------------------------------------------------------
/tracking_fields/templates/tracking_fields/admin/change_list_event.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/change_list.html" %}
2 | {% load i18n admin_urls %}
3 |
4 | {% block object-tools-items %}
5 | {% if tracked_object %}
6 |
7 | {% url tracked_object_opts|admin_urlname:'change' tracked_object.pk|admin_urlquote as tracked_object_url %}
8 | {{ tracked_object }}
9 |
10 | {% endif %}
11 | {{ block.super }}
12 | {% endblock %}
13 |
--------------------------------------------------------------------------------
/tracking_fields/templates/tracking_fields/admin/filter.html:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/tracking_fields/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/makinacorpus/django-tracking-fields/6395159f8be96b2f1e414b0c03d9e089061524f0/tracking_fields/tests/__init__.py
--------------------------------------------------------------------------------
/tracking_fields/tests/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | from tracking_fields.admin import TrackedObjectMixinAdmin
4 | from tracking_fields.tests.models import Human
5 |
6 |
7 | class HumanAdmin(TrackedObjectMixinAdmin):
8 | pass
9 |
10 |
11 | admin.site.register(Human, HumanAdmin)
12 |
--------------------------------------------------------------------------------
/tracking_fields/tests/models.py:
--------------------------------------------------------------------------------
1 | import uuid
2 |
3 | from django.db import models
4 |
5 | from tracking_fields.decorators import track
6 |
7 |
8 | @track("value")
9 | class UuidModel(models.Model):
10 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
11 | value = models.CharField(max_length=30)
12 |
13 | def __unicode__(self):
14 | return "{0}".format(self.value)
15 |
16 |
17 | @track("vet_appointment", "name", "age", "picture")
18 | class Pet(models.Model):
19 | vet_appointment = models.DateTimeField(null=True)
20 | name = models.CharField(max_length=30)
21 | age = models.PositiveSmallIntegerField()
22 | picture = models.ImageField(upload_to=".", null=True)
23 |
24 | def __unicode__(self):
25 | return "{0}".format(self.name)
26 |
27 |
28 | @track("birthday", "name", "age", "pets", "favourite_pet")
29 | class Human(models.Model):
30 | birthday = models.DateField(null=True)
31 | name = models.CharField(max_length=30)
32 | age = models.PositiveSmallIntegerField()
33 | pets = models.ManyToManyField(Pet, null=True)
34 | favourite_pet = models.ForeignKey(
35 | Pet,
36 | related_name="favorited_by",
37 | null=True,
38 | on_delete=models.CASCADE,
39 | )
40 | height = models.PositiveIntegerField(help_text="Not tracked")
41 |
42 | def __unicode__(self):
43 | return "{0}".format(self.name)
44 |
45 |
46 | @track("tenant__name", "tenant__pets", "tenant__favourite_pet")
47 | class House(models.Model):
48 | tenant = models.OneToOneField(Human, null=True, on_delete=models.CASCADE)
49 |
50 | def __unicode__(self):
51 | return "House of {0}".format(self.tenant)
52 |
--------------------------------------------------------------------------------
/tracking_fields/tests/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for django_test project.
3 |
4 | For more information on this file, see
5 | https://docs.djangoproject.com/en/1.7/topics/settings/
6 |
7 | For the full list of settings and their values, see
8 | https://docs.djangoproject.com/en/1.7/ref/settings/
9 | """
10 |
11 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
12 | import os
13 |
14 | BASE_DIR = os.path.dirname(os.path.dirname(__file__))
15 |
16 |
17 | # Quick-start development settings - unsuitable for production
18 | # See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/
19 |
20 | # SECURITY WARNING: keep the secret key used in production secret!
21 | SECRET_KEY = "%h13inv2($))j26yv825+*0kpmes3sf4ohy*@#83a07^rck$kl"
22 |
23 | # SECURITY WARNING: don't run with debug turned on in production!
24 | DEBUG = True
25 |
26 | TEMPLATE_DEBUG = True
27 |
28 | ALLOWED_HOSTS = []
29 |
30 |
31 | # Application definition
32 |
33 | INSTALLED_APPS = (
34 | "tracking_fields",
35 | "tracking_fields.tests",
36 | "django.contrib.admin",
37 | "django.contrib.auth",
38 | "django.contrib.contenttypes",
39 | "django.contrib.sessions",
40 | "django.contrib.messages",
41 | "django.contrib.staticfiles",
42 | )
43 |
44 | MIDDLEWARE = (
45 | "django.contrib.sessions.middleware.SessionMiddleware",
46 | "django.middleware.common.CommonMiddleware",
47 | "django.middleware.csrf.CsrfViewMiddleware",
48 | "django.contrib.auth.middleware.AuthenticationMiddleware",
49 | "django.contrib.messages.middleware.MessageMiddleware",
50 | "django.middleware.clickjacking.XFrameOptionsMiddleware",
51 | )
52 |
53 | ROOT_URLCONF = "tracking_fields.tests.urls"
54 |
55 | WSGI_APPLICATION = "tracking_fields.wsgi.application"
56 |
57 |
58 | TEMPLATES = [
59 | {
60 | "BACKEND": "django.template.backends.django.DjangoTemplates",
61 | "APP_DIRS": True,
62 | },
63 | ]
64 |
65 | # Database
66 | # https://docs.djangoproject.com/en/1.7/ref/settings/#databases
67 |
68 | DATABASES = {
69 | "default": {
70 | "ENGINE": "django.db.backends.sqlite3",
71 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"),
72 | }
73 | }
74 |
75 | # Internationalization
76 | # https://docs.djangoproject.com/en/1.7/topics/i18n/
77 |
78 | LANGUAGE_CODE = "en-us"
79 |
80 | TIME_ZONE = "UTC"
81 |
82 | USE_I18N = True
83 |
84 | USE_TZ = True
85 |
86 |
87 | # Static files (CSS, JavaScript, Images)
88 | # https://docs.djangoproject.com/en/1.7/howto/static-files/
89 |
90 | STATIC_URL = "/static/"
91 |
92 | # Used to speed up testing
93 | PASSWORD_HASHERS = {
94 | "django.contrib.auth.hashers.MD5PasswordHasher",
95 | }
96 |
--------------------------------------------------------------------------------
/tracking_fields/tests/tests.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import unicode_literals
3 |
4 | import datetime
5 | import json
6 |
7 | from cuser.middleware import CuserMiddleware
8 | from django.contrib.auth.models import User
9 | from django.contrib.contenttypes.models import ContentType
10 | from django.core.files import File
11 | from django.test import Client, TestCase
12 | from django.utils import timezone
13 | from django.utils.html import escape
14 |
15 | from tracking_fields.models import (
16 | ADD,
17 | CLEAR,
18 | CREATE,
19 | DELETE,
20 | REMOVE,
21 | UPDATE,
22 | TrackingEvent,
23 | )
24 | from tracking_fields.tests.models import House, Human, Pet, UuidModel
25 |
26 |
27 | class TrackingEventTestCase(TestCase):
28 | @classmethod
29 | def setUpTestData(cls):
30 | cls.user = User.objects.create_user(
31 | username="Toto", email="", password="secret"
32 | )
33 | cls.user_repr = repr(cls.user)
34 | CuserMiddleware.set_user(cls.user)
35 | cls.pet = Pet.objects.create(name="Catz", age=12)
36 | cls.human = Human.objects.create(name="George", age=42, height=175)
37 | cls.human_repr = repr(cls.human)
38 |
39 | def setUp(self):
40 | self.user = User.objects.get()
41 | self.pet = Pet.objects.get()
42 | self.human = Human.objects.get()
43 |
44 | def test_create(self):
45 | """
46 | Test the CREATE event
47 | """
48 | events = TrackingEvent.objects.order_by("date").all()
49 | assert events.count() == 2
50 | human_event = events.last()
51 | assert human_event.date is not None
52 | assert human_event.action == CREATE
53 | assert human_event.object == self.human
54 | assert human_event.object_repr == self.human_repr
55 | assert human_event.user == self.user
56 | assert human_event.user_repr == self.user_repr
57 |
58 | def test_update_without_cuser(self):
59 | """
60 | Test the CREATE event without the cuser module
61 | """
62 | from tracking_fields import tracking
63 |
64 | tracking.CUSER = False
65 | self.human.age = 43
66 | self.human.save()
67 | events = TrackingEvent.objects.order_by("date").all()
68 | assert events.count() == 3
69 | human_event = events.last()
70 | assert human_event.date is not None
71 | assert human_event.action == UPDATE
72 | assert human_event.object == self.human
73 | assert human_event.object_repr == self.human_repr
74 | assert human_event.user is None
75 | assert human_event.user_repr == "None"
76 | tracking.CUSER = True
77 |
78 | def test_update(self):
79 | """
80 | Test the UPDATE event
81 | """
82 | self.human.age = 43
83 | self.human.save()
84 | events = TrackingEvent.objects.order_by("date").all()
85 | assert events.count() == 3
86 | human_event = events.last()
87 | assert human_event.date is not None
88 | assert human_event.action == UPDATE
89 | assert human_event.object == self.human
90 | assert human_event.object_repr == self.human_repr
91 | assert human_event.user == self.user
92 | assert human_event.user_repr == self.user_repr
93 |
94 | def test_delete(self):
95 | """
96 | Test the DELETE event
97 | """
98 | self.human.delete()
99 | events = TrackingEvent.objects.order_by("date").all()
100 | assert events.count() == 3
101 | human_event = events.last()
102 | assert human_event.date is not None
103 | assert human_event.action == DELETE
104 | assert human_event.object is None # Object is deleted
105 | assert human_event.object_repr == self.human_repr
106 | assert human_event.user == self.user
107 | assert human_event.user_repr == self.user_repr
108 |
109 | def test_add(self):
110 | """
111 | Test the ADD event
112 | """
113 | self.human.pets.add(self.pet)
114 | events = TrackingEvent.objects.order_by("date").all()
115 | assert events.count() == 3
116 | human_event = events.last()
117 | assert human_event.date is not None
118 | assert human_event.action == ADD
119 | assert human_event.object == self.human
120 | assert human_event.object_repr == self.human_repr
121 | assert human_event.user == self.user
122 | assert human_event.user_repr == self.user_repr
123 |
124 | def test_add_reverse(self):
125 | """
126 | Test the ADD event from the other side of the relationship
127 | """
128 | # Event should be the same than the one from ``test_add``
129 | self.pet.human_set.add(self.human)
130 | events = TrackingEvent.objects.order_by("date").all()
131 | assert events.count() == 3
132 | human_event = events.last()
133 | assert human_event.date is not None
134 | assert human_event.action == ADD
135 | assert human_event.object == self.human
136 | assert human_event.object_repr == self.human_repr
137 | assert human_event.user == self.user
138 | assert human_event.user_repr == self.user_repr
139 |
140 | def test_remove(self):
141 | """
142 | Test the REMOVE event
143 | """
144 | self.human.pets.add(self.pet)
145 | self.human.pets.remove(self.pet)
146 | events = TrackingEvent.objects.order_by("date").all()
147 | assert events.count() == 4
148 | human_event = events.last()
149 | assert human_event.date is not None
150 | assert human_event.action == REMOVE
151 | assert human_event.object == self.human
152 | assert human_event.object_repr == self.human_repr
153 | assert human_event.user == self.user
154 | assert human_event.user_repr == self.user_repr
155 |
156 | def test_remove_reverse(self):
157 | """
158 | Test the REMOVE event from the other side of the relationship
159 | """
160 | # Event should be the same than the one from ``test_remove``
161 | self.human.pets.add(self.pet)
162 | self.pet.human_set.remove(self.human)
163 | events = TrackingEvent.objects.order_by("date").all()
164 | assert events.count() == 4
165 | human_event = events.last()
166 | assert human_event.date is not None
167 | assert human_event.action == REMOVE
168 | assert human_event.object == self.human
169 | assert human_event.object_repr == self.human_repr
170 | assert human_event.user == self.user
171 | assert human_event.user_repr == self.user_repr
172 |
173 | def test_clear(self):
174 | """
175 | Test the CLEAR event
176 | """
177 | self.human.pets.add(self.pet)
178 | self.human.pets.clear()
179 | events = TrackingEvent.objects.order_by("date").all()
180 | assert events.count() == 4
181 | human_event = events.last()
182 | assert human_event.date is not None
183 | assert human_event.action == CLEAR
184 | assert human_event.object == self.human
185 | assert human_event.object_repr == self.human_repr
186 | assert human_event.user == self.user
187 | assert human_event.user_repr == self.user_repr
188 |
189 | def test_clear_reverse(self):
190 | """
191 | Test the CLEAR event from the other side of the relationship
192 | """
193 | self.human.pets.add(self.pet)
194 | self.pet.human_set.clear()
195 | events = TrackingEvent.objects.order_by("date").all()
196 | assert events.count() == 4
197 | human_event = events.last()
198 | assert human_event.date is not None
199 | # Event is actually a removal of pet from human
200 | assert human_event.action == REMOVE
201 | assert human_event.object == self.human
202 | assert human_event.object_repr == self.human_repr
203 | assert human_event.user == self.user
204 | assert human_event.user_repr == self.user_repr
205 |
206 | def test_save_with_no_change(self):
207 | """
208 | Test to save object without change, should not create an UPDATE event
209 | """
210 | self.human.save()
211 | events = TrackingEvent.objects.all()
212 | assert events.count() == 2
213 |
214 | def test_utf8_in_repr(self):
215 | """
216 | Test to save object with utf8 in its repr
217 | """
218 | self.human.name = "😵"
219 | self.human.save()
220 | events = TrackingEvent.objects.all()
221 | assert events.count() == 3
222 |
223 |
224 | class TrackedFieldModificationTestCase(TestCase):
225 | def setUp(self):
226 | self.user = User.objects.create_user(
227 | username="Toto", email="", password="secret"
228 | )
229 | self.user_repr = repr(self.user)
230 | CuserMiddleware.set_user(self.user)
231 | self.pet = Pet.objects.create(name="Catz", age=12)
232 | self.pet2 = Pet.objects.create(name="Catzou", age=1)
233 | self.human = Human.objects.create(name="George", age=42, height=175)
234 | self.human.pets.add(self.pet)
235 | self.human_repr = repr(self.human)
236 |
237 | def test_create(self):
238 | human_event = (
239 | TrackingEvent.objects.filter(action=CREATE).order_by("date").last()
240 | )
241 | assert human_event.fields.all().count() == 4
242 | field = human_event.fields.get(field="birthday")
243 | assert field.new_value == json.dumps(self.human.birthday)
244 | field = human_event.fields.get(field="name")
245 | assert field.new_value == json.dumps(self.human.name)
246 | field = human_event.fields.get(field="age")
247 | assert field.new_value == json.dumps(self.human.age)
248 | field = human_event.fields.get(field="favourite_pet")
249 | assert field.new_value == json.dumps(self.human.favourite_pet)
250 |
251 | def test_update(self):
252 | self.human.age = 43
253 | self.human.save()
254 | human_event = TrackingEvent.objects.order_by("date").last()
255 | assert human_event.fields.all().count() == 1
256 | field = human_event.fields.get(field="age")
257 | assert field.old_value == json.dumps(42)
258 | assert field.new_value == json.dumps(43)
259 |
260 | def test_foreign_key(self):
261 | self.human.favourite_pet = self.pet
262 | self.human.save()
263 | human_event = TrackingEvent.objects.order_by("date").last()
264 | assert human_event.fields.all().count() == 1
265 | field = human_event.fields.get(field="favourite_pet")
266 | assert field.old_value == json.dumps(None)
267 | assert field.new_value == json.dumps(str(self.pet))
268 |
269 | def test_foreign_key_not_changed(self):
270 | """Test a foreign key does not change if only other values change"""
271 | self.human.favourite_pet = self.pet
272 | self.human.save()
273 | self.human.name = "Toto"
274 | self.human.save()
275 | human_event = TrackingEvent.objects.order_by("date").last()
276 | assert human_event.fields.all().count() == 1
277 | field = human_event.fields.first()
278 | assert field.old_value == json.dumps("George")
279 | assert field.new_value == json.dumps("Toto")
280 |
281 | def test_foreign_key_label(self):
282 | """Test label of foreign keys are used in tracked fields"""
283 | self.human.favourite_pet = self.pet
284 | self.human.save()
285 | self.human.favourite_pet = self.pet2
286 | self.human.save()
287 | human_event = TrackingEvent.objects.order_by("date").last()
288 | assert human_event.fields.all().count() == 1
289 | field = human_event.fields.first()
290 | assert field.old_value == json.dumps(str(self.pet))
291 | assert field.new_value == json.dumps(str(self.pet2))
292 |
293 | def test_add(self):
294 | self.human.pets.add(self.pet2)
295 | human_event = TrackingEvent.objects.order_by("date").last()
296 | assert human_event.fields.all().count() == 1
297 | field = human_event.fields.get(field="pets")
298 | assert field.old_value == json.dumps([str(self.pet)])
299 | assert field.new_value == json.dumps([str(self.pet), str(self.pet2)])
300 |
301 | def test_add_reverse(self):
302 | self.pet2.human_set.add(self.human)
303 | human_event = TrackingEvent.objects.order_by("date").last()
304 | assert human_event.fields.all().count() == 1
305 | field = human_event.fields.get(field="pets")
306 | assert field.old_value == json.dumps([str(self.pet)])
307 | assert field.new_value == json.dumps([str(self.pet), str(self.pet2)])
308 |
309 | def test_remove(self):
310 | self.human.pets.add(self.pet2)
311 | self.human.pets.remove(self.pet2)
312 | human_event = TrackingEvent.objects.order_by("date").last()
313 | assert human_event.fields.all().count() == 1
314 | field = human_event.fields.get(field="pets")
315 | assert field.old_value == json.dumps([str(self.pet), str(self.pet2)])
316 | assert field.new_value == json.dumps([str(self.pet)])
317 |
318 | def test_remove_reverse(self):
319 | self.human.pets.add(self.pet2)
320 | self.pet2.human_set.remove(self.human)
321 | human_event = TrackingEvent.objects.order_by("date").last()
322 | assert human_event.fields.all().count() == 1
323 | field = human_event.fields.get(field="pets")
324 | assert field.old_value == json.dumps([str(self.pet), str(self.pet2)])
325 | assert field.new_value == json.dumps([str(self.pet)])
326 |
327 | def test_clear(self):
328 | self.human.pets.add(self.pet2)
329 | self.human.pets.clear()
330 | human_event = TrackingEvent.objects.order_by("date").last()
331 | assert human_event.fields.all().count() == 1
332 | field = human_event.fields.get(field="pets")
333 | assert field.old_value == json.dumps([str(self.pet), str(self.pet2)])
334 | assert field.new_value == json.dumps([])
335 |
336 | def test_clear_reverse(self):
337 | self.human.pets.add(self.pet2)
338 | self.pet2.human_set.clear()
339 | human_event = TrackingEvent.objects.order_by("date").last()
340 | assert human_event.fields.all().count() == 1
341 | field = human_event.fields.get(field="pets")
342 | assert field.old_value == json.dumps([str(self.pet), str(self.pet2)])
343 | assert field.new_value == json.dumps([str(self.pet)])
344 |
345 | def test_date(self):
346 | today = datetime.date.today()
347 | self.human.birthday = today
348 | self.human.save()
349 | human_event = TrackingEvent.objects.order_by("date").last()
350 | field = human_event.fields.get(field="birthday")
351 | assert field.old_value == json.dumps(None)
352 | assert field.new_value == json.dumps(today.strftime("%Y-%m-%d"))
353 |
354 | def test_datetime(self):
355 | now = timezone.now()
356 | self.pet.vet_appointment = now
357 | self.pet.save()
358 | pet_event = TrackingEvent.objects.order_by("date").last()
359 | field = pet_event.fields.get(field="vet_appointment")
360 | assert field.old_value == json.dumps(None)
361 | assert field.new_value == json.dumps(now.strftime("%Y-%m-%d %H:%M:%S"))
362 |
363 | def test_imagefield(self):
364 | with File(open("tracking_fields/tests/__init__.py"), "picture.png") as picture:
365 | self.pet.picture = picture
366 | self.pet.save()
367 | pet_event = TrackingEvent.objects.order_by("date").last()
368 | field = pet_event.fields.get(field="picture")
369 | assert field.old_value == json.dumps(None)
370 | assert field.new_value == json.dumps(self.pet.picture.path)
371 | self.pet.picture.delete()
372 |
373 | def test_deferred_field(self):
374 | pet = Pet.objects.only('id', 'name').get(pk=self.pet.pk)
375 | pet.name = 'foo'
376 | pet.age = 42
377 | pet.save()
378 | event = TrackingEvent.objects.order_by("date").last()
379 | assert event.fields.all().count() == 1
380 | field = event.fields.get(field="name")
381 | assert field.old_value == json.dumps(str(self.pet.name))
382 | assert field.new_value == json.dumps(str(pet.name))
383 |
384 | def test_deferred_related_field(self):
385 | self.human.favourite_pet = self.pet
386 | self.human.save()
387 | human = Human.objects.only('id', 'name').get(pk=self.human.pk)
388 | human.favourite_pet = self.pet2
389 | human.name = 'foo'
390 | human.save()
391 | event = TrackingEvent.objects.order_by("date").last()
392 | assert event.fields.all().count() == 1
393 | field = event.fields.get(field="name")
394 | assert field.old_value == json.dumps(str(self.human.name))
395 | assert field.new_value == json.dumps(str(human.name))
396 |
397 |
398 | class TrackingRelatedTestCase(TestCase):
399 | def setUp(self):
400 | self.human = Human.objects.create(name="Toto", age=42, height=2)
401 | self.house = House.objects.create(tenant=self.human)
402 | self.content_type = ContentType.objects.get_for_model(House)
403 |
404 | def test_simple_change(self):
405 | self.human.name = "Tutu"
406 | self.human.save()
407 | house_event = TrackingEvent.objects.filter(
408 | object_content_type=self.content_type
409 | )
410 | assert house_event.count() == 1
411 | house_event = house_event.last()
412 | assert house_event.fields.count() == 1
413 | field = house_event.fields.last()
414 | assert field.old_value == '"Toto"'
415 | assert field.new_value == '"Tutu"'
416 | assert field.field == "tenant__name"
417 |
418 | def test_m2m_change(self):
419 | pet = Pet.objects.create(name="Pet", age=4)
420 | self.human.pets.add(pet)
421 | house_event = TrackingEvent.objects.filter(
422 | object_content_type=self.content_type
423 | )
424 | assert house_event.count() == 1
425 | house_event = house_event.last()
426 | assert house_event.fields.count() == 1
427 | field = house_event.fields.last()
428 | assert field.field == "tenant__pets"
429 | assert field.old_value == json.dumps([])
430 | assert field.new_value == json.dumps([str(pet)])
431 |
432 |
433 | class AdminModelTestCase(TestCase):
434 | @classmethod
435 | def setUpTestData(cls):
436 | cls.user = User.objects.create_superuser("admin", "", "password")
437 | cls.c = Client()
438 | cls.c.login(username="admin", password="password")
439 | CuserMiddleware.set_user(cls.user)
440 | cls.human = Human.objects.create(name="George", age=42, height=175)
441 |
442 | def test_tracker_event_user_filter_with_incorrect_user(self):
443 | """Test the admin view listing all objects given an incorrect tracking event"""
444 | user_content_type = ContentType.objects.get_for_model(User)
445 | TrackingEvent.objects.create(
446 | action="UPDATE",
447 | object=self.human,
448 | object_repr=repr(self.human),
449 | user_content_type=user_content_type,
450 | user_id=999999,
451 | user_repr="",
452 | )
453 | response = self.c.get("/admin/tracking_fields/trackingevent/")
454 | self.assertContains(
455 | response,
456 | escape(repr(self.human)),
457 | )
458 |
459 | def test_list(self):
460 | """Test the admin view listing all objects."""
461 | response = self.c.get("/admin/tracking_fields/trackingevent/")
462 | self.assertContains(
463 | response,
464 | escape(repr(self.human)),
465 | )
466 |
467 | def test_list_with_filter(self):
468 | """Test the admin view listing all objects with correct filter."""
469 | user_content_type = ContentType.objects.get_for_model(User)
470 | response = self.c.get(
471 | f"/admin/tracking_fields/trackingevent/"
472 | f"?user={user_content_type.id}:{self.user.id}"
473 | )
474 | self.assertContains(
475 | response,
476 | escape(repr(self.human)),
477 | )
478 |
479 | def test_list_with_incorrect_filter_on_user_content_type(self):
480 | """Test the admin view listing all objects with incorrect user content type
481 | filter."""
482 | response = self.c.get(
483 | f"/admin/tracking_fields/trackingevent/" f"?user=99999:{self.user.id}"
484 | )
485 | self.assertNotContains(
486 | response,
487 | escape(repr(self.human)),
488 | )
489 |
490 | def test_list_with_incorrect_filter_on_user_id(self):
491 | """Test the admin view listing all objects with incorrect user id filter."""
492 | user_content_type = ContentType.objects.get_for_model(User)
493 | response = self.c.get(
494 | f"/admin/tracking_fields/trackingevent/"
495 | f"?user={user_content_type.id}:99999"
496 | )
497 | self.assertNotContains(
498 | response,
499 | escape(repr(self.human)),
500 | )
501 |
502 | def test_list_with_incorrect_filter(self):
503 | """Test the admin view listing all objects with incorrect filter."""
504 | response = self.c.get("/admin/tracking_fields/trackingevent/?user=foobar")
505 | self.assertContains(
506 | response,
507 | escape(repr(self.human)),
508 | )
509 |
510 | def test_single(self):
511 | """Test the admin view listing all objects."""
512 | event = TrackingEvent.objects.first()
513 | url = "/admin/tracking_fields/trackingevent/{0}".format(event.pk)
514 | response = self.c.get(url, follow=True)
515 | self.assertContains(
516 | response,
517 | escape(repr(self.human)),
518 | )
519 | self.assertContains(
520 | response,
521 | escape(json.dumps(self.human.name)),
522 | )
523 |
524 | def test_history_btn(self):
525 | """Test the tracking button is present."""
526 | response = self.c.get(
527 | "/admin/tests/human/{}".format(self.human.pk),
528 | follow=True,
529 | )
530 | self.assertContains(
531 | response,
532 | ' class="historylink">Tracking',
533 | )
534 |
535 | def test_history_back_btn_is_present(self):
536 | """
537 | Test the button back to the button is present
538 | where there is an object filter.
539 | """
540 | content_type = ContentType.objects.get(app_label="tests", model="human")
541 | param = "object={0}%3A{1}".format(content_type.pk, self.human.pk)
542 | response = self.c.get(
543 | "/admin/tracking_fields/trackingevent/?{0}".format(param),
544 | follow=True,
545 | )
546 | self.assertContains(
547 | response,
548 | ' class="historylink">{0}'.format(self.human),
549 | )
550 |
551 | def test_history_back_btn_is_not_present(self):
552 | """
553 | Test the button back to the button is not present
554 | where there is no object filter.
555 | """
556 | response = self.c.get(
557 | "/admin/tracking_fields/trackingevent/",
558 | follow=True,
559 | )
560 | self.assertNotContains(
561 | response,
562 | ' class="historylink">',
563 | )
564 |
565 | def test_track_uuid_model(self):
566 | model = UuidModel.objects.create(value="foobar")
567 | model.value = "toto"
568 | model.save()
569 | model.delete()
570 |
--------------------------------------------------------------------------------
/tracking_fields/tests/urls.py:
--------------------------------------------------------------------------------
1 | try:
2 | from django.urls import re_path
3 | except ImportError:
4 | from django.conf.urls import url as re_path
5 |
6 | from django.contrib import admin
7 |
8 | urlpatterns = (re_path(r"^admin/", admin.site.urls),)
9 |
--------------------------------------------------------------------------------
/tracking_fields/tracking.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | import datetime
4 | import json
5 | import logging
6 |
7 | from django.contrib.contenttypes.models import ContentType
8 | from django.core.exceptions import ObjectDoesNotExist
9 | from django.db.models import ManyToManyField, Model
10 | from django.db.models.fields.files import FieldFile
11 | from django.db.models.fields.related import ForeignKey
12 |
13 | try:
14 | from xworkflows.base import StateWrapper
15 | except ImportError:
16 | StateWrapper = type("StateWrapper", (object,), dict())
17 |
18 | try:
19 | from cuser.middleware import CuserMiddleware
20 |
21 | CUSER = True
22 | except ImportError:
23 | CUSER = False
24 |
25 | from tracking_fields.models import (
26 | CREATE,
27 | DELETE,
28 | UPDATE,
29 | TrackedFieldModification,
30 | TrackingEvent,
31 | )
32 |
33 | logger = logging.getLogger(__name__)
34 |
35 |
36 | # ======================= HELPERS ====================
37 |
38 |
39 | def _set_original_fields(instance):
40 | """
41 | Save fields value, only for non-m2m fields.
42 | """
43 | original_fields = {}
44 |
45 | def _set_original_field(instance, field):
46 | if instance.pk is None:
47 | original_fields[field] = None
48 | else:
49 | if isinstance(instance._meta.get_field(field), ManyToManyField):
50 | # Do not store M2M manager as we won't use them
51 | return
52 | elif isinstance(instance._meta.get_field(field), ForeignKey):
53 | # Do not store deferred fields
54 | field_id = f"{field}_id"
55 | if field_id not in instance.get_deferred_fields():
56 | # Only get the PK, we don't want to get the object
57 | # (which would make an additional request)
58 | original_fields[field] = getattr(instance, field_id)
59 | else:
60 | # Do not store deferred fields
61 | if field not in instance.get_deferred_fields():
62 | original_fields[field] = getattr(instance, field)
63 |
64 | for field in getattr(instance, "_tracked_fields", []):
65 | _set_original_field(instance, field)
66 | for field in getattr(instance, "_tracked_related_fields", {}).keys():
67 | _set_original_field(instance, field)
68 |
69 | instance._original_fields = original_fields
70 | # Include pk to detect the creation of an object
71 | instance._original_fields["pk"] = instance.pk
72 |
73 |
74 | def _has_changed(instance):
75 | """
76 | Check if some tracked fields have changed
77 | """
78 | for field, value in instance._original_fields.items():
79 | if field != "pk" and not isinstance(
80 | instance._meta.get_field(field), ManyToManyField
81 | ):
82 | try:
83 | if field in getattr(instance, "_tracked_fields", []):
84 | if isinstance(instance._meta.get_field(field), ForeignKey):
85 | if getattr(instance, "{0}_id".format(field)) != value:
86 | return True
87 | else:
88 | if getattr(instance, field) != value:
89 | return True
90 | except TypeError:
91 | # Can't compare old and new value, should be different.
92 | return True
93 | return False
94 |
95 |
96 | def _has_changed_related(instance):
97 | """
98 | Check if some related tracked fields have changed
99 | """
100 | tracked_related_fields = getattr(instance, "_tracked_related_fields", {}).keys()
101 | for field, value in instance._original_fields.items():
102 | if field != "pk" and not isinstance(
103 | instance._meta.get_field(field), ManyToManyField
104 | ):
105 | if field in tracked_related_fields:
106 | if isinstance(instance._meta.get_field(field), ForeignKey):
107 | if getattr(instance, "{0}_id".format(field)) != value:
108 | return True
109 | else:
110 | if getattr(instance, field) != value:
111 | return True
112 | return False
113 |
114 |
115 | def _create_event(instance, action):
116 | """
117 | Create a new event, getting the use if django-cuser is available.
118 | """
119 | user = None
120 | user_repr = repr(user)
121 | if CUSER:
122 | user = CuserMiddleware.get_user()
123 | user_repr = repr(user)
124 | if user is not None and user.is_anonymous:
125 | user = None
126 | return TrackingEvent.objects.create(
127 | action=action,
128 | object_content_type=ContentType.objects.get_for_model(instance),
129 | object_id=instance.pk if isinstance(instance.pk, int) else None,
130 | object_repr=repr(instance),
131 | user=user,
132 | user_repr=user_repr,
133 | )
134 |
135 |
136 | def _serialize_field(field):
137 | if isinstance(field, datetime.datetime):
138 | return json.dumps(field.strftime("%Y-%m-%d %H:%M:%S"), ensure_ascii=False)
139 | if isinstance(field, datetime.date):
140 | return json.dumps(field.strftime("%Y-%m-%d"), ensure_ascii=False)
141 | if isinstance(field, FieldFile):
142 | try:
143 | return json.dumps(field.path, ensure_ascii=False)
144 | except ValueError:
145 | # No file
146 | return json.dumps(None, ensure_ascii=False)
147 | if isinstance(field, Model):
148 | return json.dumps(str(field), ensure_ascii=False)
149 | if isinstance(field, StateWrapper):
150 | return json.dumps(field.name, ensure_ascii=False)
151 | try:
152 | return json.dumps(field, ensure_ascii=False)
153 | except TypeError:
154 | logger.warning("Could not serialize field {0}".format(repr(field)))
155 | return json.dumps(repr(field), ensure_ascii=False)
156 |
157 |
158 | def _build_tracked_field(event, instance, field, fieldname=None):
159 | """
160 | Create a TrackedFieldModification for the instance.
161 |
162 | :param event: The TrackingEvent on which to add TrackingField
163 | :param instance: The instance on which the field is
164 | :param field: The field name to track
165 | :param fieldname: The displayed name for the field. Default to field.
166 | """
167 | fieldname = fieldname or field
168 | if isinstance(instance._meta.get_field(field), ForeignKey):
169 | # We only have the pk, we need to get the actual object
170 | model = instance._meta.get_field(field).remote_field.model
171 | pk = instance._original_fields[field]
172 | try:
173 | old_value = model.objects.get(pk=pk)
174 | except model.DoesNotExist:
175 | old_value = None
176 | else:
177 | old_value = instance._original_fields[field]
178 | return TrackedFieldModification(
179 | event=event,
180 | field=fieldname,
181 | old_value=_serialize_field(old_value),
182 | new_value=_serialize_field(getattr(instance, field)),
183 | )
184 |
185 |
186 | def _create_create_tracking_event(instance):
187 | """
188 | Create a TrackingEvent and TrackedFieldModification for a CREATE event.
189 | """
190 | event = _create_event(instance, CREATE)
191 | tracked_fields = [
192 | _build_tracked_field(event, instance, field)
193 | for field in instance._tracked_fields
194 | if not isinstance(instance._meta.get_field(field), ManyToManyField)
195 | ]
196 | TrackedFieldModification.objects.bulk_create(tracked_fields)
197 |
198 |
199 | def _create_update_tracking_event(instance):
200 | """
201 | Create a TrackingEvent and TrackedFieldModification for an UPDATE event.
202 | """
203 | event = _create_event(instance, UPDATE)
204 | tracked_fields = []
205 | for field in instance._tracked_fields:
206 | if not isinstance(instance._meta.get_field(field), ManyToManyField):
207 | if field not in instance._original_fields:
208 | continue
209 | try:
210 | if isinstance(instance._meta.get_field(field), ForeignKey):
211 | # Compare pk
212 | value = getattr(instance, "{0}_id".format(field))
213 | else:
214 | value = getattr(instance, field)
215 | if instance._original_fields[field] != value:
216 | tracked_fields.append(_build_tracked_field(event, instance, field))
217 | except TypeError:
218 | # Can't compare old and new value, should be different.
219 | tracked_fields.append(_build_tracked_field(event, instance, field))
220 | TrackedFieldModification.objects.bulk_create(tracked_fields)
221 |
222 |
223 | def _create_update_tracking_related_event(instance):
224 | """
225 | Create a TrackingEvent and TrackedFieldModification for an UPDATE event
226 | for each related model.
227 | """
228 | events = {}
229 | # Create a dict mapping related model field to modified fields
230 | for field, related_fields in instance._tracked_related_fields.items():
231 | if not isinstance(instance._meta.get_field(field), ManyToManyField):
232 | if field not in instance._original_fields:
233 | continue
234 | if isinstance(instance._meta.get_field(field), ForeignKey):
235 | # Compare pk
236 | value = getattr(instance, "{0}_id".format(field))
237 | else:
238 | value = getattr(instance, field)
239 | if instance._original_fields[field] != value:
240 | for related_field in related_fields:
241 | events.setdefault(related_field, []).append(field)
242 |
243 | # Create the events from the events dict
244 | tracked_fields = []
245 | for related_field, fields in events.items():
246 | if related_field[1] == "+":
247 | continue
248 | try:
249 | related_instances = getattr(instance, related_field[1])
250 | except ObjectDoesNotExist:
251 | continue
252 |
253 | # FIXME: isinstance(related_instances, RelatedManager ?)
254 | if hasattr(related_instances, "all"):
255 | related_instances = related_instances.all()
256 | else:
257 | related_instances = [related_instances]
258 | for related_instance in related_instances:
259 | event = _create_event(related_instance, UPDATE)
260 | for field in fields:
261 | fieldname = "{0}__{1}".format(related_field[0], field)
262 | tracked_fields.append(
263 | _build_tracked_field(event, instance, field, fieldname=fieldname)
264 | )
265 | TrackedFieldModification.objects.bulk_create(tracked_fields)
266 |
267 |
268 | def _create_delete_tracking_event(instance):
269 | """
270 | Create a TrackingEvent for a DELETE event.
271 | """
272 | _create_event(instance, DELETE)
273 |
274 |
275 | def _get_m2m_field(model, sender):
276 | """
277 | Get the field name from a model and a sender from m2m_changed signal.
278 | """
279 | for field in getattr(model, "_tracked_fields", []):
280 | if isinstance(model._meta.get_field(field), ManyToManyField):
281 | if getattr(model, field).through == sender:
282 | return field
283 | for field in getattr(model, "_tracked_related_fields", {}).keys():
284 | if isinstance(model._meta.get_field(field), ManyToManyField):
285 | if getattr(model, field).through == sender:
286 | return field
287 |
288 |
289 | def _build_tracked_field_m2m(event, instance, field, objects, action, fieldname=None):
290 | fieldname = fieldname or field
291 | before = list(getattr(instance, field).all())
292 | if action == "ADD":
293 | after = before + objects
294 | elif action == "REMOVE":
295 | after = [obj for obj in before if obj not in objects]
296 | elif action == "CLEAR":
297 | after = []
298 | before = list(map(str, before))
299 | after = list(map(str, after))
300 | return TrackedFieldModification(
301 | event=event,
302 | field=fieldname,
303 | old_value=json.dumps(before),
304 | new_value=json.dumps(after),
305 | )
306 |
307 |
308 | def _create_tracked_event_m2m(model, instance, sender, objects, action):
309 | """
310 | Create the ``TrackedEvent`` and it's related ``TrackedFieldModification``
311 | for a m2m modification.
312 | The first thing needed is to get the m2m field on the object being tracked.
313 | The current related objects are then taken (``old_value``).
314 | The new value is calculated in function of ``action`` (``new_value``).
315 | The ``TrackedFieldModification`` is created with the proper parameters.
316 |
317 | :param model: The model of the object being tracked.
318 | :param instance: The instance of the object being tracked.
319 | :param sender: The m2m through relationship instance.
320 | :param objects: The list of objects being added/removed.
321 | :param action: The action from the m2m_changed signal.
322 | """
323 | tracked_fields = []
324 | field = _get_m2m_field(model, sender)
325 | if field in getattr(model, "_tracked_related_fields", {}).keys():
326 | # In case of a m2m tracked on a related model
327 | related_fields = model._tracked_related_fields[field]
328 | for related_field in related_fields:
329 | try:
330 | related_instances = getattr(instance, related_field[1])
331 | except ObjectDoesNotExist:
332 | continue
333 | # FIXME: isinstance(related_instances, RelatedManager ?)
334 | if hasattr(related_instances, "all"):
335 | related_instances = related_instances.all()
336 | else:
337 | related_instances = [related_instances]
338 | for related_instance in related_instances:
339 | event = _create_event(related_instance, action)
340 | fieldname = "{0}__{1}".format(related_field[0], field)
341 | tracked_fields.append(
342 | _build_tracked_field_m2m(
343 | event, instance, field, objects, action, fieldname
344 | )
345 | )
346 | if field in getattr(model, "_tracked_fields", []):
347 | event = _create_event(instance, action)
348 | tracked_fields.append(
349 | _build_tracked_field_m2m(event, instance, field, objects, action)
350 | )
351 | TrackedFieldModification.objects.bulk_create(tracked_fields)
352 |
353 |
354 | # ======================= CALLBACKS ====================
355 |
356 |
357 | def tracking_init(sender, instance, **kwargs):
358 | """
359 | Post init, save the current state of the object to compare it before a save
360 | """
361 | _set_original_fields(instance)
362 |
363 |
364 | def tracking_save(sender, instance, raw, using, update_fields, **kwargs):
365 | """
366 | Post save, detect creation or changes and log them.
367 | We need post_save to have the object for a create.
368 | """
369 | if _has_changed(instance):
370 | if instance._original_fields["pk"] is None:
371 | # Create
372 | _create_create_tracking_event(instance)
373 | else:
374 | # Update
375 | _create_update_tracking_event(instance)
376 | if _has_changed_related(instance):
377 | # Because an object need to be saved before being related,
378 | # it can only be an update
379 | _create_update_tracking_related_event(instance)
380 | if _has_changed(instance) or _has_changed_related(instance):
381 | _set_original_fields(instance)
382 |
383 |
384 | def tracking_delete(sender, instance, using, **kwargs):
385 | """
386 | Post delete callback
387 | """
388 | _create_delete_tracking_event(instance)
389 |
390 |
391 | def tracking_m2m(sender, instance, action, reverse, model, pk_set, using, **kwargs):
392 | """
393 | m2m_changed callback.
394 | The idea is to get the model and the instance of the object being tracked,
395 | and the different objects being added/removed. It is then send to the
396 | ``_build_tracked_field_m2m`` method to extract the proper attribute for
397 | the TrackedFieldModification.
398 | """
399 | action_event = {
400 | "pre_clear": "CLEAR",
401 | "pre_add": "ADD",
402 | "pre_remove": "REMOVE",
403 | }
404 | if action not in action_event.keys():
405 | return
406 | if reverse:
407 | if action == "pre_clear":
408 | # It will actually be a remove of ``instance`` on every
409 | # tracked object being related
410 | action = "pre_remove"
411 | # pk_set is None for clear events, we need to get objects' pk.
412 | field = _get_m2m_field(model, sender)
413 | field = model._meta.get_field(field).remote_field.get_accessor_name()
414 | pk_set = set([obj.id for obj in getattr(instance, field).all()])
415 | # Create an event for each object being tracked
416 | for pk in pk_set:
417 | tracked_instance = model.objects.get(pk=pk)
418 | objects = [instance]
419 | _create_tracked_event_m2m(
420 | model, tracked_instance, sender, objects, action_event[action]
421 | )
422 | else:
423 | # Get the model of the object being tracked
424 | tracked_model = instance._meta.model
425 | objects = []
426 | if pk_set is not None:
427 | objects = [model.objects.get(pk=pk) for pk in pk_set]
428 | _create_tracked_event_m2m(
429 | tracked_model, instance, sender, objects, action_event[action]
430 | )
431 |
--------------------------------------------------------------------------------