1
0
mirror of https://gitlab.com/animath/si/plateforme.git synced 2025-04-24 03:32:37 +00:00

Compare commits

...

2 Commits

Author SHA1 Message Date
Emmy D'Anello
97eea3b11a
Add survey notification in the menu 2025-03-19 23:56:53 +01:00
Emmy D'Anello
702c8d8c9e
Add survey feature 2025-03-19 23:18:45 +01:00
30 changed files with 1170 additions and 415 deletions

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@
from django.apps import AppConfig
from django.db.models.signals import post_save, pre_save
from django.utils.translation import gettext_lazy as _
class ParticipationConfig(AppConfig):
@ -10,6 +11,7 @@ class ParticipationConfig(AppConfig):
The participation app contains the data about the teams, solutions, ...
"""
name = 'participation'
verbose_name = _("participations")
def ready(self):
from participation import signals

View File

@ -3,13 +3,12 @@
from datetime import date, timedelta
import math
import os
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models
from django.db.models import Index
from django.db.models import Index, Q
from django.template.defaultfilters import slugify
from django.urls import reverse_lazy
from django.utils import timezone, translation
@ -211,7 +210,7 @@ class Team(models.Model):
"""
:return: The mailing list to contact the team members.
"""
return f"equipe-{slugify(self.trigram)}@{os.getenv('SYMPA_HOST', 'localhost')}"
return f"equipe-{slugify(self.trigram)}@{settings.SYMPA_HOST}"
def create_mailing_list(self):
"""
@ -392,21 +391,21 @@ class Tournament(models.Model):
"""
:return: The mailing list to contact the team members.
"""
return f"equipes-{slugify(self.name)}@{os.getenv('SYMPA_HOST', 'localhost')}"
return f"equipes-{slugify(self.name)}@{settings.SYMPA_HOST}"
@property
def organizers_email(self):
"""
:return: The mailing list to contact the team members.
"""
return f"organisateurs-{slugify(self.name)}@{os.getenv('SYMPA_HOST', 'localhost')}"
return f"organisateurs-{slugify(self.name)}@{settings.SYMPA_HOST}"
@property
def jurys_email(self):
"""
:return: The mailing list to contact the team members.
"""
return f"jurys-{slugify(self.name)}@{os.getenv('SYMPA_HOST', 'localhost')}"
return f"jurys-{slugify(self.name)}@{settings.SYMPA_HOST}"
def create_mailing_lists(self):
"""
@ -847,6 +846,8 @@ class Participation(models.Model):
return _("Participation of the team {name} ({trigram})").format(name=self.team.name, trigram=self.team.trigram)
def important_informations(self):
from survey.models import Survey
informations = []
missing_payments = Payment.objects.filter(registrations__in=self.team.participants.all(), valid=False)
@ -865,6 +866,19 @@ class Participation(models.Model):
'content': content,
})
if self.valid:
for survey in Survey.objects.filter(Q(tournament__isnull=True) | Q(tournament=self.tournament), Q(invite_team=True),
~Q(completed_teams=self.team)).all():
text = _("Please answer to the survey \"{name}\". You can go to the survey on <a href=\"{survey_link}\">that link</a>, "
"using the token code you received by mail.")
content = format_lazy(text, name=survey.name, survey_link=f"{settings.LIMESURVEY_URL}/index.php/{survey.survey_id}")
informations.append({
'title': _("Required answer to survey"),
'type': "warning",
'priority': 12,
'content': content
})
if self.tournament:
informations.extend(self.informations_for_tournament(self.tournament))
if self.final:

View File

@ -234,7 +234,7 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView)
mail_plain = render_to_string("participation/mails/request_validation.txt", mail_context)
mail_html = render_to_string("participation/mails/request_validation.html", mail_context)
send_mail(f"[{settings.APP_NAME}] {_('Team validation')}", mail_plain, settings.DEFAULT_FROM_EMAIL,
[self.object.participation.tournament.organizers_email], html_message=mail_html)
[self.object.participation.tournament.organizers_email], html_message=mail_html)
return super().form_valid(form)
@ -270,7 +270,7 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView)
mail_plain = render_to_string("participation/mails/team_validated.txt", mail_context_plain)
mail_html = render_to_string("participation/mails/team_validated.html", mail_context_html)
registration.user.email_user(f"[{settings.APP_NAME}] {_('Team validated')}", mail_plain,
html_message=mail_html)
html_message=mail_html)
elif "invalidate" in self.request.POST:
self.object.participation.valid = None
self.object.participation.save()
@ -280,7 +280,7 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView)
mail_plain = render_to_string("participation/mails/team_not_validated.txt", mail_context_plain)
mail_html = render_to_string("participation/mails/team_not_validated.html", mail_context_html)
send_mail(f"[{settings.APP_NAME}] {_('Team not validated')}", mail_plain,
None, [self.object.email], html_message=mail_html)
None, [self.object.email], html_message=mail_html)
else:
form.add_error(None, _("You must specify if you validate the registration or not."))
return self.form_invalid(form)

View File

@ -3,6 +3,7 @@
from django.apps import AppConfig
from django.db.models.signals import post_save, pre_save
from django.utils.translation import gettext_lazy as _
class RegistrationConfig(AppConfig):
@ -10,6 +11,7 @@ class RegistrationConfig(AppConfig):
Registration app contains the detail about users only.
"""
name = 'registration'
verbose_name = _("registrations")
def ready(self):
from registration import signals

