models.py 31.2 KB
Newer Older
jr-garnier's avatar
jr-garnier committed
1
# -*- mode: python; coding: utf-8 -*-
nanoy's avatar
nanoy committed
2
# Re2o est un logiciel d'administration développé initiallement au Rézo Metz. Il
jr-garnier's avatar
jr-garnier committed
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# se veut agnostique au réseau considéré, de manière à être installable en
# quelques clics.
#
# Copyright © 2020  Jean-Romain Garnier
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
21
22
"""logs.models
The models definitions for the logs app
jr-garnier's avatar
jr-garnier committed
23
"""
jr-garnier's avatar
jr-garnier committed
24
from reversion.models import Version, Revision
25
26
from django.utils.translation import ugettext_lazy as _
from django.contrib.auth.models import Group
jr-garnier's avatar
jr-garnier committed
27
from django.db.models import Q
jr-garnier's avatar
jr-garnier committed
28
from django.apps import apps
29
from netaddr import EUI
30
from macaddress.fields import default_dialect
31

jr-garnier's avatar
jr-garnier committed
32
33
34
from machines.models import IpList
from machines.models import Interface
from machines.models import Machine
35
from machines.models import MachineType
jr-garnier's avatar
jr-garnier committed
36
from users.models import User
37
38
39
from users.models import Adherent
from users.models import Club
from topologie.models import Room
40
from topologie.models import Port
jr-garnier's avatar
jr-garnier committed
41

42
from .forms import classes_for_action_type
jr-garnier's avatar
jr-garnier committed
43

jr-garnier's avatar
jr-garnier committed
44

45
46
47
48
49
50
51
52
def make_version_filter(key, value):
    """
    Builds a filter for a Version object to filter by argument in its
    serialized_date
    :param key: str, The argument's key
    :param value: str or int, The argument's value
    :returns: A Q filter
    """
53
54
    # The lookup is done in a json string, so it has to be formated
    # based on the value's type (to add " or not)
55
56
57
58
59
60
61
    if type(value) is str:
        formatted_value = "\"{}\"".format(value)
    else:
        formatted_value = str(value)

    return (
        Q(serialized_data__contains='\"{}\": {},'.format(key, formatted_value))
jr-garnier's avatar
jr-garnier committed
62
        | Q(serialized_data__contains='\"{}\": {}}}'.format(key, formatted_value))
63
64
65
    )


66
67
68
69
############################
#  Machine history search  #
############################

70
class MachineHistorySearchEvent:
71
    def __init__(self, user, machine, interface, start=None, end=None):
72
73
74
75
76
77
78
79
80
81
        """Initialise an instance of MachineHistorySearchEvent.

        Args:
            user: User, the user owning the machine at the time of the event.
            machine: Version, the machine version related to the interface.
            interface: Version, the interface targeted by this event.
            start: datetime, the date at which this version was created
                (default: None).
            end: datetime, the date at which this version was replace by a new
                one (default: None).
82
        """
jr-garnier's avatar
jr-garnier committed
83
84
85
86
87
88
89
90
91
92
        self.user = user
        self.machine = machine
        self.interface = interface
        self.ipv4 = IpList.objects.get(id=interface.field_dict["ipv4_id"]).ipv4
        self.mac = self.interface.field_dict["mac_address"]
        self.start_date = start
        self.end_date = end
        self.comment = interface.revision.get_comment() or None

    def is_similar(self, elt2):
93
94
95
96
97
98
99
        """Check whether two events are similar enough to be merged.

        Args:
            elt2: MachineHistorySearchEvent, the event to compare with self.

        Returns:
            A boolean, True if the events can be merged and False otherwise.
jr-garnier's avatar
jr-garnier committed
100
        """
jr-garnier's avatar
jr-garnier committed
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
        return (
            elt2 is not None
            and self.user.id == elt2.user.id
            and self.ipv4 == elt2.ipv4
            and self.machine.field_dict["id"] == elt2.machine.field_dict["id"]
            and self.interface.field_dict["id"] == elt2.interface.field_dict["id"]
        )

    def __repr__(self):
        return "{} ({} - ): from {} to {} ({})".format(
            self.machine,
            self.mac,
            self.ipv4,
            self.start_date,
            self.end_date,
            self.comment or "No comment"
        )


120
class MachineHistorySearch:
jr-garnier's avatar
jr-garnier committed
121
122
    def __init__(self):
        self.events = []
jr-garnier's avatar
jr-garnier committed
123
        self._last_evt = None
