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..76a399c1c --- /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" + ), + ), + ( + "visibile_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..0fd3fcf44 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) + visibile_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..74d1ba63a 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 + ) 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..ff2c25e2e 100644 --- a/ephios/core/signup/forms.py +++ b/ephios/core/signup/forms.py @@ -2,14 +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.extra.widgets import CustomSplitDateTimeWidget +from ephios.extra.widgets import CustomSplitDateTimeWidget, PreviousCommentWidget class BaseParticipationForm(forms.ModelForm): @@ -21,6 +23,10 @@ class BaseParticipationForm(forms.ModelForm): widget=CustomSplitDateTimeWidget, required=False, ) + comment = forms.CharField(label=_("Comment"), max_length=255, required=False) + comment_is_public = forms.BooleanField( + label=_("Make comment visible for other participants"), required=False + ) def clean_individual_start_time(self): if self.cleaned_data["individual_start_time"] == self.shift.start_time: @@ -32,8 +38,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 +55,35 @@ def clean(self): self.add_error("individual_end_time", _("End time must not be before start time.")) return cleaned_data + def save(self): + with transaction.atomic(): + result = super().save() + if comment := self.cleaned_data["comment"]: + ParticipationComment.objects.create( + participation=result, + text=comment, + authored_by_responsible=self.acting_user, + visibile_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()), 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 @@