View File

@ -8,6 +8,7 @@ from django.contrib.sites.models import Site
from django.core.mail import send_mail
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Q
from django.template import loader
from django.urls import reverse, reverse_lazy
from django.utils import timezone, translation
@ -260,6 +261,8 @@ class ParticipantRegistration(Registration):
raise NotImplementedError
def registration_informations(self):
from survey.models import Survey
informations = []
if not self.team:
text = _("You are not in a team. You can <a href=\"{create_url}\">create one</a> "
@ -300,6 +303,20 @@ class ParticipantRegistration(Registration):
'content': content,
})
if self.team.participation.valid:
for survey in Survey.objects.filter(Q(tournament__isnull=True) | Q(tournament=self.team.participation.tournament),
Q(invite_team=False), Q(invite_coaches=True) | Q(invite_coaches=self.is_coach),
~Q(completed_registrations=self)):
text = _("Please answer to the survey \"{name}\". You can go to the survey on <a href=\"{survey_link}\">that link</a>, "
"using the token code you received by mail.")
content = format_lazy(text, name=survey.name, survey_link=f"{settings.LIMESURVEY_URL}/index.php/{survey.survey_id}")
informations.append({
'title': _("Required answer to survey"),
'type': "warning",
'priority': 12,
'content': content
})
informations.extend(self.team.important_informations())
return informations
@ -315,19 +332,19 @@ class ParticipantRegistration(Registration):
tournament = Tournament.final_tournament()
payment = self.payments.filter(final=True).first() if self.is_student else None
message = loader.render_to_string('registration/mails/final_selection.txt',
{
'user': self.user,
'domain': site.domain,
'tournament': tournament,
'payment': payment,
})
{
'user': self.user,
'domain': site.domain,
'tournament': tournament,
'payment': payment,
})
html = loader.render_to_string('registration/mails/final_selection.html',
{
'user': self.user,
'domain': site.domain,
'tournament': tournament,
'payment': payment,
})
{
'user': self.user,
'domain': site.domain,
'tournament': tournament,
'payment': payment,
})
self.user.email_user(subject, message, html_message=html)
class Meta:
@ -807,9 +824,9 @@ class Payment(models.Model):
site = Site.objects.first()
for registration in self.registrations.all():
message = loader.render_to_string('registration/mails/payment_reminder.txt',
dict(registration=registration, payment=self, domain=site.domain))
dict(registration=registration, payment=self, domain=site.domain))
html = loader.render_to_string('registration/mails/payment_reminder.html',
dict(registration=registration, payment=self, domain=site.domain))
dict(registration=registration, payment=self, domain=site.domain))
registration.user.email_user(subject, message, html_message=html)
def send_helloasso_payment_confirmation_mail(self):
@ -818,18 +835,18 @@ class Payment(models.Model):
site = Site.objects.first()
for registration in self.registrations.all():
message = loader.render_to_string('registration/mails/payment_confirmation.txt',
dict(registration=registration, payment=self, domain=site.domain))
dict(registration=registration, payment=self, domain=site.domain))
html = loader.render_to_string('registration/mails/payment_confirmation.html',
dict(registration=registration, payment=self, domain=site.domain))
dict(registration=registration, payment=self, domain=site.domain))
registration.user.email_user(subject, message, html_message=html)
payer = self.get_checkout_intent()['order']['payer']
payer_name = f"{payer['firstName']} {payer['lastName']}"
if not self.registrations.filter(user__email=payer['email']).exists():
message = loader.render_to_string('registration/mails/payment_confirmation.txt',
dict(registration=payer_name, payment=self, domain=site.domain))
dict(registration=payer_name, payment=self, domain=site.domain))
html = loader.render_to_string('registration/mails/payment_confirmation.html',
dict(registration=payer_name, payment=self, domain=site.domain))
dict(registration=payer_name, payment=self, domain=site.domain))
send_mail(subject, message, None, [payer['email']], html_message=html)
def get_absolute_url(self):

