Comparer les révisions

...

12 Révisions

15 fichiers modifiés avec 405 ajouts et 3 suppressions

Voir le fichier

@ -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 = ()

Voir le fichier

@ -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')),
],
),
]

Voir le fichier

@ -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

Voir le fichier

@ -1,3 +1,5 @@
import json
from django.core.exceptions import ValidationError
import pytest

Voir le fichier

@ -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:

Voir le fichier

@ -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)),
]

Voir le fichier

@ -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
# ------------------------------------------------------------------------------

Voir le fichier

@ -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():

Voir le fichier

@ -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">

Voir le fichier

@ -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 %}

Voir le fichier

@ -0,0 +1,3 @@
{% extends form.base_layout_template %}
{% load tapeforms %}

Voir le fichier

@ -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 %}

Voir le fichier

@ -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 %}

Voir le fichier

@ -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>