jr-garnier's avatar
jr-garnier committed
124
125

    def get(self, search, params):
126
127
128
129
130
131
132
133
        """Get the events in machine histories related to the search.

        Args:
            search: the IP or MAC address used in the search.
            params: the dictionary built by the search view.

        Returns:
            A list of MachineHistorySearchEvent in reverse chronological order.
134
        """
jr-garnier's avatar
jr-garnier committed
135
136
137
138
139
140
        self.start = params.get("s", None)
        self.end = params.get("e", None)
        search_type = params.get("t", 0)

        self.events = []
        if search_type == "ip":
141
142
143
144
            try:
                return self._get_by_ip(search)[::-1]
            except:
                pass
jr-garnier's avatar
jr-garnier committed
145
        elif search_type == "mac":
146
            try:
147
                search = EUI(search, dialect=default_dialect())
148
149
150
                return self._get_by_mac(search)[::-1]
            except:
                pass
jr-garnier's avatar
jr-garnier committed
151

152
        return []
jr-garnier's avatar
jr-garnier committed
153

jr-garnier's avatar
jr-garnier committed
154
    def _add_revision(self, user, machine, interface):
155
156
157
158
159
160
        """Add a new revision to the chronological order.

        Args:
            user: User, the user owning the maching at the time of the event.
            machine: Version, the machine version related to the interface.
            interface: Version, the interface targeted by this event.
jr-garnier's avatar
jr-garnier committed
161
        """
162
        evt = MachineHistorySearchEvent(user, machine, interface)
jr-garnier's avatar
jr-garnier committed
163
164
        evt.start_date = interface.revision.date_created

jr-garnier's avatar
jr-garnier committed
165
        # Try not to recreate events if it's unnecessary
jr-garnier's avatar
jr-garnier committed
166
        if evt.is_similar(self._last_evt):
jr-garnier's avatar
jr-garnier committed
167
168
169
            return

        # Mark the end of validity of the last element
jr-garnier's avatar
jr-garnier committed
170
171
        if self._last_evt and not self._last_evt.end_date:
            self._last_evt.end_date = evt.start_date
jr-garnier's avatar
jr-garnier committed
172
173

            # If the event ends before the given date, remove it
174
            if self.start and evt.start_date.date() < self.start:
jr-garnier's avatar
jr-garnier committed
175
                self._last_evt = None
jr-garnier's avatar
jr-garnier committed
176
177
178
                self.events.pop()

        # Make sure the new event starts before the given end date
179
        if self.end and evt.start_date.date() > self.end:
jr-garnier's avatar
jr-garnier committed
180
181
182
183
            return

        # Save the new element
        self.events.append(evt)
jr-garnier's avatar
jr-garnier committed
184
        self._last_evt = evt
jr-garnier's avatar
jr-garnier committed
185

jr-garnier's avatar
jr-garnier committed
186
    def _get_interfaces_for_ip(self, ip):
187
188
189
190
191
192
193
194
195
        """Get the Version objects of interfaces with the given IP
        address.

        Args:
            ip: the string corresponding to the IP address.

        Returns:
            An iterable object with the Version objects of interfaces with the
            given IP address.
jr-garnier's avatar
jr-garnier committed
196
        """
jr-garnier's avatar
jr-garnier committed
197
198
199
200
201
202
        # TODO: What if ip list was deleted?
        try:
            ip_id = IpList.objects.get(ipv4=ip).id
        except IpList.DoesNotExist:
            return []

203
204
        return (
            Version.objects.get_for_model(Interface)
205
            .filter(make_version_filter("ipv4", ip_id))
206
            .order_by("revision__date_created")
jr-garnier's avatar
jr-garnier committed
207
208
        )

jr-garnier's avatar
jr-garnier committed
209
    def _get_interfaces_for_mac(self, mac):
210
211
212
213
214
215
216
217
218
        """Get the Version objects of interfaces with the given MAC
        address.

        Args:
            mac: the string corresponding to the MAC address.

        Returns:
            An iterable object with the Version objects of interfaces with the
            given MAC address.
jr-garnier's avatar
jr-garnier committed
219
        """
220
221
        return (
            Version.objects.get_for_model(Interface)
222
            .filter(make_version_filter("mac_address", str(mac)))
223
            .order_by("revision__date_created")
jr-garnier's avatar
jr-garnier committed
224
225
        )

jr-garnier's avatar
jr-garnier committed
226
    def _get_machines_for_interface(self, interface):