View File

@ -145,9 +145,9 @@ class AddOrganizerView(VolunteerMixin, CreateView):
password=password,
domain=site.domain))
html = render_to_string('registration/mails/add_organizer.html', dict(user=registration.user,
inviter=self.request.user,
password=password,
domain=site.domain))
inviter=self.request.user,
password=password,
domain=site.domain))
registration.user.email_user(subject, message, html_message=html)
if registration.is_admin:

View File

@ -1,5 +1,6 @@
channels[daphne]~=4.1.0
channels-redis~=4.2.0
citric~=1.4.0
crispy-bootstrap5~=2024.10
Django>=5.1.2,<6.0
django-crispy-forms~=2.3

0
survey/__init__.py Normal file
View File

13
survey/admin.py Normal file
View File

@ -0,0 +1,13 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
from .models import Survey
@admin.register(Survey)
class SurveyAdmin(admin.ModelAdmin):
list_display = ('survey_id', 'name', 'invite_team', 'invite_coaches', 'tournament',)
list_filter = ('invite_team', 'invite_coaches', 'tournament',)
search_fields = ('name',)

11
survey/apps.py Normal file
View File

@ -0,0 +1,11 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class SurveyConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "survey"
verbose_name = _("surveys")

28
survey/forms.py Normal file
View File

@ -0,0 +1,28 @@
from django import forms
from .models import Survey
class SurveyForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'survey_id' in self.initial:
self.fields['survey_id'].disabled = True
class Meta:
model = Survey
exclude = ('completed_registrations', 'completed_teams',)
widgets = {
'completed_registrations': forms.SelectMultiple(attrs={
'class': 'selectpicker',
'data-live-search': 'true',
'data-live-search-normalize': 'true',
'data-width': 'fit',
}),
'completed_teams': forms.SelectMultiple(attrs={
'class': 'selectpicker',
'data-live-search': 'true',
'data-live-search-normalize': 'true',
'data-width': 'fit',
}),
}

View File

@ -0,0 +1,13 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.core.management import BaseCommand
from ...models import Survey
class Command(BaseCommand):
def handle(self, *args, **kwargs):
for survey in Survey.objects.all():
survey.fetch_completion_data()

View File

@ -0,0 +1,83 @@
# Generated by Django 5.1.5 on 2025-03-19 21:12
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
(
"participation",
"0023_tournament_unified_registration",
),
("registration", "0014_participantregistration_country"),
]
operations = [
migrations.CreateModel(
name="Survey",
fields=[
(
"survey_id",
models.IntegerField(
help_text="The numeric identifier of the Limesurvey.",
primary_key=True,
serialize=False,
verbose_name="survey identifier",
),
),
("name", models.CharField(max_length=255, verbose_name="display name")),
(
"invite_team",
models.BooleanField(
default=False,
help_text="When this field is checked, teams will get only one survey invitation instead of one per person.",
verbose_name="invite whole team",
),
),
(
"invite_coaches",
models.BooleanField(
default=True,
help_text="When this field is checked, coaches will also be invited in the survey. No effect when the whole team is invited.",
verbose_name="invite coaches",
),
),
(
"completed_registrations",
models.ManyToManyField(
related_name="completed_surveys",
to="registration.participantregistration",
verbose_name="participants that completed the survey",
),
),
(
"completed_teams",
models.ManyToManyField(
related_name="completed_surveys",
to="participation.team",
verbose_name="teams that completed the survey",
),
),
(
"tournament",
models.ForeignKey(
blank=True,
default=None,
help_text="When this field is filled, the survey participants will be restricted to this tournament members.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="participation.tournament",
verbose_name="tournament restriction",
),
),
],
options={
"verbose_name": "survey",
"verbose_name_plural": "surveys",
},
),
]

