diff --git a/config/settings/base.py b/config/settings/base.py index 20f2cd179..bc337e40a 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -123,6 +123,7 @@ "huey.contrib.djhuey", # huey (Async tasks) "rest_framework", # djangorestframework "phonenumber_field", # django-phonenumber-field + "simple_history", # django-simple-history ] LOCAL_APPS = [ @@ -185,7 +186,8 @@ "django.middleware.clickjacking.XFrameOptionsMiddleware", # Third-party Middlewares "whitenoise.middleware.WhiteNoiseMiddleware", - "django_htmx.middleware.HtmxMiddleware", + "django_htmx.middleware.HtmxMiddleware", # django-htmx + "simple_history.middleware.HistoryRequestMiddleware", # django-simple-history # wagtail "wagtail.contrib.redirects.middleware.RedirectMiddleware", # Custom Middlewares @@ -748,6 +750,7 @@ # django-ckeditor # https://django-ckeditor.readthedocs.io/en/latest/#optional-customizing-ckeditor-editor # ------------------------------------------------------------------------------ + DEFAULT_CKEDITOR_CONFIG = { "toolbar": "Custom", "toolbar_Custom": [ @@ -785,6 +788,13 @@ } +# Django Simple History +# https://django-simple-history.readthedocs.io/ +# ------------------------------------------------------------------------------ + +SIMPLE_HISTORY_HISTORY_ID_USE_UUID = True + + # Internal & external # (if you need these settings in the template, add them to settings_context_processor.expose_settings) # ------------------------------------------------------------------------------ diff --git a/lemarche/siaes/admin.py b/lemarche/siaes/admin.py index e9aa62144..dd0b4537d 100644 --- a/lemarche/siaes/admin.py +++ b/lemarche/siaes/admin.py @@ -7,6 +7,7 @@ from django.urls import reverse from django.utils.html import format_html, mark_safe from fieldsets_with_inlines import FieldsetsInlineMixin +from simple_history.admin import SimpleHistoryAdmin from lemarche.conversations.models import Conversation from lemarche.labels.models import Label @@ -129,7 +130,7 @@ def nb_message_with_link(self, conversation: Conversation): @admin.register(Siae, site=admin_site) -class SiaeAdmin(FieldsetsInlineMixin, gis_admin.OSMGeoAdmin): +class SiaeAdmin(FieldsetsInlineMixin, gis_admin.OSMGeoAdmin, SimpleHistoryAdmin): actions = [export_as_xls] list_display = [ "id", diff --git a/lemarche/siaes/factories.py b/lemarche/siaes/factories.py index 191adb91b..a8fc9c81f 100644 --- a/lemarche/siaes/factories.py +++ b/lemarche/siaes/factories.py @@ -23,6 +23,7 @@ def siaes(self, create, extracted, **kwargs): class SiaeFactory(DjangoModelFactory): class Meta: model = Siae + skip_postgeneration_save = True name = factory.Faker("company", locale="fr_FR") # slug auto-generated diff --git a/lemarche/siaes/migrations/0075_historicalsiae.py b/lemarche/siaes/migrations/0075_historicalsiae.py new file mode 100644 index 000000000..3fd199585 --- /dev/null +++ b/lemarche/siaes/migrations/0075_historicalsiae.py @@ -0,0 +1,670 @@ +# Generated by Django 4.2.13 on 2024-06-09 14:15 + +import uuid + +import django.contrib.gis.db.models.fields +import django.db.models.deletion +import django.utils.timezone +import phonenumber_field.modelfields +import simple_history.models +from django.conf import settings +from django.db import migrations, models + +import lemarche.utils.fields +import lemarche.utils.validators + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("siaes", "0074_alter_siae_contact_phone_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="HistoricalSiae", + fields=[ + ("id", models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ("name", models.CharField(max_length=255, verbose_name="Raison sociale")), + ("slug", models.SlugField(max_length=255, verbose_name="Slug")), + ("brand", models.CharField(blank=True, max_length=255, verbose_name="Enseigne")), + ( + "kind", + models.CharField( + choices=[ + ("EI", "Entreprise d'insertion (EI)"), + ("AI", "Association intermédiaire (AI)"), + ("ACI", "Atelier chantier d'insertion (ACI)"), + ("ETTI", "Entreprise de travail temporaire d'insertion (ETTI)"), + ("EITI", "Entreprise d'insertion par le travail indépendant (EITI)"), + ("GEIQ", "Groupement d'employeurs pour l'insertion et la qualification (GEIQ)"), + ("SEP", "Produits et services réalisés en prison (SEP)"), + ("EA", "Entreprise adaptée (EA)"), + ("EATT", "Entreprise adaptée de travail temporaire (EATT)"), + ("ESAT", "Etablissement et service d'aide par le travail (ESAT)"), + ], + db_index=True, + default="EI", + max_length=6, + verbose_name="Type de structure", + ), + ), + ("description", models.TextField(blank=True, verbose_name="Description")), + ( + "siret", + models.CharField( + db_index=True, + max_length=14, + validators=[lemarche.utils.validators.validate_siret], + verbose_name="Siret", + ), + ), + ("siret_is_valid", models.BooleanField(default=False, verbose_name="Siret Valide")), + ( + "naf", + models.CharField( + blank=True, + max_length=5, + validators=[lemarche.utils.validators.validate_naf], + verbose_name="Naf", + ), + ), + ( + "nature", + models.CharField( + blank=True, + choices=[ + ("HEAD_OFFICE", "Conventionné par la DREETS"), + ("ANTENNA", "Rattaché à un autre conventionnement"), + ], + max_length=20, + verbose_name="Établissement", + ), + ), + ( + "presta_type", + lemarche.utils.fields.ChoiceArrayField( + base_field=models.CharField( + choices=[ + ("DISP", "Mise à disposition - Interim"), + ("PREST", "Prestation de service"), + ("BUILD", "Fabrication et commercialisation de biens"), + ], + max_length=20, + ), + blank=True, + db_index=True, + null=True, + size=None, + verbose_name="Type de prestation", + ), + ), + ( + "legal_form", + models.CharField( + blank=True, + choices=[ + ("SARL", "SARL"), + ("SARL_COOP", "SARL coopérative"), + ("SAS", "SAS (Société par actions simplifiée)"), + ("SA", "SA (Société anonyme)"), + ("SA_COOP", "SA coopérative"), + ("SNC", "SNC (Société en nom collectif)"), + ("ASSOCIATION", "Association"), + ("GROUPEMENT_EMPLOYEUR", "Groupement d'employeurs"), + ("COLLECTIVITE", "Collectivité"), + ("CCAS", "CCAS (Centre (inter)communal d'action sociale)"), + ("EPSMS", "EPSMS (Établissement public social ou médico-social)"), + ("FONDATION", "Fondation"), + ("AUTRE", "Autre"), + ], + db_index=True, + max_length=20, + verbose_name="Forme juridique", + ), + ), + ("website", models.URLField(blank=True, verbose_name="Site internet")), + ("email", models.EmailField(blank=True, max_length=254, verbose_name="E-mail")), + ("phone", models.CharField(blank=True, max_length=20, verbose_name="Téléphone")), + ("address", models.TextField(verbose_name="Adresse")), + ("city", models.CharField(blank=True, max_length=255, verbose_name="Ville")), + ( + "department", + models.CharField( + blank=True, + choices=[ + ("01", "01 - Ain"), + ("02", "02 - Aisne"), + ("03", "03 - Allier"), + ("04", "04 - Alpes-de-Haute-Provence"), + ("05", "05 - Hautes-Alpes"), + ("06", "06 - Alpes-Maritimes"), + ("07", "07 - Ardèche"), + ("08", "08 - Ardennes"), + ("09", "09 - Ariège"), + ("10", "10 - Aube"), + ("11", "11 - Aude"), + ("12", "12 - Aveyron"), + ("13", "13 - Bouches-du-Rhône"), + ("14", "14 - Calvados"), + ("15", "15 - Cantal"), + ("16", "16 - Charente"), + ("17", "17 - Charente-Maritime"), + ("18", "18 - Cher"), + ("19", "19 - Corrèze"), + ("2A", "2A - Corse-du-Sud"), + ("2B", "2B - Haute-Corse"), + ("21", "21 - Côte-d'Or"), + ("22", "22 - Côtes-d'Armor"), + ("23", "23 - Creuse"), + ("24", "24 - Dordogne"), + ("25", "25 - Doubs"), + ("26", "26 - Drôme"), + ("27", "27 - Eure"), + ("28", "28 - Eure-et-Loir"), + ("29", "29 - Finistère"), + ("30", "30 - Gard"), + ("31", "31 - Haute-Garonne"), + ("32", "32 - Gers"), + ("33", "33 - Gironde"), + ("34", "34 - Hérault"), + ("35", "35 - Ille-et-Vilaine"), + ("36", "36 - Indre"), + ("37", "37 - Indre-et-Loire"), + ("38", "38 - Isère"), + ("39", "39 - Jura"), + ("40", "40 - Landes"), + ("41", "41 - Loir-et-Cher"), + ("42", "42 - Loire"), + ("43", "43 - Haute-Loire"), + ("44", "44 - Loire-Atlantique"), + ("45", "45 - Loiret"), + ("46", "46 - Lot"), + ("47", "47 - Lot-et-Garonne"), + ("48", "48 - Lozère"), + ("49", "49 - Maine-et-Loire"), + ("50", "50 - Manche"), + ("51", "51 - Marne"), + ("52", "52 - Haute-Marne"), + ("53", "53 - Mayenne"), + ("54", "54 - Meurthe-et-Moselle"), + ("55", "55 - Meuse"), + ("56", "56 - Morbihan"), + ("57", "57 - Moselle"), + ("58", "58 - Nièvre"), + ("59", "59 - Nord"), + ("60", "60 - Oise"), + ("61", "61 - Orne"), + ("62", "62 - Pas-de-Calais"), + ("63", "63 - Puy-de-Dôme"), + ("64", "64 - Pyrénées-Atlantiques"), + ("65", "65 - Hautes-Pyrénées"), + ("66", "66 - Pyrénées-Orientales"), + ("67", "67 - Bas-Rhin"), + ("68", "68 - Haut-Rhin"), + ("69", "69 - Rhône"), + ("70", "70 - Haute-Saône"), + ("71", "71 - Saône-et-Loire"), + ("72", "72 - Sarthe"), + ("73", "73 - Savoie"), + ("74", "74 - Haute-Savoie"), + ("75", "75 - Paris"), + ("76", "76 - Seine-Maritime"), + ("77", "77 - Seine-et-Marne"), + ("78", "78 - Yvelines"), + ("79", "79 - Deux-Sèvres"), + ("80", "80 - Somme"), + ("81", "81 - Tarn"), + ("82", "82 - Tarn-et-Garonne"), + ("83", "83 - Var"), + ("84", "84 - Vaucluse"), + ("85", "85 - Vendée"), + ("86", "86 - Vienne"), + ("87", "87 - Haute-Vienne"), + ("88", "88 - Vosges"), + ("89", "89 - Yonne"), + ("90", "90 - Territoire de Belfort"), + ("91", "91 - Essonne"), + ("92", "92 - Hauts-de-Seine"), + ("93", "93 - Seine-Saint-Denis"), + ("94", "94 - Val-de-Marne"), + ("95", "95 - Val-d'Oise"), + ("971", "971 - Guadeloupe"), + ("972", "972 - Martinique"), + ("973", "973 - Guyane"), + ("974", "974 - La Réunion"), + ("975", "975 - Saint-Pierre-et-Miquelon"), + ("976", "976 - Mayotte"), + ("977", "977 - Saint-Barthélémy"), + ("978", "978 - Saint-Martin"), + ("984", "984 - Terres australes et antarctiques françaises"), + ("986", "986 - Wallis-et-Futuna"), + ("987", "987 - Polynésie française"), + ("988", "988 - Nouvelle-Calédonie"), + ("989", "989 - Île de Clipperton"), + ], + max_length=255, + verbose_name="Département", + ), + ), + ( + "region", + models.CharField( + blank=True, + choices=[ + ("Auvergne-Rhône-Alpes", "Auvergne-Rhône-Alpes"), + ("Bourgogne-Franche-Comté", "Bourgogne-Franche-Comté"), + ("Bretagne", "Bretagne"), + ("Centre-Val de Loire", "Centre-Val de Loire"), + ("Corse", "Corse"), + ("Grand Est", "Grand Est"), + ("Guadeloupe", "Guadeloupe"), + ("Guyane", "Guyane"), + ("Hauts-de-France", "Hauts-de-France"), + ("Île-de-France", "Île-de-France"), + ("La Réunion", "La Réunion"), + ("Martinique", "Martinique"), + ("Mayotte", "Mayotte"), + ("Normandie", "Normandie"), + ("Nouvelle-Aquitaine", "Nouvelle-Aquitaine"), + ("Occitanie", "Occitanie"), + ("Pays de la Loire", "Pays de la Loire"), + ("Provence-Alpes-Côte d'Azur", "Provence-Alpes-Côte d'Azur"), + ("Collectivités d'outre-mer", "Collectivités d'outre-mer"), + ], + max_length=255, + verbose_name="Région", + ), + ), + ( + "post_code", + models.CharField( + blank=True, + max_length=5, + validators=[lemarche.utils.validators.validate_post_code], + verbose_name="Code Postal", + ), + ), + ( + "coords", + django.contrib.gis.db.models.fields.PointField(blank=True, geography=True, null=True, srid=4326), + ), + ( + "geo_range", + models.CharField( + blank=True, + choices=[ + ("COUNTRY", "France entière"), + ("REGION", "Région"), + ("DEPARTMENT", "Département"), + ("CUSTOM", "Distance en kilomètres"), + ], + db_index=True, + max_length=20, + verbose_name="Périmètre d'intervention", + ), + ), + ( + "geo_range_custom_distance", + models.IntegerField( + blank=True, null=True, verbose_name="Distance en kilomètres (périmètre d'intervention)" + ), + ), + ("contact_first_name", models.CharField(blank=True, max_length=150, verbose_name="Prénom")), + ("contact_last_name", models.CharField(blank=True, max_length=150, verbose_name="Nom")), + ( + "contact_website", + models.URLField( + blank=True, help_text="Doit commencer par http:// ou https://", verbose_name="Site internet" + ), + ), + ( + "contact_email", + models.EmailField( + blank=True, + help_text="Le contact renseigné ici recevra les opportunités commerciales par mail", + max_length=254, + verbose_name="E-mail", + ), + ), + ( + "contact_phone", + phonenumber_field.modelfields.PhoneNumberField( + blank=True, max_length=150, region=None, verbose_name="Téléphone" + ), + ), + ( + "contact_social_website", + models.URLField( + blank=True, help_text="Doit commencer par http:// ou https://", verbose_name="Réseau social" + ), + ), + ("image_name", models.CharField(blank=True, max_length=255, verbose_name="Nom de l'image")), + ("logo_url", models.URLField(blank=True, max_length=500, verbose_name="Lien vers le logo")), + ("is_consortium", models.BooleanField(default=False, verbose_name="Consortium")), + ("is_cocontracting", models.BooleanField(default=False, verbose_name="Co-traitance")), + ("asp_id", models.IntegerField(blank=True, null=True, verbose_name="ID ASP")), + ( + "is_active", + models.BooleanField( + db_index=True, + default=True, + help_text="Convention active (C1) ou import", + verbose_name="Active", + ), + ), + ( + "is_delisted", + models.BooleanField( + db_index=True, + default=False, + help_text="La structure n'apparaîtra plus dans les résultats", + verbose_name="Masquée", + ), + ), + ( + "is_first_page", + models.BooleanField( + default=False, + help_text="La structure apparaîtra sur la page principale", + verbose_name="A la une", + ), + ), + ("admin_name", models.CharField(blank=True, max_length=255)), + ("admin_email", models.EmailField(blank=True, max_length=255)), + ( + "year_constitution", + models.PositiveIntegerField(blank=True, null=True, verbose_name="Année de création"), + ), + ( + "employees_insertion_count", + models.PositiveIntegerField(blank=True, null=True, verbose_name="Nombre de salariés en insertion"), + ), + ( + "employees_insertion_count_last_updated", + models.DateTimeField( + blank=True, + null=True, + verbose_name="Date de dernière mise à jour du nombre de salariés en insertion", + ), + ), + ( + "employees_permanent_count", + models.PositiveIntegerField(blank=True, null=True, verbose_name="Nombre de salariés permanents"), + ), + ( + "employees_permanent_count_last_updated", + models.DateTimeField( + blank=True, + null=True, + verbose_name="Date de dernière mise à jour du nombre de salariés permanents", + ), + ), + ("ca", models.PositiveIntegerField(blank=True, null=True, verbose_name="Chiffre d'affaires")), + ( + "ca_last_updated", + models.DateTimeField( + blank=True, null=True, verbose_name="Date de dernière mise à jour du chiffre d'affaires" + ), + ), + ( + "super_badge", + models.BooleanField( + blank=True, + help_text="Champ recalculé à intervalles réguliers", + null=True, + verbose_name="Badge 'Super prestataire inclusif'", + ), + ), + ( + "super_badge_last_updated", + models.DateTimeField( + blank=True, + null=True, + verbose_name="Date de dernière mise à jour du badge 'Super prestataire inclusif'", + ), + ), + ("c2_etp_count", models.FloatField(blank=True, null=True, verbose_name="Nombre d'ETP (C2)")), + ( + "c2_etp_count_date_saisie", + models.DateField(blank=True, null=True, verbose_name="Date de saisie du nombre d'ETP (C2)"), + ), + ( + "c2_etp_count_last_sync_date", + models.DateTimeField( + blank=True, null=True, verbose_name="Date de dernière synchronisation (C2 ETP)" + ), + ), + ( + "is_qpv", + models.BooleanField( + db_index=True, + default=False, + verbose_name="Quartier prioritaire de la politique de la ville (API QPV)", + ), + ), + ( + "qpv_name", + models.CharField(blank=True, max_length=255, verbose_name="Nom de la zone QPV (API QPV)"), + ), + ( + "qpv_code", + models.CharField(blank=True, max_length=16, verbose_name="Code de la zone QPV (API QPV)"), + ), + ( + "api_qpv_last_sync_date", + models.DateTimeField( + blank=True, null=True, verbose_name="Date de dernière synchronisation (API QPV)" + ), + ), + ( + "is_zrr", + models.BooleanField( + db_index=True, default=False, verbose_name="Zone de revitalisation rurale (API ZRR)" + ), + ), + ( + "zrr_name", + models.CharField(blank=True, max_length=255, verbose_name="Nom de la zone ZRR (API ZRR)"), + ), + ( + "zrr_code", + models.CharField(blank=True, max_length=16, verbose_name="Code de la zone ZRR (API ZRR)"), + ), + ( + "api_zrr_last_sync_date", + models.DateTimeField( + blank=True, null=True, verbose_name="Date de dernière synchronisation (API ZRR)" + ), + ), + ( + "api_entreprise_forme_juridique", + models.CharField(blank=True, max_length=255, verbose_name="Forme juridique (API Entreprise)"), + ), + ( + "api_entreprise_forme_juridique_code", + models.CharField( + blank=True, max_length=5, verbose_name="Code de la forme juridique (API Entreprise)" + ), + ), + ( + "api_entreprise_entreprise_last_sync_date", + models.DateTimeField( + blank=True, + null=True, + verbose_name="Date de dernière synchronisation (API Entreprise /entreprises)", + ), + ), + ( + "api_entreprise_date_constitution", + models.DateField(blank=True, null=True, verbose_name="Date de création (API Entreprise)"), + ), + ( + "api_entreprise_employees", + models.CharField(blank=True, max_length=255, verbose_name="Nombre de salariés (API Entreprise)"), + ), + ( + "api_entreprise_employees_year_reference", + models.CharField( + blank=True, + max_length=4, + verbose_name="Année de référence du nombre de salariés (API Entreprise)", + ), + ), + ( + "api_entreprise_etablissement_last_sync_date", + models.DateTimeField( + blank=True, + null=True, + verbose_name="Date de dernière synchronisation (API Entreprise /etablissements)", + ), + ), + ( + "api_entreprise_ca", + models.IntegerField(blank=True, null=True, verbose_name="Chiffre d'affaires (API Entreprise)"), + ), + ( + "api_entreprise_ca_date_fin_exercice", + models.DateField(blank=True, null=True, verbose_name="Date de fin de l'exercice (API Entreprise)"), + ), + ( + "api_entreprise_exercice_last_sync_date", + models.DateTimeField( + blank=True, + null=True, + verbose_name="Date de dernière synchronisation (API Entreprise /exercices)", + ), + ), + ("c1_id", models.IntegerField(blank=True, null=True)), + ("c4_id_old", models.IntegerField(blank=True, null=True)), + ("c1_last_sync_date", models.DateTimeField(blank=True, null=True)), + ("c1_sync_skip", models.BooleanField(default=False)), + ( + "brevo_company_id", + models.CharField(blank=True, max_length=80, null=True, verbose_name="Brevo company id"), + ), + ("user_count", models.IntegerField(default=0, verbose_name="Nombre d'utilisateurs")), + ("sector_count", models.IntegerField(default=0, verbose_name="Nombre de secteurs d'activité")), + ("network_count", models.IntegerField(default=0, verbose_name="Nombre de réseaux")), + ("group_count", models.IntegerField(default=0, verbose_name="Nombre de groupements")), + ("offer_count", models.IntegerField(default=0, verbose_name="Nombre de prestations")), + ( + "client_reference_count", + models.IntegerField(default=0, verbose_name="Nombre de références clients"), + ), + ("label_count", models.IntegerField(default=0, verbose_name="Nombre de labels")), + ("image_count", models.IntegerField(default=0, verbose_name="Nombre d'images")), + ( + "etablissement_count", + models.IntegerField(default=0, verbose_name="Nombre d'établissements (à partir du Siren)"), + ), + ( + "signup_date", + models.DateTimeField( + blank=True, null=True, verbose_name="Date d'inscription de la structure (premier utilisateur)" + ), + ), + ( + "content_filled_basic_date", + models.DateTimeField( + blank=True, null=True, verbose_name="Date de remplissage (basique) de la fiche" + ), + ), + ( + "completion_rate", + models.IntegerField(blank=True, null=True, verbose_name="Taux de remplissage de sa fiche"), + ), + ( + "tender_count", + models.IntegerField( + default=0, + help_text="Champ recalculé à intervalles réguliers", + verbose_name="Nombre de besoins concernés", + ), + ), + ( + "tender_email_send_count", + models.IntegerField( + default=0, + help_text="Champ recalculé à intervalles réguliers", + verbose_name="Nombre de besoins reçus", + ), + ), + ( + "tender_email_link_click_count", + models.IntegerField( + default=0, + help_text="Champ recalculé à intervalles réguliers", + verbose_name="Nombre de besoins cliqués", + ), + ), + ( + "tender_detail_display_count", + models.IntegerField( + default=0, + help_text="Champ recalculé à intervalles réguliers", + verbose_name="Nombre de besoins vus", + ), + ), + ( + "tender_detail_contact_click_count", + models.IntegerField( + default=0, + help_text="Champ recalculé à intervalles réguliers", + verbose_name="Nombre de besoins intéressés", + ), + ), + ("logs", models.JSONField(default=list, editable=False, verbose_name="Logs historiques")), + ( + "source", + models.CharField( + choices=[ + ("ASP", "Export ASP"), + ("GEIQ", "Export GEIQ"), + ("EA_EATT", "Export EA+EATT"), + ("USER_CREATED", "Utilisateur (Antenne)"), + ("STAFF_C1_CREATED", "Staff C1"), + ("STAFF_C4_CREATED", "Staff C4"), + ("ESAT", "Import ESAT (GSAT, Handeco)"), + ("SEP", "Import SEP"), + ], + default="STAFF_C4_CREATED", + max_length=20, + ), + ), + ("extra_data", models.JSONField(default=dict, editable=False, verbose_name="Données complémentaires")), + ("import_raw_object", models.JSONField(editable=False, null=True, verbose_name="Donnée JSON brute")), + ( + "created_at", + models.DateTimeField(default=django.utils.timezone.now, verbose_name="Date de création"), + ), + ("updated_at", models.DateTimeField(blank=True, editable=False, verbose_name="Date de mise à jour")), + ( + "history_id", + models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), + ), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "historical Structure", + "verbose_name_plural": "historical Structures", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/lemarche/siaes/models.py b/lemarche/siaes/models.py index 8aef9c694..04c93cb7a 100644 --- a/lemarche/siaes/models.py +++ b/lemarche/siaes/models.py @@ -17,6 +17,7 @@ from django.utils.functional import cached_property from django.utils.text import slugify from phonenumber_field.modelfields import PhoneNumberField +from simple_history.models import HistoricalRecords from lemarche.perimeters.models import Perimeter from lemarche.siaes import constants as siae_constants @@ -877,6 +878,8 @@ class Siae(models.Model): extra_data = models.JSONField(verbose_name="Données complémentaires", editable=False, default=dict) import_raw_object = models.JSONField(verbose_name="Donnée JSON brute", editable=False, null=True) + history = HistoricalRecords() + created_at = models.DateTimeField(verbose_name="Date de création", default=timezone.now) updated_at = models.DateTimeField(verbose_name="Date de mise à jour", auto_now=True) diff --git a/lemarche/siaes/tests.py b/lemarche/siaes/tests.py index e57a18be8..4f1543d3a 100644 --- a/lemarche/siaes/tests.py +++ b/lemarche/siaes/tests.py @@ -17,6 +17,7 @@ ) from lemarche.siaes.models import Siae, SiaeGroup, SiaeLabel, SiaeUser from lemarche.users.factories import UserFactory +from lemarche.utils.history import HISTORY_TYPE_CREATE, HISTORY_TYPE_UPDATE class SiaeGroupModelTest(TestCase): @@ -567,6 +568,30 @@ def test_geo_range_in_perimeter_list(self): ) +class SiaeHistoryTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.siae_1 = SiaeFactory(name="ZZZ", brand="ABC") + cls.siae_2 = SiaeFactory(name="Test", brand="") + + def test_history_object_on_create(self): + self.assertEqual(self.siae_1.history.count(), 1) + siae_1_create_history_item = self.siae_1.history.last() + self.assertEqual(siae_1_create_history_item.history_type, HISTORY_TYPE_CREATE) + self.assertEqual(siae_1_create_history_item.name, self.siae_1.name) + + def test_history_object_on_update(self): + self.siae_2.brand = "test" + self.siae_2.save() + self.assertEqual(self.siae_2.history.count(), 1 + 1) + siae_2_create_history_item = self.siae_2.history.last() + self.assertEqual(siae_2_create_history_item.history_type, HISTORY_TYPE_CREATE) + self.assertEqual(siae_2_create_history_item.brand, "") + siae_2_update_history_item = self.siae_2.history.first() + self.assertEqual(siae_2_update_history_item.history_type, HISTORY_TYPE_UPDATE) + self.assertEqual(siae_2_update_history_item.brand, self.siae_2.brand) + + class SiaeLabelModelTest(TestCase): @classmethod def setUpTestData(cls): diff --git a/lemarche/utils/history.py b/lemarche/utils/history.py new file mode 100644 index 000000000..f1ef860e2 --- /dev/null +++ b/lemarche/utils/history.py @@ -0,0 +1,6 @@ +# https://django-simple-history.readthedocs.io/en/latest/index.html + +# https://django-simple-history.readthedocs.io/en/latest/quick_start.html#what-is-django-simple-history-doing-behind-the-scenes +HISTORY_TYPE_CREATE = "+" +HISTORY_TYPE_UPDATE = "~" +HISTORY_TYPE_DELETE = "-" diff --git a/poetry.lock b/poetry.lock index b82d6f8ff..08e865ca2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1005,6 +1005,20 @@ django = ">=3.2" [package.extras] ua = ["ua-parser (>=0.15)"] +[[package]] +name = "django-simple-history" +version = "3.7.0" +description = "Store model history and view/revert changes from admin site." +optional = false +python-versions = ">=3.8" +files = [ + {file = "django_simple_history-3.7.0-py3-none-any.whl", hash = "sha256:282cb2c4aa63f51547f17da7f2130abaa81ba01694676d19b88d52c94a57a52c"}, + {file = "django_simple_history-3.7.0.tar.gz", hash = "sha256:ac3b7ca8b0d33f7ea6be8fe7fc98cf43415efa500ff5dfe736fbd1ebc0cf39f9"}, +] + +[package.dependencies] +django = ">=4.2" + [[package]] name = "django-storages" version = "1.14.3" @@ -4126,4 +4140,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10.4" -content-hash = "06d618daab4c0e43b62f99a94d27d04105becac9afc115d67dab1e7c186e6c54" +content-hash = "ff8f1026167dfbf3274153c105135d83cc2f71a3f39cd3dddbd408fe1d6c4389" diff --git a/pyproject.toml b/pyproject.toml index 1fd8f1403..ecef53216 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ wagtail = "^5.1.3" whitenoise = "^6.6.0" xlwt = "^1.3.0" django-phonenumber-field = {extras = ["phonenumbers"], version = "^7.3.0"} +django-simple-history = "^3.7.0" [tool.poetry.group.dev.dependencies] black = "^23.12.1"