227
228
229
230
231
232
233
234
        """Get the Version objects of machines with the given interface.

        Args:
            interface: Version, the interface used to find machines.

        Returns:
            An iterable object with the Version objects of machines to which
            the given interface was assigned.
jr-garnier's avatar
jr-garnier committed
235
236
        """
        machine_id = interface.field_dict["machine_id"]
237
238
        return (
            Version.objects.get_for_model(Machine)
239
            .filter(make_version_filter("pk", machine_id))
240
            .order_by("revision__date_created")
jr-garnier's avatar
jr-garnier committed
241
242
        )

jr-garnier's avatar
jr-garnier committed
243
    def _get_user_for_machine(self, machine):
244
245
246
247
248
249
250
        """Get the User instance owning the given machine.

        Args:
            machine: Version, the machine used to find its owner.

        Returns:
            The User instance of the owner of the given machine.
jr-garnier's avatar
jr-garnier committed
251
252
253
254
255
        """
        # TODO: What if user was deleted?
        user_id = machine.field_dict["user_id"]
        return User.objects.get(id=user_id)

jr-garnier's avatar
jr-garnier committed
256
    def _get_by_ip(self, ip):
257
258
259
260
261
262
263
264
        """Get events related to the given IP address.

        Args:
            ip: the string corresponding to the IP address.

        Returns:
            A list of MachineHistorySearchEvent related to the given IP
            address.
265
        """
jr-garnier's avatar
jr-garnier committed
266
        interfaces = self._get_interfaces_for_ip(ip)
jr-garnier's avatar
jr-garnier committed
267
268

        for interface in interfaces:
jr-garnier's avatar
jr-garnier committed
269
            machines = self._get_machines_for_interface(interface)
jr-garnier's avatar
jr-garnier committed
270
271

            for machine in machines:
jr-garnier's avatar
jr-garnier committed
272
273
                user = self._get_user_for_machine(machine)
                self._add_revision(user, machine, interface)
jr-garnier's avatar
jr-garnier committed
274
275
276

        return self.events

jr-garnier's avatar
jr-garnier committed
277
    def _get_by_mac(self, mac):
278
279
280
281
282
283
284
285
        """Get events related to the given MAC address.

        Args:
            mac: the string corresponding to the MAC address.

        Returns:
            A list of MachineHistorySearchEvent related to the given MAC
            address.
286
        """
jr-garnier's avatar
jr-garnier committed
287
        interfaces = self._get_interfaces_for_mac(mac)
jr-garnier's avatar
jr-garnier committed
288
289

        for interface in interfaces:
jr-garnier's avatar
jr-garnier committed
290
            machines = self._get_machines_for_interface(interface)
jr-garnier's avatar
jr-garnier committed
291
292

            for machine in machines:
jr-garnier's avatar
jr-garnier committed
293
294
                user = self._get_user_for_machine(machine)
                self._add_revision(user, machine, interface)
jr-garnier's avatar
jr-garnier committed
295
296

        return self.events
297
298


299
300
301
302
############################
#  Generic history classes #
############################

jr-garnier's avatar
jr-garnier committed
303
class RelatedHistory:
jr-garnier's avatar
jr-garnier committed
304
    def __init__(self, version):
305
306
307
308
        """Initialise an instance of RelatedHistory.

        Args:
            version: Version, the version related to the history.
jr-garnier's avatar
jr-garnier committed
309
        """
jr-garnier's avatar
jr-garnier committed
310
311
312
313
        self.version = version
        self.app_name = version.content_type.app_label
        self.model_name = version.content_type.model
        self.object_id = version.object_id
314
        self.name = version.object_repr
jr-garnier's avatar
jr-garnier committed
315

316
317
318
        if self.model_name:
            self.name = "{}: {}".format(self.model_name.title(), self.name)

319
320
    def __eq__(self, other):
        return (
321
            self.model_name == other.model_name
jr-garnier's avatar
jr-garnier committed
322
            and self.object_id == other.object_id
323
324
325
        )

    def __hash__(self):
326
        return hash((self.model_name, self.object_id))
327

jr-garnier's avatar
jr-garnier committed
328

jr-garnier's avatar
jr-garnier committed
329
330
class HistoryEvent:
    def __init__(self, version, previous_version=None, edited_fields=None):
331
332
333
334
335
336
337
338
        """Initialise an instance of HistoryEvent.

        Args:
            version: Version, the version of the object for this event.
            previous_version: Version, the version of the object before this
                event (default: None).
            edited_fields: list, The list of modified fields by this event
                (default: None).
339
340
341
        """
        self.version = version
        self.previous_version = previous_version