View File

@ -0,0 +1,53 @@
# Generated by Django 5.1.5 on 2025-03-19 22:51
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"participation",
"0023_tournament_unified_registration",
),
("registration", "0014_participantregistration_country"),
("survey", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="survey",
name="completed_registrations",
field=models.ManyToManyField(
blank=True,
related_name="completed_surveys",
to="registration.participantregistration",
verbose_name="participants that completed the survey",
),
),
migrations.AlterField(
model_name="survey",
name="completed_teams",
field=models.ManyToManyField(
blank=True,
related_name="completed_surveys",
to="participation.team",
verbose_name="teams that completed the survey",
),
),
migrations.AlterField(
model_name="survey",
name="tournament",
field=models.ForeignKey(
blank=True,
default=None,
help_text="When this field is filled, the survey participants will be restricted to this tournament members.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="surveys",
to="participation.tournament",
verbose_name="tournament restriction",
),
),
]

View File

137
survey/models.py Normal file
View File

@ -0,0 +1,137 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from citric import Client
from django.conf import settings
from django.db import models
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from participation.models import Team, Tournament
from registration.models import ParticipantRegistration, StudentRegistration
class Survey(models.Model):
"""
Ce modèle représente un sondage LimeSurvey afin de faciliter l'import des
participantes au sondage et d'effectuer le suivi.
"""
survey_id = models.IntegerField(
primary_key=True,
verbose_name=_("survey identifier"),
help_text=_("The numeric identifier of the Limesurvey."),
)
name = models.CharField(
max_length=255,
verbose_name=_("display name"),
)
invite_team = models.BooleanField(
default=False,
verbose_name=_("invite whole team"),
help_text=_("When this field is checked, teams will get only one survey invitation instead of one per person."),
)
invite_coaches = models.BooleanField(
default=True,
verbose_name=_("invite coaches"),
help_text=_("When this field is checked, coaches will also be invited in the survey. No effect when the whole team is invited."),
)
tournament = models.ForeignKey(
Tournament,
null=True,
blank=True,
default=None,
on_delete=models.SET_NULL,
related_name="surveys",
verbose_name=_("tournament restriction"),
help_text=_("When this field is filled, the survey participants will be restricted to this tournament members."),
)
completed_registrations = models.ManyToManyField(
ParticipantRegistration,
blank=True,
related_name="completed_surveys",
verbose_name=_("participants that completed the survey"),
)
completed_teams = models.ManyToManyField(
Team,
blank=True,
related_name="completed_surveys",
verbose_name=_("teams that completed the survey"),
)
@property
def participants(self):
if self.invite_team:
teams = Team.objects.filter(participation__valid=True)
if self.tournament:
teams = teams.filter(participation__tournament=self.tournament)
return teams.all()
else:
if self.invite_coaches:
registrations = ParticipantRegistration.objects.filter(team__participation__valid=True)
else:
registrations = StudentRegistration.objects.filter(team__participation__valid=True)
if self.tournament:
registrations = registrations.filter(team__participation__tournament=self.tournament)
return registrations.all()
@property
def completed(self):
if self.invite_team:
return self.completed_teams
else:
return self.completed_registrations
def get_absolute_url(self):
return reverse_lazy("survey:survey_detail", args=(self.survey_id,))
def generate_participants_data(self):
participants_data = []
if self.invite_team:
for team in self.participants:
participant_data = {"firstname": team.name, "lastname": f"(équipe {team.trigram})", "email": team.email}
participants_data.append(participant_data)
else:
for reg in self.participants:
participant_data = {"firstname": reg.user.first_name, "lastname": reg.user.last_name, "email": reg.user.email}
participants_data.append(participant_data)
return participants_data
def invite_all(self):
participants_data = self.generate_participants_data()
with Client(f"{settings.LIMESURVEY_URL}/index.php/admin/remotecontrol", settings.LIMESURVEY_USER, settings.LIMESURVEY_PASSWORD) as client:
try:
current_participants = client.list_participants(self.survey_id, limit=10000)
except:
current_participants = []
current_participants_email = set(participant['participant_info']['email'] for participant in current_participants)
participants_data = [participant_data for participant_data in participants_data if participant_data['email'] not in current_participants_email]
try:
client.activate_tokens(self.survey_id)
except:
pass
new_participants = client.add_participants(self.survey_id, participant_data=participants_data)
if new_participants:
client.invite_participants(self.survey_id, token_ids=[participant['tid'] for participant in new_participants])
return new_participants
def fetch_completion_data(self):
with Client(f"{settings.LIMESURVEY_URL}/index.php/admin/remotecontrol", settings.LIMESURVEY_USER, settings.LIMESURVEY_PASSWORD) as client:
participants = client.list_participants(self.survey_id, limit=10000, attributes=['completed'])
if self.invite_team:
team_names = [participant['participant_info']['firstname'] for participant in participants if participant['completed'] != 'N']
self.completed_teams.set(list(Team.objects.filter(name__in=team_names).values_list('id', flat=True)))
else:
mails = [participant['participant_info']['email'] for participant in participants if participant['completed'] != 'N']
self.completed_registrations.set(list(ParticipantRegistration.objects.filter(user__email__in=mails).values_list('id', flat=True)))
self.save()
class Meta:
verbose_name = _("survey")
verbose_name_plural = _("surveys")

