Comparer les révisions
12 Révisions
develop
...
feat/assom
Auteur | SHA1 | Date |
---|---|---|
Antoine | 995acee899 | |
Antoine | 1455fbe685 | |
Antoine | 425567b763 | |
Zoé Martin | a1ef4235ff | |
Zoé Martin | 15163420f9 | |
Zoé Martin | 931530e14a | |
Zoé Martin | a098d75995 | |
Zoé Martin | f5e7258af7 | |
Zoé Martin | 6a375a6aa9 | |
Zoé Martin | 90582b6572 | |
Zoé Martin | 96dff8bb4e | |
Zoé Martin | f33f1710d7 |
|
@ -146,3 +146,27 @@ class AnonymousEngagementUpdateForm(AnonymousEngagementCreateForm):
|
|||
'expires_at',
|
||||
)
|
||||
widgets = {'expires_at': forms.DateInput}
|
||||
|
||||
|
||||
class MigrationCreateForm(CustomTapeformMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = models.Migration
|
||||
fields = ()
|
||||
|
||||
|
||||
class MigrationUpdateForm(CustomTapeformMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = models.Migration
|
||||
fields = ('message',)
|
||||
|
||||
|
||||
class AssociationUploadForm(CustomTapeformMixin, forms.ModelForm):
|
||||
layout_template = (
|
||||
"association/forms/layouts/association_upload_form.html"
|
||||
)
|
||||
|
||||
json_file = forms.FileField()
|
||||
|
||||
class Meta:
|
||||
model = models.Association
|
||||
fields = ()
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
# Generated by Django 3.2.16 on 2023-01-30 14:43
|
||||
|
||||
import benevalibre.utils.validators
|
||||
import datetime
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('association', '0015_anonymousengagement'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Migration',
|
||||
fields=[
|
||||
('association', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='association.association', verbose_name='association')),
|
||||
('date', models.DateField(default=datetime.date.today, validators=[benevalibre.utils.validators.validate_after2k, benevalibre.utils.validators.validate_before1y], verbose_name='date')),
|
||||
('state', models.CharField(choices=[('DRAFT', 'Brouillon'), ('START', 'Démarrée'), ('DONE', 'Terminée')], default='DRAFT', max_length=5, verbose_name='état')),
|
||||
('token', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='jeton de sécurité')),
|
||||
('token_used', models.BooleanField(default=False, verbose_name='jeton utilisé')),
|
||||
('destination_uri', models.URLField(null=True, verbose_name='destination')),
|
||||
('message', models.TextField(max_length=1024, null=True, verbose_name='message')),
|
||||
('creator', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='créateur·ice')),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -1,6 +1,9 @@
|
|||
import urllib
|
||||
from datetime import date
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import serializers
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Q
|
||||
|
@ -21,6 +24,7 @@ from benevalibre.instance.models import (
|
|||
)
|
||||
from benevalibre.utils.mixins import HTMLDocString
|
||||
from benevalibre.utils.models import AbstractRole, AbstractTaxonomy, Visitor
|
||||
from benevalibre.utils.validators import validate_after2k, validate_before1y
|
||||
|
||||
|
||||
@reversion.register()
|
||||
|
@ -242,6 +246,9 @@ class Association(models.Model):
|
|||
def is_waiting_for_moderation(self, user):
|
||||
return self.engagement_set.filter(user=user, is_active=False).exists()
|
||||
|
||||
def is_migrating(self):
|
||||
return self.migration and self.migration.state == MigrationState.START
|
||||
|
||||
def need_moderation(self, user):
|
||||
return self.moderate_benevalo and not self.can_manage_benevalos(user)
|
||||
|
||||
|
@ -374,6 +381,19 @@ class Association(models.Model):
|
|||
fb.association = self
|
||||
fb.save()
|
||||
|
||||
def serialize(self):
|
||||
return {
|
||||
"association": serializers.serialize("python", [self])[0],
|
||||
"users": serializers.serialize("python", self.engagement_set.all()),
|
||||
"roles": serializers.serialize("python", self.role_set.all()),
|
||||
"categories": serializers.serialize("python", self.category_set.all()),
|
||||
"levels": serializers.serialize("python", self.level_set.all()),
|
||||
"benevalos": serializers.serialize("python", self.benevalo_set.all()),
|
||||
}
|
||||
|
||||
def export_to_json(self):
|
||||
return self.serialize()
|
||||
|
||||
|
||||
class AbstractAssociated(models.Model):
|
||||
class Meta:
|
||||
|
@ -384,6 +404,57 @@ class AbstractAssociated(models.Model):
|
|||
)
|
||||
|
||||
|
||||
class MigrationState(models.TextChoices):
|
||||
DRAFT = 'DRAFT', 'Brouillon' # préparation à la migration
|
||||
START = 'START', 'Démarrée' # mode maintenance avec message aux utilisateur·ices
|
||||
DONE = 'DONE', 'Terminée' # déplacée sur la nouvelle instance, message aux utilisateurs pour leur demander de bouger
|
||||
|
||||
|
||||
class Migration(models.Model):
|
||||
# Une migration est une relation 1-à-1 avec une association :
|
||||
# Sa clef primaire est donc la même que l'asso.
|
||||
association = models.OneToOneField(
|
||||
Association, verbose_name="association", on_delete=models.CASCADE, primary_key=True
|
||||
)
|
||||
|
||||
# date de création de la migration
|
||||
# le jeton expire au bout d'une durée choisie arbitrairement
|
||||
date = models.DateField(
|
||||
"date",
|
||||
default=date.today,
|
||||
validators=[validate_after2k, validate_before1y],
|
||||
)
|
||||
|
||||
creator = models.ForeignKey(
|
||||
User, verbose_name="créateur·ice", null=True, on_delete=models.SET_NULL
|
||||
)
|
||||
|
||||
state = models.CharField(
|
||||
'état',
|
||||
max_length=5,
|
||||
choices=MigrationState.choices,
|
||||
default=MigrationState.DRAFT,
|
||||
)
|
||||
|
||||
# utilisable une seule fois par l'instance de destination
|
||||
# pour prouver que la migration a bien eu lieu
|
||||
token = models.UUIDField('jeton de sécurité', default=uuid.uuid4, editable=False)
|
||||
|
||||
# une fois le jeton utilisé, la migration est considérée comme terminée
|
||||
# peu importe son état
|
||||
token_used = models.BooleanField('jeton utilisé', default=False)
|
||||
|
||||
# l'uri de l'asso sur l'instance de destination
|
||||
destination_uri = models.URLField('destination', null=True)
|
||||
|
||||
# message personnalisé à afficher aux utilisateur·ices qui arrivent sur la page
|
||||
# d'une asso migrée
|
||||
message = models.TextField('message', null=True, max_length=1024)
|
||||
|
||||
def __str__(self):
|
||||
return f"Migration de « { self.association } »"
|
||||
|
||||
|
||||
@reversion.register()
|
||||
class Project(HTMLDocString, AbstractAssociated, AbstractTaxonomy):
|
||||
"""
|
||||
|
|
Diff de fichier supprimé car une ou plusieurs lignes sont trop longues
|
@ -1,3 +1,5 @@
|
|||
import json
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
|
|
@ -1606,3 +1606,44 @@ class TestStatistics:
|
|||
client.force_login(foreign)
|
||||
response = client.get(self.url)
|
||||
assert "L'AssoTest" in response.content.decode()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestMigrationData:
|
||||
def test_export_json(
|
||||
self,
|
||||
client,
|
||||
manager,
|
||||
user,
|
||||
fake_association,
|
||||
fake_migration,
|
||||
fake_user,
|
||||
fake_benevalos,
|
||||
):
|
||||
url = reverse('association:migration-data', args=[fake_association.id])
|
||||
response = client.get(url)
|
||||
with open(
|
||||
'benevalibre/association/tests/data/fake_association.json', 'rb'
|
||||
) as f:
|
||||
json = f.read()
|
||||
assert json == response.content
|
||||
|
||||
def test_not_manager_cannont_access(
|
||||
self,
|
||||
client,
|
||||
user,
|
||||
fake_association,
|
||||
):
|
||||
url = reverse('association:migration-data', args=[fake_association.id])
|
||||
assert client.get(url).status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestMigrationEndpoint:
|
||||
def test_migration_is_ready(
|
||||
self,
|
||||
client,
|
||||
fake_migration,
|
||||
):
|
||||
url = reverse('api:association:migration', args=[fake_migration.token])
|
||||
def test_migration_finalize:
|
||||
|
|
|
@ -21,5 +21,9 @@ association_patterns = [
|
|||
|
||||
urlpatterns = [
|
||||
path('', crud_include(views, 'Association')),
|
||||
path('upload', views.AssociationUpload.as_view(), name='upload'),
|
||||
path('<int:association>/migration/', views.MigrationCreate.as_view(), name='migration'),
|
||||
path('<int:association>/migration/detail/', views.MigrationUpdate.as_view(), name='migration-detail'),
|
||||
path('<int:association>/migration/data/', views.MigrationData.as_view(), name='migration-data'),
|
||||
path('<int:association>/', include(association_patterns)),
|
||||
]
|
||||
|
|
|
@ -2,9 +2,10 @@ from django.conf import settings
|
|||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.db.models import Q
|
||||
from django.http import Http404
|
||||
from django.http import Http404, JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.views import View
|
||||
from django.views.generic import DetailView, TemplateView
|
||||
|
||||
from cruditor.views import (
|
||||
|
@ -123,7 +124,13 @@ def get_management_buttons(association, user):
|
|||
'url': _reverse('association:statistics'),
|
||||
}
|
||||
)
|
||||
|
||||
if association.can_manage_association(user):
|
||||
buttons.append(
|
||||
{
|
||||
'label': fa_icon('truck', "Migrer l'asso vers une autre instance"),
|
||||
'url': _reverse('association:migration'),
|
||||
}
|
||||
)
|
||||
if association.get_engagement(user):
|
||||
buttons.append(
|
||||
{
|
||||
|
@ -442,6 +449,85 @@ class AssociationDelete(CruditorPageMixin, CruditorDeleteView):
|
|||
return reverse('base:home')
|
||||
|
||||
|
||||
class MigrationViewMixin(
|
||||
CruditorPageMixin,
|
||||
UserPassesTestMixin,
|
||||
AssociationRelatedMixin,
|
||||
):
|
||||
model = models.Migration
|
||||
title = "Migrer l'association"
|
||||
|
||||
def get_title(self):
|
||||
return f"{ self.title } « { self.association } »"
|
||||
|
||||
def test_func(self):
|
||||
return self.association.can_manage_association(self.request.user)
|
||||
|
||||
|
||||
class MigrationCreate(MigrationViewMixin, AssociationRelatedFormMixin, CruditorAddView):
|
||||
form_class = forms.MigrationCreateForm
|
||||
template_name = 'association/forms/layouts/migration_create_form.html'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.association.migration
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('association:migration-detail', args=[self.object.pk])
|
||||
|
||||
|
||||
class MigrationUpdate(MigrationViewMixin, AssociationRelatedFormMixin, CruditorChangeView):
|
||||
form_class = forms.MigrationUpdateForm
|
||||
template_name = 'association/forms/layouts/migration_update_form.html'
|
||||
|
||||
def get_object(self):
|
||||
obj, _ = models.Migration.objects.get_or_create(association=self.association)
|
||||
return obj
|
||||
|
||||
def save_form(self, form, **formsets):
|
||||
action = form.data['save']
|
||||
|
||||
# The migration state cannot be directly selected by the user.
|
||||
# Instead, we check what save button was pressed and change it here,
|
||||
# just before cruditor calls save().
|
||||
if form.instance.state == models.MigrationState.DRAFT:
|
||||
if action == 'start':
|
||||
form.instance.state = models.MigrationState.START
|
||||
|
||||
if form.instance.state == models.MigrationState.START:
|
||||
if action == 'abort':
|
||||
form.instance.state = models.MigrationState.DRAFT
|
||||
|
||||
super().save_form(form, **formsets)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('association:migration-detail', args=[self.object.pk])
|
||||
|
||||
|
||||
class MigrationData(View):
|
||||
# TODO: sécuriser l'accès à cette vue
|
||||
def get(self, request, *args, **kwargs):
|
||||
migration = models.Migration.objects.get(association_id=kwargs['association'])
|
||||
response = JsonResponse({
|
||||
'token': migration.token,
|
||||
'uri': '',
|
||||
'association': migration.association.serialize()
|
||||
})
|
||||
response['Content-Disposition'] = f'attachment; filename="benevalibre_asso_{migration.association_id}.json"'
|
||||
return response
|
||||
|
||||
|
||||
class AssociationUpload(
|
||||
EmailNotifySuccessMixin, CruditorPageMixin, CruditorAddView
|
||||
):
|
||||
model = models.Association
|
||||
form_class = forms.AssociationUploadForm
|
||||
title = "Importer une association"
|
||||
|
||||
notify_users_query = Q(is_superuser=True)
|
||||
notify_template = 'association/registration_moderation_email.txt'
|
||||
notify_subject_template = 'association/registration_moderation_subject.txt'
|
||||
|
||||
|
||||
# ASSOCIATION'S PROJECTS
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from benevalibre.accounts.models import User
|
||||
|
@ -120,6 +122,15 @@ def fake_benevalos(
|
|||
return benevalos
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_migration(fake_association):
|
||||
return models.Migration.objects.create(
|
||||
association=fake_association,
|
||||
state=models.MigrationState.START,
|
||||
token=uuid.UUID('e6ad7cf8-518e-4103-af57-cf3f2740b707'),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def role(django_db_blocker, association):
|
||||
with django_db_blocker.unblock():
|
||||
|
|
|
@ -25,6 +25,11 @@
|
|||
<span class="badge badge-warning">En modération</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if association.is_migrating %}
|
||||
<div class="float-right ml-3">
|
||||
<span class="badge badge-warning">En maintenance</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if object.description %}
|
||||
{{ object.description|linebreaks }}
|
||||
{% endif %}
|
||||
|
@ -37,6 +42,27 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{% if association.is_migrating %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-sm-auto">
|
||||
<h3>Maintenance en cours</h3>
|
||||
<div class="alert alert-warning d-flex flex-column" role="alert">
|
||||
<div class="d-flex align-items-center" role="alert">
|
||||
<i class="fa fa-2x fa-truck mr-3 mt-1" aria-hidden="true"></i>
|
||||
<p class="mb-0">Cette association est actuellement en cours de maintenance pour être migrée. Il n'est pas possible de rentrer de nouvelles bénévalos pendant ce temps. {% if association.migration.message %}Les modérateur·ices ont indiqué le message suivant :{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if association.migration.message %}
|
||||
<hr class="w-100"/>
|
||||
<div>
|
||||
{{ association.migration.message | linebreaks }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_engage %}
|
||||
<div class="row justify-content-end mt-2">
|
||||
<div class="col-sm-auto">
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
{% load tapeforms %}
|
||||
|
||||
{% block visible_fields %}
|
||||
<p class="text-info text-right"><i class="fa fa-info-circle" aria-hidden="true"></i> Pour importer une asso existante depuis une autre instance Bénévalibre, <a href="{% url 'association:upload' %}">cliquez ici</a>.</p>
|
||||
{% csrf_token %}
|
||||
{% for fieldset in form.get_fieldsets %}
|
||||
<div class="{{ fieldset.extra.css_class }}">
|
||||
|
@ -15,4 +16,4 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
{% extends form.base_layout_template %}
|
||||
{% load tapeforms %}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
{% extends "cruditor/form.html" %}
|
||||
|
||||
{% block before_content %}
|
||||
<h3>Qu'est-ce qu'une migration ?</h3>
|
||||
|
||||
<p>Si vous souhaitez déplacer votre association vers un autre hébergeur de Bénévalibre (par exemple plus proche de chez vous), c'est possible ! Cela s'appelle une migration.</p>
|
||||
|
||||
<h3>Comment ça marche ?</h3>
|
||||
|
||||
<p>La migration se passe en plusieurs étapes :</p>
|
||||
|
||||
<dl>
|
||||
<dt>I · Mise en maintenance</dt> <dd>Votre association se met en pause. Il devient impossible de rentrer de nouvelles bénévalos.</dd>
|
||||
<dt>II · Export des données</dt> <dd>Vous récupérez un fichier sur votre ordinateur qui contient toutes les données de votre asso.</dd>
|
||||
<dt>III · Déplacement</dt> <dd>Vous envoyez ce fichier à la nouvelle instance de Bénévalibre dans laquelle vous souhaitez vous rendre.</dd>
|
||||
<dt>IV · Validation</dt> <dd>Une fois la nouvelle asso créée, vos anciens bénévoles recevront un lien pour se rendre sur le nouvel espace. Iels devronts créer un compte sur la nouvelle instance. Votre asso sera toujours présente ici (en lecture seule), et un lien guidera les futurs bénévoles vers le nouvel emplacement de votre asso.</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Vais-je perdre des données ?</h3>
|
||||
|
||||
<p>Les données qui peuvent être migrées sont les suivantes : fiche de l'asso, historique des bénévalos, bénévoles, roles, catégories, niveaux. Si vos catégories de bénévolat ne coincident pas avec celles de la nouvelle instance, elles seront archivées.</p>
|
||||
|
||||
<h3>Je veux migrer mon asso</h3>
|
||||
|
||||
<p>Cliquez sur le bouton « Commencer » pour commencer la procédure. Vous aurez encore la possibilité de revenir en arrière si vous changez d'avis.</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block form_actions_left %}
|
||||
<a class="btn btn-outline-danger" href="{% url 'association:detail' form.instance.pk %}">Retour</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block form_actions_right %}
|
||||
<button class="btn btn-success" type="submit" name="save">Commencer</button>
|
||||
{% endblock %}
|
|
@ -0,0 +1,64 @@
|
|||
{% extends "cruditor/form.html" %}
|
||||
|
||||
|
||||
{% block before_content %}
|
||||
{% if form.instance.state == "DRAFT" %}
|
||||
|
||||
<h3>Étape I · Mise en maintenance</h3>
|
||||
|
||||
<p>En remplissant le formulaire suivant, vous allez mettre l'association en pause. Plus personne ne pourra entrer de bénévalo.</p>
|
||||
|
||||
<p>Les personnes qui arriveront sur la page de votre asso verront le message de maintenance, que vous pouvez personnaliser ci-dessous :</p>
|
||||
|
||||
{% elif form.instance.state == "START" %}
|
||||
|
||||
<h3>Étape II · Téléchargement des données</h3>
|
||||
|
||||
<p>Votre asso est actuellement en maintenance. Personne ne peut entrer de nouvelles bénévalos. Vous pouvez maintenant télécharger toutes vos données et les transmettre à l'instance Bénévalibre de votre choix.</p>
|
||||
|
||||
<p>Les personnes qui arrivent actuellement sur la page de votre asso voient le message suivant :</p>
|
||||
|
||||
{% elif form.instance.state == "DONE" %}
|
||||
|
||||
<h3>Migration terminée</h3>
|
||||
|
||||
<p>Nous avons bien reçu un message de la part de la nouvelle instance nous indiquant que votre asso a été migrée avec succès. Les bénévoles ont été prévenu·es et vont devoir créer un nouveau compte sur l'instance de destination.</p>
|
||||
|
||||
<p>Les personnes qui arrivent actuellement sur la page de l'ancienne asso voient le message suivant :</p>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block form_actions %}
|
||||
<div class="row form-actions">
|
||||
{% if form.instance.state == "DRAFT" %}
|
||||
<div class="col-auto mr-auto">
|
||||
<a class="btn btn-outline-danger" href="{% url 'association:migration' form.instance.pk %}">Retour</a>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success" type="submit" name="save" value="start">Mettre mon asso en maintenance</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if form.instance.state == "START" %}
|
||||
<div class="col-auto mr-auto">
|
||||
<button class="btn btn-outline-success" type="submit" name="save" value="edit">Modifier le message de maintenance</button>
|
||||
<button class="btn btn-outline-danger" type="submit" name="save" value="abort">Retirer la maintenance</button>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a class="btn btn-success" href="{% url 'association:migration-data' form.instance.pk %}">Télécharger les données de l'asso</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if form.instance.state == "DONE" %}
|
||||
<div class="col-auto mr-auto">
|
||||
<button class="btn btn-outline-success" type="submit" name="save" value="edit">Modifier le message de maintenance</button>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a class="btn btn-success" href="{% url 'association:migration-data' form.instance.pk %}">Télécharger les données de l'asso</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -70,7 +70,9 @@
|
|||
|
||||
{% block content_container %}
|
||||
<div class="container content-container">
|
||||
{% block before_content %}{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
{% block after_content %}{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
</main>
|
||||
|
|
Chargement…
Référencer dans un nouveau ticket