342
        self.edited_fields = edited_fields or []
343
344
345
346
        self.date = version.revision.date_created
        self.performed_by = version.revision.user
        self.comment = version.revision.get_comment() or None

jr-garnier's avatar
jr-garnier committed
347
    def _repr(self, name, value):
348
349
350
351
352
353
354
355
356
        """Get the appropriate representation of the given field.

        Args:
            name: the name of the field
            value: the value of the field

        Returns:
            The string corresponding to the appropriate representation of the
            given field.
jr-garnier's avatar
jr-garnier committed
357
358
359
360
361
362
        """
        if value is None:
            return _("None")

        return value

363
    def edits(self, hide=["password", "pwd_ntlm"]):
364
365
366
367
368
369
370
371
        """Get the list of the changes performed during this event.

        Args:
            hide: the list of fields for which not to show details (default:
            []).

        Returns:
            The list of fields edited by the event to display.
jr-garnier's avatar
jr-garnier committed
372
373
374
375
        """
        edits = []

        for field in self.edited_fields:
Jean-Romain Garnier's avatar
Jean-Romain Garnier committed
376
377
            old_value = None
            new_value = None
jr-garnier's avatar
jr-garnier committed
378
            if field in hide:
Jean-Romain Garnier's avatar
Jean-Romain Garnier committed
379
380
                # Don't show sensitive information, so leave values at None
                pass
jr-garnier's avatar
jr-garnier committed
381
            else:
Jean-Romain Garnier's avatar
Jean-Romain Garnier committed
382
383
384
385
386
387
388
389
390
391
392
393
394
395
                # Take into account keys that may exist in only one dict
                if field in self.previous_version.field_dict:
                    old_value = self._repr(
                        field,
                        self.previous_version.field_dict[field]
                    )

                if field in self.version.field_dict:
                    new_value = self._repr(
                        field,
                        self.version.field_dict[field]
                    )

            edits.append((field, old_value, new_value))
jr-garnier's avatar
jr-garnier committed
396
397
398
399
400
401

        return edits


class History:
    def __init__(self):
402
        self.name = None
jr-garnier's avatar
jr-garnier committed
403
        self.events = []
jr-garnier's avatar
jr-garnier committed
404
        self.related = []  # For example, a machine has a list of its interfaces
jr-garnier's avatar
jr-garnier committed
405
        self._last_version = None
jr-garnier's avatar
jr-garnier committed
406
407
        self.event_type = HistoryEvent

408
    def get(self, instance_id, model):
409
410
411
412
413
414
415
416
417
        """Get the list of history events of the given object.

        Args:
            instance_id: int, the id of the instance to lookup.
            model: class, the type of object to lookup.

        Returns:
            A list of HistoryEvent, in reverse chronological order, related to
            the given object or None if no version was found.
jr-garnier's avatar
jr-garnier committed
418
419
420
        """
        self.events = []

421
        # Get all the versions for this instance, with the oldest first
jr-garnier's avatar
jr-garnier committed
422
        self._last_version = None
423
424
        interface_versions = (
            Version.objects.get_for_model(model)
425
            .filter(make_version_filter("pk", instance_id))
426
            .order_by("revision__date_created")
jr-garnier's avatar
jr-garnier committed
427
428
429
430
431
        )

        for version in interface_versions:
            self._add_revision(version)

432
433
434
435
        # Return None if interface_versions was empty
        if self._last_version is None:
            return None

436
        self.name = self._last_version.object_repr
jr-garnier's avatar
jr-garnier committed
437
        return self.events[::-1]
jr-garnier's avatar
jr-garnier committed
438

jr-garnier's avatar
jr-garnier committed
439
    def _compute_diff(self, v1, v2, ignoring=[]):
440
441
442
443
444
445
446
447
448
449
        """Find the edited fields between two versions.

        Args:
            v1: Version to compare.
            v2: Version to compare.
            ignoring: a list of fields to ignore.

        Returns:
            The list of field names in v1 that are different from the ones in
            v2.
jr-garnier's avatar
jr-garnier committed
450
451
        """
        fields = []
Jean-Romain Garnier's avatar
Jean-Romain Garnier committed
452
453
        v1_keys = set([k for k in v1.field_dict.keys() if k not in ignoring])
        v2_keys = set([k for k in v2.field_dict.keys() if k not in ignoring])