31
survey/tables.py Normal file
View File

@ -0,0 +1,31 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
from .models import Survey
class SurveyTable(tables.Table):
survey_id = tables.LinkColumn(
'survey:survey_detail',
args=[tables.A('survey_id')],
verbose_name=lambda: _("survey identifier").capitalize(),
)
nb_completed = tables.Column(
verbose_name=_("completed").capitalize,
accessor='survey_id'
)
def render_nb_completed(self, record):
return f"{record.completed.count()}/{record.participants.count()}"
class Meta:
attrs = {
'class': 'table table-condensed table-striped',
}
model = Survey
fields = ('survey_id', 'name', 'invite_team', 'invite_coaches', 'tournament', 'nb_completed',)
order_by = ('survey_id',)

View File

@ -0,0 +1,84 @@
{% extends "base.html" %}
{% load i18n %}
{% load crispy_forms_filters %}
{% block content %}
<div class="card bg-body shadow">
<div class="card-header text-center">
<h4>
{% trans "survey"|capfirst %} {{ survey.survey_id }}
<a href="{{ TFJM.LIMESURVEY_URL }}/index.php/{{ survey.survey_id }}" target="_blank"><i class="fas fa-arrow-up-right-from-square"></i></a>
</h4>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-6 text-sm-end">{% trans "Name:" %}</dt>
<dd class="col-sm-6">{{ survey.name }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans "One answer per team:" %}</dt>
<dd class="col-sm-6">{{ survey.invite_team|yesno }}</dd>
{% if not survey.invite_team %}
<dt class="col-sm-6 text-sm-end">{% trans "Coaches can answer the survey:" %}</dt>
<dd class="col-sm-6">{{ survey.invite_coaches|yesno }}</dd>
{% endif %}
{% if survey.tournament %}
<dt class="col-sm-6 text-sm-end">{% trans "Tournament restriction:" %}</dt>
<dd class="col-sm-6">{{ survey.tournament }}</dd>
{% endif %}
<dt class="col-sm-6 text-sm-end">{% trans "Completion rate:" %}</dt>
<dd class="col-sm-6">
{{ survey.completed.count }}/{{ survey.participants.count }}
<a href="{% url "survey:survey_refresh_completed" pk=survey.pk %}"><i class="fas fa-arrow-rotate-right" alt="refresh"></i></a>
</dd>
</dl>
</div>
<div class="card-footer text-center">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updateSurveyModal">{% trans "Update" %}</button>
<a class="btn btn-secondary" href="{% url "survey:survey_invite" pk=survey.pk %}">{% trans "Send invites" %}</a>
</div>
</div>
<hr>
<table class="table table-condensed table-striped">
<thead>
<tr>
<th>{% trans "participant"|capfirst %}</th>
<th>{% trans "completed"|capfirst %}</th>
</tr>
</thead>
<tbody>
{% for participant in survey.participants %}
<tr class="{% if participant in survey.completed.all %}table-success{% else %}table-danger{% endif %}">
{% if survey.invite_team %}
<td>{% trans "Team" %} {{ participant.name }} ({{ participant.trigram }})</td>
{% else %}
<td>{{ participant.user.first_name }} {{ participant.user.last_name }} ({% trans "team" %} {{ participant.team.trigram }})</td>
{% endif %}
{% if participant in survey.completed.all %}
<td>{% trans "Yes" %}</td>
{% else %}
<td>{% trans "No" %}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% trans "Update survey" as modal_title %}
{% trans "Update" as modal_button %}
{% url "survey:survey_update" pk=survey.pk as modal_action %}
{% include "base_modal.html" with modal_id="updateSurvey" %}
{% endblock %}
{% block extrajavascript %}
<script>
document.addEventListener('DOMContentLoaded', () => {
initModal("updateSurvey", "{% url "survey:survey_update" pk=survey.pk %}")
})
</script>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends request.content_only|yesno:"empty.html,base.html" %}
{% load crispy_forms_filters i18n %}
{% block content %}
<form method="post">
<div id="form-content">
{% csrf_token %}
{{ form|crispy }}
</div>
{% if object.pk %}
<button class="btn btn-primary" type="submit">{% trans "Update" %}</button>
{% else %}
<button class="btn btn-success" type="submit">{% trans "Create" %}</button>
{% endif %}
</form>
{% endblock content %}

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% load django_tables2 i18n %}
{% block content %}
<div class="d-grid">
<a href="{% url "survey:survey_create" %}" class="btn gap-0 btn-success">
<i class="fas fa-square-poll-horizontal"></i> {% trans "Add survey" %}
</a>
</div>
<hr>
{% render_table table %}
{% endblock %}

