0001-nanterre-d-tecte-et-supprime-les-fiches-inactives-fi.patch
debian/control | ||
---|---|---|
17 | 17 |
python-psycopg2, |
18 | 18 |
python-memcache, |
19 | 19 |
python-django-mellon, |
20 |
python-cached-property, |
|
20 | 21 |
uwsgi |
21 | 22 |
Recommends: nginx, postgresql, memcached |
22 | 23 |
Description: Maintain a graph of objects |
setup.py | ||
---|---|---|
108 | 108 |
'python-dateutil', |
109 | 109 |
'django-admin-rangefilter', |
110 | 110 |
'requests', |
111 |
'cached-property', |
|
111 | 112 |
], |
112 | 113 |
zip_safe=False, |
113 | 114 |
cmdclass={ |
zoo/zoo_nanterre/apps.py | ||
---|---|---|
95 | 95 |
kwargs={'model_admin': model_admin}, |
96 | 96 |
name='synchronize-federations' + desc['name'], |
97 | 97 |
)) |
98 |
urls.append(url( |
|
99 |
r'^inactive/', |
|
100 |
model_admin.admin_site.admin_view( |
|
101 |
getattr(views, 'inactive_index')), |
|
102 |
kwargs={'model_admin': model_admin}, |
|
103 |
name='inactive-index', |
|
104 |
)) |
|
98 | 105 |
return urls |
99 | 106 | |
100 | 107 |
def post_migrate(self, *args, **kwargs): |
zoo/zoo_nanterre/inactivity.py | ||
---|---|---|
1 |
# zoo - versatile objects management |
|
2 |
# Copyright (C) 2016 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
import datetime |
|
18 |
import operator |
|
19 |
try: |
|
20 |
from functools import reduce |
|
21 |
except ImportError: |
|
22 |
pass |
|
23 | ||
24 |
from django.db.models.query import Q |
|
25 |
from django.utils.timezone import now |
|
26 | ||
27 |
from cached_property import cached_property |
|
28 | ||
29 |
from zoo.zoo_data.models import Entity |
|
30 | ||
31 |
from . import utils |
|
32 | ||
33 | ||
34 |
class Inactivity(object): |
|
35 |
def __init__(self, child_delay=365, adult_delay=365): |
|
36 |
self.child_delay = child_delay |
|
37 |
self.adult_delay = adult_delay |
|
38 | ||
39 |
@property |
|
40 |
def child_threshold(self): |
|
41 |
return now() - datetime.timedelta(days=self.child_delay) |
|
42 | ||
43 |
@property |
|
44 |
def adult_threshold(self): |
|
45 |
return now() - datetime.timedelta(days=self.adult_delay) |
|
46 | ||
47 |
def exclude_newer_than_threshold(self, qs, threshold): |
|
48 |
return qs.exclude( |
|
49 |
created__created__gte=threshold, |
|
50 |
modified__created__gte=threshold, |
|
51 |
log__timestamp__gte=threshold) |
|
52 | ||
53 |
@property |
|
54 |
def query_no_federation(self): |
|
55 |
queries = [] |
|
56 | ||
57 |
for app_id in utils.get_applications(rsu_ws_url=True): |
|
58 |
query = Q(**{'content__cles_de_federation__%s__isnull' % app_id: True}) |
|
59 |
query |= Q(**{'content__cles_de_federation__%s__exact' % app_id: ''}) |
|
60 |
queries.append(query) |
|
61 |
return reduce(operator.__and__, queries) |
|
62 | ||
63 |
def filter_no_federation(self, qs): |
|
64 |
return qs.filter(self.query_no_federation) |
|
65 | ||
66 |
@property |
|
67 |
def entities(self): |
|
68 |
qs = Entity.objects.all() |
|
69 |
# prefetch to optimize accesors to spouses and siblings |
|
70 |
qs = qs.prefetch_related( |
|
71 |
'left_relations__schema', 'left_relations__right', |
|
72 |
'right_relations__schema', 'right_relations__left', |
|
73 |
'right_relations__left__left_relations__left', |
|
74 |
'right_relations__left__left_relations__schema', |
|
75 |
) |
|
76 |
return qs |
|
77 | ||
78 |
@property |
|
79 |
def children(self): |
|
80 |
return self.entities.filter(content__statut_legal='mineur') |
|
81 | ||
82 |
@property |
|
83 |
def adults(self): |
|
84 |
return self.entities.filter(content__statut_legal='majeur') |
|
85 | ||
86 |
@cached_property |
|
87 |
def deletable_children(self): |
|
88 |
potent = self.exclude_newer_than_threshold(self.filter_no_federation(self.children), self.child_threshold) |
|
89 |
potent_ids = potent.values_list('id', flat=True) |
|
90 | ||
91 |
def filter_siblings_are_potent(): |
|
92 |
for child in potent: |
|
93 |
for sibling in utils.fratrie(child): |
|
94 |
if sibling.id not in potent_ids: |
|
95 |
break |
|
96 |
else: |
|
97 |
yield child.id |
|
98 |
potent2_ids = list(filter_siblings_are_potent()) |
|
99 | ||
100 |
return potent.filter(id__in=potent2_ids) |
|
101 | ||
102 |
@cached_property |
|
103 |
def deletable_adults(self): |
|
104 |
deletable_children_ids = self.deletable_children.values_list('id', flat=True) |
|
105 |
potent = self.exclude_newer_than_threshold(self.filter_no_federation(self.adults), self.adult_threshold) |
|
106 | ||
107 |
def filter_children_are_deletable(): |
|
108 |
for adult in potent: |
|
109 |
for enfant, rele in utils.enfants(adult): |
|
110 |
if enfant.id not in deletable_children_ids: |
|
111 |
break |
|
112 |
else: |
|
113 |
yield adult |
|
114 |
potent2 = list(filter_children_are_deletable()) |
|
115 |
potent2_ids = [adult.id for adult in potent2] |
|
116 | ||
117 |
def filter_spouse_is_deletable(): |
|
118 |
for adult in potent2: |
|
119 |
conjoint = utils.conjoint(adult)[0] |
|
120 |
if conjoint and conjoint.id not in potent2_ids: |
|
121 |
continue |
|
122 |
yield adult.id |
|
123 |
return potent.filter(id__in=filter_spouse_is_deletable()) |
zoo/zoo_nanterre/models.py | ||
---|---|---|
1 |
# zoo - versatile objects management |
|
2 |
# Copyright (C) 2016 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
1 | 17 |
from django.utils.translation import ugettext_lazy as _ |
2 | 18 |
from django.contrib.postgres.fields import JSONField |
3 | 19 |
from django.db import models |
zoo/zoo_nanterre/templates/admin/index.html | ||
---|---|---|
61 | 61 |
<td> </td> |
62 | 62 |
</tr> |
63 | 63 |
{% endif %} |
64 |
{% if perms.zoo_data.action2_entity %} |
|
65 |
<tr> |
|
66 |
<th scope="row"> |
|
67 |
<a href="{% url "admin:inactive-index" %}"> |
|
68 |
Fiches inactives |
|
69 |
</a> |
|
70 |
</th> |
|
71 |
<td> </td> |
|
72 |
<td> </td> |
|
73 |
</tr> |
|
74 |
{% endif %} |
|
64 | 75 |
</table> |
65 | 76 |
</div> |
66 | 77 |
</div> |
zoo/zoo_nanterre/templates/admin/zoo_data/entity/inactive_index.html | ||
---|---|---|
1 |
{% extends "admin/base_site.html" %} |
|
2 |
{% load i18n admin_urls static %} |
|
3 | ||
4 |
{% block extrastyle %} |
|
5 |
{{ block.super }} |
|
6 |
<link rel="stylesheet" type="text/css" href="{% static "admin/css/changelists.css" %}" /> |
|
7 |
{% endblock %} |
|
8 | ||
9 |
{% block coltype %}flex{% endblock %} |
|
10 | ||
11 | ||
12 |
{% block breadcrumbs %} |
|
13 |
<div class="breadcrumbs"> |
|
14 |
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a> |
|
15 |
› Fiches inactives |
|
16 |
</div> |
|
17 |
{% endblock %} |
|
18 | ||
19 |
{% block content %} |
|
20 |
<div id="content-main"> |
|
21 |
<ul class="object-tools"> |
|
22 |
<li><a href="?recompute">Recalculer</a></li> |
|
23 |
<li><a href="?csv">Export CSV</a></li> |
|
24 |
<li><a href="#" onclick="document.getElementById('delete-button').click()">Supprimer</a></li> |
|
25 |
<form method="post" action="?delete" style="display: none">{% csrf_token %}<button id="delete-button">Supprimer</button></form> |
|
26 |
</ul> |
|
27 |
<div style="float: right"> |
|
28 |
<p>{{ fiches|length }} fiches. Dernier calcul le {{ timestamp }}. {% if duration %} Durée {{ duration }} secondes.{% endif %}{% if queries %}{{ queries }} requêtes.{% endif %}</p> |
|
29 |
<h3>Explication du calcul</h3> |
|
30 |
<ul> |
|
31 |
<li>Une fiche sans activité est une fiche dont la dernière date de modification et les dernières lignes du journal n'ont pas bougé depuis un certain nombre de jours</li> |
|
32 |
<li>Les fiches enfants sans activité depuis {{ child_delay }} jours sont considérées inactives.</li> |
|
33 |
<li>Les fiches adultes sans activité depuis {{ adult_delay }} jours sont considérées inactives.</li> |
|
34 |
<li>Les fratries (tous les enfants partageant au moins un parent commun) inactives et sans fédérations sont à supprimer.</li> |
|
35 |
<li>Les adultes sans liens matrimoniaux, inactifs, sans fédérations et dont tous les enfants sont à supprimer sont à supprimer.</li> |
|
36 |
<li>Les couples inactifs, sans fédérations et dont tous les enfants sont à supprimer sont à supprimer.</li> |
|
37 |
</ul> |
|
38 |
<h3>Configuration</h3> |
|
39 |
<ul> |
|
40 |
<li>Le délai d'inactivité en jours pour les fiches enfants est configuré par le setting <tt>ZOO_NANTERRE_INACTIVITY_CHILD_DELAY</tt></li> |
|
41 |
<li>Le délai d'inactivité en jours pour les fiches adultes est configuré par le setting <tt>ZOO_NANTERRE_INACTIVITY_ADULT_DELAY</tt></li> |
|
42 |
</ul> |
|
43 |
</div> |
|
44 |
<table id="result-list"> |
|
45 |
{% comment %}Fiches inactives{% endcomment %} |
|
46 |
<thead> |
|
47 |
<tr> |
|
48 |
<th>Identifiant RSU</th> |
|
49 |
<th>Prénom</th> |
|
50 |
<th>Nom d'usage</th> |
|
51 |
<th>Nom de naissance</th> |
|
52 |
<th>Date de naissance</th> |
|
53 |
<th>Statut légal</th> |
|
54 |
<th>Âge</th> |
|
55 |
</tr> |
|
56 |
</thead> |
|
57 |
<tbody> |
|
58 |
{% for fiche in fiches %} |
|
59 |
<tr> |
|
60 |
<td>{{ fiche.id }}</td> |
|
61 |
<td>{{ fiche.prenoms }}</td> |
|
62 |
<td>{{ fiche.nom_d_usage }}</td> |
|
63 |
<td>{{ fiche.nom_de_naissance }}</td> |
|
64 |
<td>{{ fiche.date_de_naissance }}</td> |
|
65 |
<td>{{ fiche.statut_legal }}</td> |
|
66 |
<td>{{ fiche.age }}</td> |
|
67 |
</tr> |
|
68 |
{% endfor %} |
|
69 |
</tbody> |
|
70 |
</table> |
|
71 |
</div> |
|
72 |
{% endblock %} |
zoo/zoo_nanterre/utils.py | ||
---|---|---|
20 | 20 |
import six |
21 | 21 |
import functools |
22 | 22 |
import uuid |
23 |
import io |
|
24 |
import csv |
|
23 | 25 | |
24 | 26 |
import sys |
25 | 27 |
import re |
... | ... | |
39 | 41 |
from django.db.models import Q, F, Value, ExpressionWrapper, CharField, When, Case |
40 | 42 |
from django.db.models.functions import Least, Greatest, Coalesce, Concat |
41 | 43 |
from django.db import transaction |
42 |
from django.utils.timezone import now, make_aware |
|
43 | 44 |
from django.contrib.auth.hashers import make_password |
45 |
from django.http import HttpResponse |
|
46 | ||
47 |
from django.utils.timezone import now, make_aware |
|
48 |
from django.utils.encoding import force_bytes |
|
44 | 49 | |
45 | 50 |
from zoo.zoo_meta.models import EntitySchema, RelationSchema |
46 | 51 |
from zoo.zoo_data.models import Entity, Relation, Transaction, Log |
... | ... | |
141 | 146 |
return adresses |
142 | 147 | |
143 | 148 | |
149 |
def fratrie(individu): |
|
150 |
assert individu.content['statut_legal'] == 'mineur' |
|
151 | ||
152 |
def helper_fratrie(): |
|
153 |
for parent, relp in parents(individu): |
|
154 |
for enfant, rele in enfants(parent): |
|
155 |
yield enfant |
|
156 |
return set(helper_fratrie()) |
|
157 | ||
158 | ||
144 | 159 |
def adresses_norel(individu): |
145 | 160 |
return [adresse for adresse, rel in adresses(individu)] |
146 | 161 | |
... | ... | |
1249 | 1264 |
if c['date_de_naissance']: |
1250 | 1265 |
s += u' - ' + c['date_de_naissance'] |
1251 | 1266 |
return s |
1267 | ||
1268 | ||
1269 |
def csv_export_response(rows, filename): |
|
1270 |
if sys.version >= (3,): |
|
1271 |
with io.StringIO(newline='') as f: |
|
1272 |
writer = csv.writer(f) |
|
1273 |
for row in rows: |
|
1274 |
writer.writerow(map(str, row)) |
|
1275 |
r = HttpResponse(f.getvalue(), content_type='text/csv') |
|
1276 |
else: |
|
1277 |
with io.BytesIO() as f: |
|
1278 |
writer = csv.writer(f) |
|
1279 |
for row in rows: |
|
1280 |
writer.writerow(map(force_bytes, row)) |
|
1281 |
r = HttpResponse(f.getvalue(), content_type='text/csv') |
|
1282 |
r['Content-Disposition'] = 'attachment; filename="%s"' % filename |
|
1283 |
return r |
|
1284 |
zoo/zoo_nanterre/views.py | ||
---|---|---|
17 | 17 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
18 | 18 | |
19 | 19 |
import csv |
20 |
import itertools |
|
21 |
import time |
|
20 | 22 | |
21 | 23 |
from django.template.response import TemplateResponse |
22 | 24 |
from django.views.generic import TemplateView |
23 | 25 |
from django.shortcuts import redirect, get_object_or_404 |
24 |
from django.http import Http404, FileResponse |
|
25 |
from django.db.transaction import non_atomic_requests |
|
26 |
from django.http import Http404, FileResponse, HttpResponseRedirect
|
|
27 |
from django.db.transaction import non_atomic_requests, atomic
|
|
26 | 28 |
from django.db import connection |
29 |
from django.conf import settings |
|
30 |
from django.core.cache import cache |
|
31 |
from django.utils.timezone import now |
|
32 | ||
27 | 33 |
from django.contrib.auth.decorators import permission_required |
34 |
from django.contrib import messages |
|
28 | 35 | |
29 |
from zoo.zoo_data.models import Job
|
|
36 |
from zoo.zoo_data.models import Entity
|
|
30 | 37 | |
31 | 38 |
from . import forms |
32 | 39 |
from .synchronize_federations import SynchronizeFederationsAction |
40 |
from .inactivity import Inactivity |
|
41 |
from . import utils |
|
33 | 42 | |
34 | 43 | |
35 | 44 |
class Demo(TemplateView): |
... | ... | |
186 | 195 |
raise Http404 |
187 | 196 |
job.action.set_apply(job) |
188 | 197 |
return redirect('admin:synchronize-federations') |
198 | ||
199 | ||
200 |
def fiches_inactives(): |
|
201 |
inactivity = Inactivity( |
|
202 |
child_delay=getattr(settings, 'ZOO_NANTERRE_INACTIVITY_CHILD_DELAY', 365), |
|
203 |
adult_delay=getattr(settings, 'ZOO_NANTERRE_INACTIVITY_ADULT_DELAY', 365), |
|
204 |
) |
|
205 |
fiches = [] |
|
206 |
for child in itertools.chain(inactivity.deletable_children, inactivity.deletable_adults): |
|
207 |
utils.PersonSearch.add_age(child) |
|
208 |
fiches.append({ |
|
209 |
'id': child.id, |
|
210 |
'prenoms': child.content['prenoms'], |
|
211 |
'nom_de_naissance': child.content['nom_de_naissance'], |
|
212 |
'nom_d_usage': child.content['nom_d_usage'], |
|
213 |
'date_de_naissance': child.content['date_de_naissance'], |
|
214 |
'statut_legal': child.content['statut_legal'], |
|
215 |
'age': child.age_label, |
|
216 |
}) |
|
217 |
fiches.sort(key=lambda f: f['id']) |
|
218 |
return fiches |
|
219 | ||
220 | ||
221 |
@permission_required('zoo_data.action1_entity') |
|
222 |
@atomic |
|
223 |
def inactive_index(request, model_admin, *args, **kwargs): |
|
224 |
try: |
|
225 |
timestamp, fiches = cache.get('fiches-inactives') |
|
226 |
if 'recompute' in request.GET: |
|
227 |
cache.delete('fiches-inactives') |
|
228 |
return HttpResponseRedirect(request.path) |
|
229 |
duration = None |
|
230 |
queries = None |
|
231 |
except (TypeError, ValueError): |
|
232 |
try: |
|
233 |
connection.force_debug_cursor = True |
|
234 |
start = time.time() |
|
235 |
fiches = fiches_inactives() |
|
236 |
queries = len(connection.queries_log) |
|
237 |
duration = time.time() - start |
|
238 |
timestamp = now() |
|
239 |
cache.set('fiches-inactives', (timestamp, fiches), 365 * 24 * 3600) |
|
240 |
finally: |
|
241 |
connection.force_debug_cursor = False |
|
242 | ||
243 |
# delete operation |
|
244 |
if 'delete' in request.GET and request.method == 'POST': |
|
245 |
Entity.objects.filter(id__in=[fiche['id'] for fiche in fiches]).delete() |
|
246 |
messages.info(request, '%d fiches ont été supprimées.' % len(fiches)) |
|
247 |
cache.delete('fiches-inactives') |
|
248 |
return HttpResponseRedirect(request.path) |
|
249 | ||
250 |
# download csv export |
|
251 |
if 'csv' in request.GET: |
|
252 |
header = ['id', 'prenoms', 'nom_d_usage', 'nom_de_naissance', |
|
253 |
'date_de_naissance', 'statut_legal', 'age'] |
|
254 | ||
255 |
def rows(): |
|
256 |
yield header |
|
257 |
for fiche in fiches: |
|
258 |
yield [fiche[key] for key in header] |
|
259 |
return utils.csv_export_response(rows(), 'fiches-inactives-%s.csv' % timestamp) |
|
260 | ||
261 |
context = dict( |
|
262 |
model_admin.admin_site.each_context(request), |
|
263 |
title='Fiches inactives à supprimer', |
|
264 |
fiches=fiches, |
|
265 |
timestamp=timestamp, |
|
266 |
duration=duration, |
|
267 |
queries=queries, |
|
268 |
child_delay=getattr(settings, 'ZOO_NANTERRE_INACTIVITY_CHILD_DELAY', 365), |
|
269 |
adult_delay=getattr(settings, 'ZOO_NANTERRE_INACTIVITY_ADULT_DELAY', 365), |
|
270 |
) |
|
271 |
return TemplateResponse(request, "admin/zoo_data/entity/inactive_index.html", context) |
|
189 |
- |