jr-garnier's avatar
jr-garnier committed
454

Jean-Romain Garnier's avatar
Jean-Romain Garnier committed
455
456
457
458
459
460
        common_keys = v1_keys.intersection(v2_keys)
        fields += list(v2_keys - v1_keys)
        fields += list(v1_keys - v2_keys)

        for key in common_keys:
            if v1.field_dict[key] != v2.field_dict[key]:
jr-garnier's avatar
jr-garnier committed
461
462
463
464
                fields.append(key)

        return fields

jr-garnier's avatar
jr-garnier committed
465
    def _add_revision(self, version):
466
467
468
469
        """Add a new revision to the chronological order.

        Args:
            version: Version, the version of the interface for this event.
jr-garnier's avatar
jr-garnier committed
470
471
472
473
474
475
        """
        diff = None
        if self._last_version is not None:
            diff = self._compute_diff(version, self._last_version)

        # Ignore "empty" events
476
477
        # but always keep the first event
        if not diff and self._last_version:
jr-garnier's avatar
jr-garnier committed
478
479
480
481
482
483
484
            self._last_version = version
            return

        evt = self.event_type(version, self._last_version, diff)
        self.events.append(evt)
        self._last_version = version

jr-garnier's avatar
jr-garnier committed
485

486
487
488
489
############################
#     Revision history     #
############################

490
491
492
493
494
class VersionAction(HistoryEvent):
    def __init__(self, version):
        self.version = version

    def name(self):
495
        return self.version._object_cache or self.version.object_repr
496
497
498
499
500
501
502
503
504
505
506
507
508
509

    def application(self):
        return self.version.content_type.app_label

    def model_name(self):
        return self.version.content_type.model

    def object_id(self):
        return self.version.object_id

    def object_type(self):
        return apps.get_model(self.application(), self.model_name())

    def edits(self, hide=["password", "pwd_ntlm", "gpg_fingerprint"]):
510
511
512
513
514
515
516
517
518
        """Get the list of the changes performed during this event.

        Args:
            hide: the list of fields for which not to show details (default:
            ["password", "pwd_ntlm", "gpg_fingerprint"]).

        Returns:
            The list of fields edited by the event to display.
        """
519
        self.previous_version = self._previous_version()
520
521
522
523

        if self.previous_version is None:
            return None, None, None

524
525
526
527
        self.edited_fields = self._compute_diff(self.version, self.previous_version)
        return super(VersionAction, self).edits(hide)

    def _previous_version(self):
528
529
530
531
532
533
        """Get the previous version of self.

        Returns:
            The Version corresponding to the previous version of self, or None
            in case of exception.
        """
534
        model = self.object_type()
535
        try:
536
            query = (
537
                make_version_filter("pk", self.object_id())
538
                & Q(
539
                    revision__date_created__lt=self.version.revision.date_created
540
                )
541
            )
542
            return (Version.objects.get_for_model(model)
543
                    .filter(query)
544
                    .order_by("-revision__date_created")[0])
545
        except Exception:
546
            return None
547

548
    def _compute_diff(self, v1, v2, ignoring=["pwd_ntlm"]):
549
550
551
552
553
554
555
556
557
558
        """Find the edited fields between two versions.

        Args:
            v1: Version to compare.
            v2: Version to compare.
            ignoring: a list of fields to ignore (default: ["pwd_ntlm"]).

        Returns:
            The list of field names in v1 that are different from the ones in
            v2.
559
560
        """
        fields = []
Jean-Romain Garnier's avatar
Jean-Romain Garnier committed
561
562
563
564
565
566
        v1_keys = set([k for k in v1.field_dict.keys() if k not in ignoring])
        v2_keys = set([k for k in v2.field_dict.keys() if k not in ignoring])

        common_keys = v1_keys.intersection(v2_keys)
        fields += list(v2_keys - v1_keys)
        fields += list(v1_keys - v2_keys)
567

Jean-Romain Garnier's avatar
Jean-Romain Garnier committed
568
569
        for key in common_keys:
            if v1.field_dict[key] != v2.field_dict[key]:
570
571
572
573
574
575
                fields.append(key)

        return fields


class RevisionAction:
576
577
    """A Revision may group multiple Version objects together."""

578
579
580
    def __init__(self, revision):
        self.performed_by = revision.user
        self.revision = revision
581
        self.versions = [VersionAction(v) for v in revision.version_set.all()]