3
survey/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

18
survey/urls.py Normal file
View File

@ -0,0 +1,18 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path
from .views import SurveyCreateView, SurveyDetailView, SurveyInviteView, \
SurveyListView, SurveyRefreshCompletedView, SurveyUpdateView
app_name = "survey"
urlpatterns = [
path("", SurveyListView.as_view(), name="survey_list"),
path("create/", SurveyCreateView.as_view(), name="survey_create"),
path("<int:pk>/", SurveyDetailView.as_view(), name="survey_detail"),
path("<int:pk>/invite/", SurveyInviteView.as_view(), name="survey_invite"),
path("<int:pk>/refresh/", SurveyRefreshCompletedView.as_view(), name="survey_refresh_completed"),
path("<int:pk>/update/", SurveyUpdateView.as_view(), name="survey_update"),
]

56
survey/views.py Normal file
View File

@ -0,0 +1,56 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import messages
from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, UpdateView
from django_tables2 import SingleTableView
from tfjm.views import AdminMixin
from .forms import SurveyForm
from .models import Survey
from .tables import SurveyTable
class SurveyListView(AdminMixin, SingleTableView):
model = Survey
table_class = SurveyTable
template_name = "survey/survey_list.html"
class SurveyCreateView(AdminMixin, CreateView):
model = Survey
form_class = SurveyForm
class SurveyDetailView(AdminMixin, DetailView):
model = Survey
class SurveyInviteView(AdminMixin, DetailView):
model = Survey
def get(self, request, *args, **kwargs):
survey = self.get_object()
new_participants = survey.invite_all()
if new_participants:
messages.success(request, _("Invites sent!"))
else:
messages.warning(request, _("All invites were already sent."))
return redirect("survey:survey_detail", survey.pk)
class SurveyRefreshCompletedView(AdminMixin, DetailView):
model = Survey
def get(self, request, *args, **kwargs):
survey = self.get_object()
survey.fetch_completion_data()
messages.success(request, _("Completion data refreshed!"))
return redirect("survey:survey_detail", survey.pk)
class SurveyUpdateView(AdminMixin, UpdateView):
model = Survey
form_class = SurveyForm

View File

