diff --git a/ephios/api/serializers.py b/ephios/api/serializers.py
index e54702ba1..b8a19d462 100644
--- a/ephios/api/serializers.py
+++ b/ephios/api/serializers.py
@@ -18,6 +18,7 @@
Shift,
UserProfile,
)
+from ephios.core.models.events import ParticipationComment
from ephios.core.services.qualification import collect_all_included_qualifications
from ephios.core.templatetags.settings_extras import make_absolute
@@ -152,6 +153,14 @@ class ConfidentialParticipantSerializer(PublicParticipantSerializer):
age = serializers.IntegerField(source="get_age")
+class CommentSerializer(ModelSerializer):
+ author = serializers.CharField(source="author.display_name", read_only=True)
+
+ class Meta:
+ model = ParticipationComment
+ fields = ["author", "text", "created_at"]
+
+
class UserinfoParticipationSerializer(ModelSerializer):
state = ChoiceDisplayField(choices=AbstractParticipation.States.choices)
duration = serializers.SerializerMethodField(label=_("Duration in seconds"))
@@ -162,6 +171,7 @@ class UserinfoParticipationSerializer(ModelSerializer):
event = serializers.PrimaryKeyRelatedField(source="shift.event", read_only=True)
user = serializers.PrimaryKeyRelatedField(read_only=True, allow_null=True)
participant = ConfidentialParticipantSerializer(read_only=True)
+ comments = CommentSerializer(many=True, read_only=True)
def build_unknown_field(self, field_name, model_class):
if field_name in {"start_time", "end_time"}:
@@ -180,7 +190,7 @@ class Meta:
"event_title",
"event_type",
"state",
- "comment",
+ "comments",
"start_time",
"end_time",
"duration",
@@ -199,4 +209,4 @@ class ParticipationSerializer(UserinfoParticipationSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- del self.fields["comment"]
+ del self.fields["comments"]
diff --git a/ephios/core/admin.py b/ephios/core/admin.py
index 6a348695a..0311e1529 100644
--- a/ephios/core/admin.py
+++ b/ephios/core/admin.py
@@ -14,7 +14,7 @@
UserProfile,
WorkingHours,
)
-from ephios.core.models.events import PlaceholderParticipation
+from ephios.core.models.events import ParticipationComment, PlaceholderParticipation
from ephios.core.models.users import IdentityProvider
admin.site.register(UserProfile)
@@ -31,3 +31,4 @@
admin.site.register(PlaceholderParticipation)
admin.site.register(Notification)
admin.site.register(IdentityProvider)
+admin.site.register(ParticipationComment)
diff --git a/ephios/core/migrations/0035_participationcomment.py b/ephios/core/migrations/0035_participationcomment.py
new file mode 100644
index 000000000..616c78969
--- /dev/null
+++ b/ephios/core/migrations/0035_participationcomment.py
@@ -0,0 +1,87 @@
+# Generated by Django 5.0.9 on 2025-01-05 12:37
+
+import django.db.models.deletion
+import django.utils.timezone
+from django.conf import settings
+from django.db import migrations, models
+
+
+def migrate_comment(apps, schema_editor):
+ ParticipationComment = apps.get_model("core", "ParticipationComment")
+ AbstractParticipation = apps.get_model("core", "AbstractParticipation")
+ db_alias = schema_editor.connection.alias
+ comments = []
+ for participation in AbstractParticipation.objects.using(db_alias).all():
+ if participation.comment:
+ comments.append(
+ ParticipationComment(participation=participation, text=participation.comment)
+ )
+ ParticipationComment.objects.using(db_alias).bulk_create(comments)
+
+
+def revert_comments(apps, schema_editor):
+ ParticipationComment = apps.get_model("core", "ParticipationComment")
+ db_alias = schema_editor.connection.alias
+ for comment in ParticipationComment.objects.using(db_alias).all():
+ comment.participation.comment = comment.text
+ comment.participation.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("core", "0034_alter_eventtype_show_participant_data"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ParticipationComment",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+ ),
+ ),
+ (
+ "visible_for",
+ models.IntegerField(
+ choices=[
+ (0, "responsibles only"),
+ (1, "responsibles and corresponding participant"),
+ (2, "everyone"),
+ ],
+ default=0,
+ verbose_name="visible for",
+ ),
+ ),
+ ("text", models.CharField(max_length=255, verbose_name="Comment")),
+ (
+ "authored_by_responsible",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True),
+ ),
+ (
+ "participation",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="comments",
+ to="core.abstractparticipation",
+ ),
+ ),
+ ],
+ ),
+ migrations.RunPython(migrate_comment, revert_comments),
+ migrations.RemoveField(
+ model_name="abstractparticipation",
+ name="comment",
+ ),
+ ]
diff --git a/ephios/core/models/events.py b/ephios/core/models/events.py
index d6f00667f..8bd395a3b 100644
--- a/ephios/core/models/events.py
+++ b/ephios/core/models/events.py
@@ -273,9 +273,6 @@ def labels_dict(cls):
individual_start_time = DateTimeField(_("individual start time"), null=True)
individual_end_time = DateTimeField(_("individual end time"), null=True)
- # human readable comment
- comment = models.CharField(_("Comment"), max_length=255, blank=True)
-
"""
The finished flag is used to make sure the participation_finished signal is only sent out once, even
if the shift time is changed afterwards.
@@ -288,7 +285,6 @@ def has_customized_signup(self):
return bool(
self.individual_start_time
or self.individual_end_time
- or self.comment
or self.shift.structure.has_customized_signup(self)
)
@@ -320,6 +316,34 @@ def is_in_positive_state(self):
)
+class ParticipationComment(Model):
+ class Visibility(models.IntegerChoices):
+ RESPONSIBLES_ONLY = 0, _("responsibles only")
+ PARTICIPANT = 1, _("responsibles and corresponding participant")
+ PUBLIC = 2, _("everyone")
+
+ participation = models.ForeignKey(
+ AbstractParticipation, on_delete=models.CASCADE, related_name="comments"
+ )
+ authored_by_responsible = models.ForeignKey(
+ "UserProfile", on_delete=models.SET_NULL, blank=True, null=True
+ )
+ created_at = models.DateTimeField(auto_now_add=True)
+ visible_for = IntegerField(
+ _("visible for"), choices=Visibility.choices, default=Visibility.RESPONSIBLES_ONLY
+ )
+ text = models.CharField(_("Comment"), max_length=255)
+
+ @property
+ def author(self):
+ return self.authored_by_responsible or self.participation.participant
+
+ def __str__(self):
+ return _("Participation comment for {participation}").format(
+ participation=self.participation
+ )
+
+
class Shift(DatetimeDisplayMixin, Model):
event = ForeignKey(
Event, on_delete=models.CASCADE, related_name="shifts", verbose_name=_("event")
diff --git a/ephios/core/signup/disposition.py b/ephios/core/signup/disposition.py
index ecd9ccecf..32ee9ba3c 100644
--- a/ephios/core/signup/disposition.py
+++ b/ephios/core/signup/disposition.py
@@ -16,6 +16,7 @@
Shift,
UserProfile,
)
+from ephios.core.models.events import ParticipationComment
from ephios.core.services.notifications.types import (
ParticipationCustomizationNotification,
ParticipationStateChangeNotification,
@@ -31,6 +32,9 @@ class MissingParticipation(ValueError):
class BaseDispositionParticipationForm(BaseParticipationForm):
disposition_participation_template = "core/disposition/fragment_participation.html"
+ comment_is_internal = forms.BooleanField(
+ label=_("Hide comment for participant"), required=False, label_suffix=""
+ )
def __init__(self, **kwargs):
try:
@@ -40,10 +44,16 @@ def __init__(self, **kwargs):
super().__init__(**kwargs)
self.can_delete = self.instance.state == AbstractParticipation.States.GETTING_DISPATCHED
- self.fields["comment"].disabled = True
+
+ def get_comment_visibility(self):
+ return (
+ ParticipationComment.Visibility.RESPONSIBLES_ONLY
+ if self.cleaned_data["comment_is_internal"]
+ else ParticipationComment.Visibility.PARTICIPANT
+ )
class Meta(BaseParticipationForm.Meta):
- fields = ["state", "individual_start_time", "individual_end_time", "comment"]
+ fields = ["state", "individual_start_time", "individual_end_time"]
widgets = {"state": forms.HiddenInput(attrs={"class": "state-input"})}
@@ -215,6 +225,7 @@ def get_formset(self):
self.request.POST or None,
queryset=self.object.participations.all(),
prefix="participations",
+ form_kwargs={"acting_user": self.request.user},
)
return formset
diff --git a/ephios/core/signup/forms.py b/ephios/core/signup/forms.py
index 922f9be72..2a5f59959 100644
--- a/ephios/core/signup/forms.py
+++ b/ephios/core/signup/forms.py
@@ -2,13 +2,16 @@
from crispy_forms.helper import FormHelper
from crispy_forms.layout import HTML, Field, Layout
from django import forms
+from django.db import transaction
from django.utils.formats import date_format
from django.utils.timezone import localtime
from django.utils.translation import gettext_lazy as _
from ephios.core.models import AbstractParticipation, Shift
+from ephios.core.models.events import ParticipationComment
from ephios.core.signup.flow.participant_validation import get_conflicting_participations
from ephios.core.signup.participants import AbstractParticipant
+from ephios.core.widgets import PreviousCommentWidget
from ephios.extra.widgets import CustomSplitDateTimeWidget
@@ -21,6 +24,10 @@ class BaseParticipationForm(forms.ModelForm):
widget=CustomSplitDateTimeWidget,
required=False,
)
+ comment = forms.CharField(label=_("Comment"), max_length=255, required=False, label_suffix="")
+ comment_is_public = forms.BooleanField(
+ label=_("Make comment visible for other participants"), required=False, label_suffix=""
+ )
def clean_individual_start_time(self):
if self.cleaned_data["individual_start_time"] == self.shift.start_time:
@@ -32,8 +39,16 @@ def clean_individual_end_time(self):
return None
return self.cleaned_data["individual_end_time"]
+ def get_comment_visibility(self):
+ return (
+ ParticipationComment.Visibility.PUBLIC
+ if self.cleaned_data["comment_is_public"]
+ else ParticipationComment.Visibility.PARTICIPANT
+ )
+
def clean(self):
cleaned_data = super().clean()
+ cleaned_data["comment_visibility"] = self.get_comment_visibility()
if not self.errors:
start = cleaned_data["individual_start_time"] or self.shift.start_time
end = cleaned_data["individual_end_time"] or self.shift.end_time
@@ -41,18 +56,50 @@ def clean(self):
self.add_error("individual_end_time", _("End time must not be before start time."))
return cleaned_data
+ @transaction.atomic
+ def save(self, commit=True):
+ result = super().save()
+ if comment := self.cleaned_data["comment"]:
+ ParticipationComment.objects.create(
+ participation=result,
+ text=comment,
+ authored_by_responsible=self.acting_user,
+ visible_for=self.get_comment_visibility(),
+ )
+ return result
+
class Meta:
model = AbstractParticipation
- fields = ["individual_start_time", "individual_end_time", "comment"]
+ fields = ["individual_start_time", "individual_end_time"]
def __init__(self, *args, **kwargs):
instance = kwargs["instance"]
+ self.acting_user = kwargs.pop("acting_user", None)
kwargs["initial"] = {
**kwargs.get("initial", {}),
"individual_start_time": instance.individual_start_time or self.shift.start_time,
"individual_end_time": instance.individual_end_time or self.shift.end_time,
}
super().__init__(*args, **kwargs)
+ if self.instance.pk and self.instance.comments.exists():
+ self.fields["previous_comments"] = forms.CharField(
+ widget=PreviousCommentWidget(
+ comments=(
+ self.instance.comments.all()
+ if self.acting_user
+ and self.acting_user.has_perm(
+ "core.change_event", obj=self.instance.shift.event
+ )
+ else self.instance.comments.filter(
+ visible_for__in=[
+ ParticipationComment.Visibility.PUBLIC,
+ ParticipationComment.Visibility.PARTICIPANT,
+ ]
+ )
+ )
+ ),
+ required=False,
+ )
def get_customization_notification_info(self):
"""
diff --git a/ephios/core/templates/core/disposition/fragment_participation.html b/ephios/core/templates/core/disposition/fragment_participation.html
index e7fec366b..a9dd224c8 100644
--- a/ephios/core/templates/core/disposition/fragment_participation.html
+++ b/ephios/core/templates/core/disposition/fragment_participation.html
@@ -37,6 +37,9 @@