582
583
584
585
586
587
588
589
590
591
592

    def id(self):
        return self.revision.id

    def date_created(self):
        return self.revision.date_created

    def comment(self):
        return self.revision.get_comment()


593
594
class ActionsSearch:
    def get(self, params):
595
596
597
598
599
600
601
        """Get the Revision objects corresponding to the search.

        Args:
            params: dictionary built by the search view.

        Returns:
            The QuerySet of Revision objects corresponding to the search.
602
        """
chirac's avatar
chirac committed
603
604
605
606
        user = params.get("user", None)
        start = params.get("start_date", None)
        end = params.get("end_date", None)
        action_types = params.get("action_type", None)
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646

        query = Q()

        if user:
            query &= Q(user__pseudo=user)

        if start:
            query &= Q(date_created__gte=start)

        if end:
            query &= Q(date_created__lte=end)

        action_models = self.models_for_action_types(action_types)
        if action_models:
            query &= Q(version__content_type__model__in=action_models)

        return (
            Revision.objects.all()
            .filter(query)
            .select_related("user")
            .prefetch_related("version_set__object")
        )

    def models_for_action_types(self, action_types):
        if action_types is None:
            return None

        classes = []
        for action_type in action_types:
            c = classes_for_action_type(action_type)

            # Selecting "all" removes the filter
            if c is None:
                return None

            classes += list(map(str.lower, c))

        return classes


647
648
649
650
############################
#  Class-specific history  #
############################

jr-garnier's avatar
jr-garnier committed
651
class UserHistoryEvent(HistoryEvent):
jr-garnier's avatar
jr-garnier committed
652
    def _repr(self, name, value):
653
654
655
656
657
658
659
660
661
        """Get the appropriate representation of the given field.

        Args:
            name: the name of the field
            value: the value of the field

        Returns:
            The string corresponding to the appropriate representation of the
            given field.
662
        """
663
664
665
666
667
        if name == "groups":
            if len(value) == 0:
                # Removed all the user's groups
                return _("None")

668
669
670
671
672
            # value is a list of ints
            groups = []
            for gid in value:
                # Try to get the group name, if it's not deleted
                try:
673
                    groups.append(Group.objects.get(id=gid).name)
674
675
                except Group.DoesNotExist:
                    # TODO: Find the group name in the versions?
676
                    groups.append("{} ({})".format(_("Deleted"), gid))
677
678

            return ", ".join(groups)
679
680
        elif name == "state":
            if value is not None:
681
                return User.STATES[value][1]
682
683
684
685
            else:
                return _("Unknown")
        elif name == "email_state":
            if value is not None:
686
                return User.EMAIL_STATES[value][1]
687
688
            else:
                return _("Unknown")
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
        elif name == "room_id" and value is not None:
            # Try to get the room name, if it's not deleted
            try:
                return Room.objects.get(id=value)
            except Room.DoesNotExist:
                # TODO: Find the room name in the versions?
                return "{} ({})".format(_("Deleted"), value)
        elif name == "members" or name == "administrators":
            if len(value) == 0:
                # Removed all the club's members
                return _("None")

            # value is a list of ints
            users = []
            for uid in value:
                # Try to get the user's name, if theyr're not deleted
                try:
                    users.append(User.objects.get(id=uid).pseudo)
                except User.DoesNotExist:
                    # TODO: Find the user's name in the versions?
709
                    users.append("{} ({})".format(_("Deleted"), uid))
710
711

            return ", ".join(users)
712

713
        return super(UserHistoryEvent, self)._repr(name, value)
714

715
    def edits(self, hide=["password", "pwd_ntlm", "gpg_fingerprint"]):
716
717
718
719
720
721
722
723
        """Get the list of the changes performed during this event.

        Args:
            hide: the list of fields for which not to show details (default:
            ["password", "pwd_ntlm", "gpg_fingerprint"]).

        Returns:
            The list of fields edited by the event to display.
724
        """
jr-garnier's avatar
jr-garnier committed
725
        return super(UserHistoryEvent, self).edits(hide)
726

727
728
    def __eq__(self, other):
        return (
jr-garnier's avatar
jr-garnier committed
729
            self.edited_fields == other.edited_fields
730
731
732
733
734
735
            and self.date == other.date
            and self.performed_by == other.performed_by
            and self.comment == other.comment
        )

    def __hash__(self):
736
        return hash((frozenset(self.edited_fields), self.date, self.performed_by, self.comment))
737

738
    def __repr__(self):