@ -19,5 +19,8 @@
# Update Google Drive notifications daily
0 0 * * * cd /code && python manage.py renew_gdrive_notifications -v 0
# Fetch LimeSurvey completion data
*/15 * * 03-06 * cd /code && python manage.py fetch_survey_completion_data -v 0
# Clean temporary files
30 * * * * rm -rf /tmp/*

View File

@ -13,6 +13,7 @@ def tfjm_context(request):
'HAS_OBSERVER': settings.HAS_OBSERVER,
'HAS_FINAL': settings.HAS_FINAL,
'HOME_PAGE_LINK': settings.HOME_PAGE_LINK,
'LIMESURVEY_URL': settings.LIMESURVEY_URL,
'LOGO_PATH': "tfjm/img/" + settings.LOGO_FILE,
'NB_ROUNDS': settings.NB_ROUNDS,
'ML_MANAGEMENT': settings.ML_MANAGEMENT,

View File

@ -1,7 +1,7 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from django.conf import settings
_client = None
@ -9,10 +9,10 @@ _client = None
def get_sympa_client():
global _client
if _client is None:
if os.getenv("SYMPA_PASSWORD", None): # pragma: no cover
if settings.SYMPA_PASSWORD is not None: # pragma: no cover
from sympasoap import Client
_client = Client("https://" + os.getenv("SYMPA_URL"))
_client.login(os.getenv("SYMPA_EMAIL"), os.getenv("SYMPA_PASSWORD"))
_client = Client("https://" + settings.SYMPA_URL)
_client.login(settings.SYMPA_EMAIL, settings.SYMPA_PASSWORD)
else:
_client = FakeSympaSoapClient()
return _client

View File

@ -74,6 +74,7 @@ INSTALLED_APPS = [
'draw',
'registration',
'participation',
'survey',
]
if "test" not in sys.argv: # pragma: no cover
@ -300,6 +301,12 @@ CHANNEL_LAYERS = {
PHONENUMBER_DB_FORMAT = 'NATIONAL'
PHONENUMBER_DEFAULT_REGION = 'FR'
# Sympa configuration
SYMPA_HOST = os.getenv("SYMPA_HOST", "localhost")
SYMPA_URL = os.getenv("SYMPA_URL", "localhost")
SYMPA_EMAIL = os.getenv("SYMPA_EMAIL", "contact@localhost")
SYMPA_PASSWORD = os.getenv("SYMPA_PASSWORD", None)
# Hello Asso API creds
HELLOASSO_CLIENT_ID = os.getenv('HELLOASSO_CLIENT_ID', 'CHANGE_ME_IN_ENV_SETTINGS')
HELLOASSO_CLIENT_SECRET = os.getenv('HELLOASSO_CLIENT_SECRET', 'CHANGE_ME_IN_ENV_SETTINGS')
@ -322,6 +329,10 @@ GOOGLE_SERVICE_CLIENT = {
# The ID of the Google Drive folder where to store the notation sheets
NOTES_DRIVE_FOLDER_ID = os.getenv("NOTES_DRIVE_FOLDER_ID", "CHANGE_ME_IN_ENV_SETTINGS")
LIMESURVEY_URL = os.getenv("LIMESURVEY_URL", "https://survey.example.com")
LIMESURVEY_USER = os.getenv("LIMESURVEY_USER", "CHANGE_ME_IN_ENV_SETTINGS")
LIMESURVEY_PASSWORD = os.getenv("LIMESURVEY_PASSWORD", "CHANGE_ME_IN_ENV_SETTINGS")
# Custom parameters
FORBIDDEN_TRIGRAMS = [
"BIT",

View File

@ -74,6 +74,9 @@
</li>
{% endif %}
{% if user.registration.is_admin %}
<li class="nav-item active">
<a class="nav-link" href="{% url "survey:survey_list" %}"><i class="fas fa-square-poll-horizontal"></i> {% trans "surveys"|capfirst %}</a>
</li>
<li class="nav-item active">
<a class="nav-link" href="{% url "admin:index" %}"><i class="fas fa-cog"></i> {% trans "Administration" %}</a>
</li>

View File

@ -44,6 +44,7 @@ urlpatterns = [
path('draw/', include('draw.urls')),
path('participation/', include('participation.urls')),
path('registration/', include('registration.urls')),
path('survey/', include('survey.urls')),
path('media/authorization/photo/<str:filename>/', PhotoAuthorizationView.as_view(),
name='photo_authorization'),