739
        return "{} edited fields {} ({})".format(
740
741
742
743
744
745
            self.performed_by,
            self.edited_fields or "nothing",
            self.comment or "No comment"
        )


jr-garnier's avatar
jr-garnier committed
746
class UserHistory(History):
747
    def __init__(self):
jr-garnier's avatar
jr-garnier committed
748
        super(UserHistory, self).__init__()
jr-garnier's avatar
jr-garnier committed
749
        self.event_type = UserHistoryEvent
750

751
    def get(self, user_id, model):
752
753
754
755
756
757
758
759
        """Get the the list of UserHistoryEvent related to the object.

        Args:
            user_id: int, the id of the user to lookup.

        Returns:
            The list of UserHistoryEvent, in reverse chronological order,
            related to the object, or None if nothing was found.
760
761
762
        """
        self.events = []

763
        # Try to find an Adherent object
764
765
        # If it exists, its id will be the same as the user's
        adherents = (
766
            Version.objects.get_for_model(Adherent)
767
            .filter(make_version_filter("pk", user_id))
768
        )
769
770
771
772
773
        try:
            obj = adherents[0]
            model = Adherent
        except IndexError:
            obj = None
774
775
776

        # Fallback on a Club
        if obj is None:
777
            clubs = (
778
                Version.objects.get_for_model(Club)
779
                .filter(make_version_filter("pk", user_id))
780
            )
781
782
783
784
785
786

            try:
                obj = clubs[0]
                model = Club
            except IndexError:
                obj = None
787
788
789
790

        # If nothing was found, abort
        if obj is None:
            return None
791

792
        # Add in "related" elements the list of objects
jr-garnier's avatar
jr-garnier committed
793
        # that were once owned by this user
794
        self.related = (
795
            Version.objects.all()
796
            .filter(make_version_filter("user", user_id))
797
            .order_by("content_type__model")
jr-garnier's avatar
jr-garnier committed
798
        )
jr-garnier's avatar
jr-garnier committed
799
        self.related = [RelatedHistory(v) for v in self.related]
800
        self.related = list(dict.fromkeys(self.related))
jr-garnier's avatar
jr-garnier committed
801

802
        # Get all the versions for this user, with the oldest first
jr-garnier's avatar
jr-garnier committed
803
        self._last_version = None
804
805
        user_versions = (
            Version.objects.get_for_model(User)
806
            .filter(make_version_filter("pk", user_id))
807
            .order_by("revision__date_created")
808
809
810
        )

        for version in user_versions:
jr-garnier's avatar
jr-garnier committed
811
            self._add_revision(version)
812

813
814
815
        # Update name
        self.name = self._last_version.field_dict["pseudo"]

816
        # Do the same thing for the Adherent of Club
jr-garnier's avatar
jr-garnier committed
817
        self._last_version = None
818
819
        obj_versions = (
            Version.objects.get_for_model(model)
820
            .filter(make_version_filter("pk", user_id))
821
            .order_by("revision__date_created")
822
823
824
        )

        for version in obj_versions:
jr-garnier's avatar
jr-garnier committed
825
            self._add_revision(version)
826
827
828
829
830
831
832
833

        # Remove duplicates and sort
        self.events = list(dict.fromkeys(self.events))
        return sorted(
            self.events,
            key=lambda e: e.date,
            reverse=True
        )
834

jr-garnier's avatar
jr-garnier committed
835
    def _add_revision(self, version):
836
837
838
839
        """Add a new revision to the chronological order.

        Args:
            version: Version, the version of the user for this event.
840
        """
jr-garnier's avatar
jr-garnier committed
841
        diff = None
jr-garnier's avatar
jr-garnier committed
842
843
        if self._last_version is not None:
            diff = self._compute_diff(
jr-garnier's avatar
jr-garnier committed
844
                version,
jr-garnier's avatar
jr-garnier committed
845
                self._last_version,
jr-garnier's avatar
jr-garnier committed
846
847
                ignoring=["last_login", "pwd_ntlm", "email_change_date"]
            )
848

jr-garnier's avatar
jr-garnier committed
849
        # Ignore "empty" events like login
850
851
        # but always keep the first event
        if not diff and self._last_version:
jr-garnier's avatar
jr-garnier committed
852
            self._last_version = version
jr-garnier's avatar
jr-garnier committed
853
            return
854

jr-garnier's avatar
jr-garnier committed
855
        evt = UserHistoryEvent(version, self._last_version, diff)
jr-garnier's avatar
jr-garnier committed
856
        self.events.append(evt)
jr-garnier's avatar
jr-garnier committed
857
        self._last_version = version
858

jr-garnier's avatar
jr-garnier committed
859

jr-garnier's avatar
jr-garnier committed
860
861
class MachineHistoryEvent(HistoryEvent):
    def _repr(self, name, value):
862
863
864
865
866
867
868
869
870
        """Return the appropriate representation of the given field.

        Args:
            name: the name of the field
            value: the value of the field

        Returns:
            The string corresponding to the appropriate representation of the
            given field.
jr-garnier's avatar
jr-garnier committed
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
        """
        if name == "user_id":
            try:
                return User.objects.get(id=value).pseudo
            except User.DoesNotExist:
                return "{} ({})".format(_("Deleted"), value)

        return super(MachineHistoryEvent, self)._repr(name, value)


class MachineHistory(History):
    def __init__(self):
        super(MachineHistory, self).__init__()
        self.event_type = MachineHistoryEvent

886
    def get(self, machine_id, model):
887
888
889
890
891
892
893
894
895
        """Get the the list of MachineHistoryEvent related to the object.

        Args:
            machine_id: int, the id of the machine to lookup.

        Returns:
            The list of MachineHistoryEvent, in reverse chronological order,
            related to the object.
        """
896
        self.related = (
897
            Version.objects.get_for_model(Interface)
898
            .filter(make_version_filter("machine", machine_id))
899
            .order_by("content_type__model")
900
        )
jr-garnier's avatar
jr-garnier committed
901

902
        # Create RelatedHistory objects and remove duplicates
jr-garnier's avatar
jr-garnier committed
903
        self.related = [RelatedHistory(v) for v in self.related]
904
905
        self.related = list(dict.fromkeys(self.related))

906
        return super(MachineHistory, self).get(machine_id, Machine)
jr-garnier's avatar
jr-garnier committed
907
908


jr-garnier's avatar
jr-garnier committed
909
class InterfaceHistoryEvent(HistoryEvent):
910
    def _repr(self, name, value):
911
912
913
914
915
916
917
918
919
        """Get the appropriate representation of the given field.

        Args:
            name: the name of the field
            value: the value of the field

        Returns:
            The string corresponding to the appropriate representation of the
            given field.
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
        """
        if name == "ipv4_id" and value is not None:
            try:
                return IpList.objects.get(id=value)
            except IpList.DoesNotExist:
                return "{} ({})".format(_("Deleted"), value)
        elif name == "machine_type_id":
            try:
                return MachineType.objects.get(id=value).name
            except MachineType.DoesNotExist:
                return "{} ({})".format(_("Deleted"), value)
        elif name == "machine_id":
            try:
                return Machine.objects.get(id=value).get_name() or _("No name")
            except Machine.DoesNotExist:
                return "{} ({})".format(_("Deleted"), value)
        elif name == "port_lists":
            if len(value) == 0:
                return _("None")

            ports = []
            for pid in value:
                try:
                    ports.append(Port.objects.get(id=pid).pretty_name())
                except Group.DoesNotExist:
                    ports.append("{} ({})".format(_("Deleted"), pid))

947
948
949
950
        return super(InterfaceHistoryEvent, self)._repr(name, value)


class InterfaceHistory(History):
jr-garnier's avatar
jr-garnier committed
951
952
953
    def __init__(self):
        super(InterfaceHistory, self).__init__()
        self.event_type = InterfaceHistoryEvent
954

955
    def get(self, interface_id, model):
956
957
958
959
960
961
962
963
964
        """Get the the list of InterfaceHistoryEvent related to the object.

        Args:
            interface_id: int, the id of the interface to lookup.

        Returns:
            The list of InterfaceHistoryEvent, in reverse chronological order,
            related to the object.
        """
965
        return super(InterfaceHistory, self).get(interface_id, Interface)
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980


############################
#    History auto-detect   #
############################

HISTORY_CLASS_MAPPING = {
    User: UserHistory,
    Machine: MachineHistory,
    Interface: InterfaceHistory,
    "default": History
}


def get_history_class(model):
981
982
983
984
985
986
987
988
989
    """Get the most appropriate History subclass to represent the given model's
    history.

    Args:
        model: the class for which to get the history.

    Returns:
        The most appropriate History subclass for the given model's history,
        or History if no other was found.
990
991
992
993
994
    """
    try:
        return HISTORY_CLASS_MAPPING[model]()
    except KeyError:
        return HISTORY_CLASS_MAPPING["default"]()