diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py index cef045d867bd838acc8443c17eb695a48e90b31a..0daff5fd91fe2c45452025dd4d1e99bb7246953e 100644 --- a/aleksis/core/forms.py +++ b/aleksis/core/forms.py @@ -1,10 +1,17 @@ +from datetime import time +from typing import Optional + from django import forms from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.utils import timezone from django.utils.translation import ugettext_lazy as _ from django_select2.forms import ModelSelect2MultipleWidget, Select2Widget +from material import Layout, Fieldset, Row -from .models import Group, Person, School, SchoolTerm +from .models import Group, Person, School, SchoolTerm, Announcement, AnnouncementRecipient class PersonAccountForm(forms.ModelForm): @@ -132,3 +139,105 @@ class EditTermForm(forms.ModelForm): class Meta: model = SchoolTerm fields = ["caption", "date_start", "date_end"] + + +class AnnouncementForm(forms.ModelForm): + valid_from = forms.DateTimeField(required=False) + valid_until = forms.DateTimeField(required=False) + + valid_from_date = forms.DateField(label=_("Date")) + valid_from_time = forms.TimeField(label=_("Time")) + + valid_until_date = forms.DateField(label=_("Date")) + valid_until_time = forms.TimeField(label=_("Time")) + + persons = forms.ModelMultipleChoiceField(Person.objects.all(), label=_("Persons"), required=False) + groups = forms.ModelMultipleChoiceField(Group.objects.all(), label=_("Groups"), required=False) + + layout = Layout( + Fieldset( + _("From when until when should the announcement be displayed?"), + Row("valid_from_date", "valid_from_time", "valid_until_date", "valid_until_time"), + ), + Fieldset(_("Who should see the announcement?"), Row("groups", "persons")), + Fieldset(_("Write your announcement:"), "title", "description"), + ) + + def __init__(self, *args, **kwargs): + if "instance" not in kwargs: + kwargs["initial"] = { + "valid_from_date": timezone.datetime.now(), + "valid_from_time": time(0, 0), + "valid_until_date": timezone.datetime.now(), + "valid_until_time": time(23, 59), + } + else: + announcement = kwargs["instance"] + + # Fill special fields from given announcement instance + kwargs["initial"] = { + "valid_from_date": announcement.valid_from.date(), + "valid_from_time": announcement.valid_from.time(), + "valid_until_date": announcement.valid_until.date(), + "valid_until_time": announcement.valid_until.time(), + "groups": announcement.get_recipients_for_model(Group), + "persons": announcement.get_recipients_for_model(Person), + } + super().__init__(*args, **kwargs) + + def clean(self): + data = super().clean() + + # Check date and time + from_date = data["valid_from_date"] + from_time = data["valid_from_time"] + until_date = data["valid_until_date"] + until_time = data["valid_until_time"] + + valid_from = timezone.datetime.combine(from_date, from_time) + valid_until = timezone.datetime.combine(until_date, until_time) + + if valid_until < timezone.datetime.now(): + raise ValidationError( + _("You are not allowed to create announcements which are only valid in the past.") + ) + elif valid_from > valid_until: + raise ValidationError( + _("The from date and time must be earlier then the until date and time.") + ) + + data["valid_from"] = valid_from + data["valid_until"] = valid_until + + # Check recipients + if "groups" not in data and "persons" not in data: + raise ValidationError(_("You need at least one recipient.")) + + recipients = [] + recipients += data.get("groups", []) + recipients += data.get("persons", []) + + data["recipients"] = recipients + + return data + + def save(self, _=False): + # Save announcement + a = self.instance if self.instance is not None else Announcement() + a.valid_from = self.cleaned_data["valid_from"] + a.valid_until = self.cleaned_data["valid_until"] + a.title = self.cleaned_data["title"] + a.description = self.cleaned_data["description"] + a.save() + + # Save recipients + a.recipients.all().delete() + for recipient in self.cleaned_data["recipients"]: + a.recipients.create(recipient=recipient) + a.save() + + return a + + class Meta: + model = Announcement + exclude = [] diff --git a/aleksis/core/menus.py b/aleksis/core/menus.py index 1c41dbb704847658c4ae91e4e060ff54bc1548b0..0fdada99f18cf38b1dae32bf55848e4422cbd838 100644 --- a/aleksis/core/menus.py +++ b/aleksis/core/menus.py @@ -57,6 +57,15 @@ MENUS = { "menu_generator.validators.is_superuser", ], "submenu": [ + { + "name": _("Announcements"), + "url": "announcements", + "icon": "announcement", + "validators": [ + "menu_generator.validators.is_authenticated", + "menu_generator.validators.is_superuser", + ], + }, { "name": _("Data management"), "url": "data_management", diff --git a/aleksis/core/migrations/0013_multiple_recipients_announcement.py b/aleksis/core/migrations/0013_multiple_recipients_announcement.py new file mode 100644 index 0000000000000000000000000000000000000000..cdb9eae7d6769f0cd72ad5f56c0c5a12ffc2521a --- /dev/null +++ b/aleksis/core/migrations/0013_multiple_recipients_announcement.py @@ -0,0 +1,51 @@ +# Generated by Django 3.0.3 on 2020-02-19 18:14 + +import aleksis.core.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('core', '0012_announcement'), + ] + + operations = [ + migrations.AlterModelOptions( + name='announcement', + options={'verbose_name': 'Announcement', 'verbose_name_plural': 'Announcements'}, + ), + migrations.RemoveField( + model_name='announcement', + name='content_type', + ), + migrations.RemoveField( + model_name='announcement', + name='recipient_id', + ), + migrations.AlterField( + model_name='announcement', + name='description', + field=models.TextField(blank=True, max_length=500, verbose_name='Description'), + ), + migrations.AlterField( + model_name='announcement', + name='valid_until', + field=models.DateTimeField(default=aleksis.core.models.now_tomorrow, verbose_name='Date and time until when to show'), + ), + migrations.CreateModel( + name='AnnouncementRecipient', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('recipient_id', models.PositiveIntegerField()), + ('announcement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recipients', to='core.Announcement')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ], + ), + migrations.AlterModelOptions( + name='announcementrecipient', + options={'verbose_name': 'Announcement recipient', 'verbose_name_plural': 'Announcement recipients'}, + ), + ] diff --git a/aleksis/core/models.py b/aleksis/core/models.py index 9f001cc714584c9c4694d44b5f53f72971f3f6b9..aef59f28a6d81af6a0a970bdc90eedfdfe05f501 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -330,6 +330,12 @@ class Announcement(ExtensibleModel): persons += recipient.persons return persons + def get_recipients_for_model(self, obj: Union[models.Model]) -> Sequence[models.Model]: + """ Get all recipients for this announcement with a special content type (provided through model) """ + + ct = ContentType.objects.get_for_model(obj) + return [r.recipient for r in self.recipients.filter(content_type=ct)] + def __str__(self): return self.title diff --git a/aleksis/core/templates/core/announcement/form.html b/aleksis/core/templates/core/announcement/form.html new file mode 100644 index 0000000000000000000000000000000000000000..0cd31cdefc11e8f1a2d29a30fb72a001357a946f --- /dev/null +++ b/aleksis/core/templates/core/announcement/form.html @@ -0,0 +1,33 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} + +{% load i18n material_form %} + + +{% block browser_title %} + {% if mode == "edit" %} + {% blocktrans %}Edit announcement{% endblocktrans %} + {% else %} + {% blocktrans %}Publish announcement{% endblocktrans %} + {% endif %} +{% endblock %} +{% block page_title %} + {% if mode == "edit" %} + {% blocktrans %}Edit announcement{% endblocktrans %} + {% else %} + {% blocktrans %}Publish new announcement{% endblocktrans %} + {% endif %} +{% endblock %} + +{% block content %} + <form action="" method="post"> + {% csrf_token %} + {% form form=form %}{% endform %} + + <button type="submit" class="btn green waves-effect waves-light"> + <i class="material-icons left">save</i> + {% trans "Save und publish announcement" %} + </button> + </form> +{% endblock %} diff --git a/aleksis/core/templates/core/announcement/list.html b/aleksis/core/templates/core/announcement/list.html new file mode 100644 index 0000000000000000000000000000000000000000..9f84617e255c78b7ee37e502c212e0704aa1da5e --- /dev/null +++ b/aleksis/core/templates/core/announcement/list.html @@ -0,0 +1,56 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} + +{% load i18n %} + +{% block browser_title %}{% blocktrans %}Announcements{% endblocktrans %}{% endblock %} +{% block page_title %}{% blocktrans %}Announcements{% endblocktrans %}{% endblock %} + +{% block content %} + <a class="btn green waves-effect waves-light" href="{% url "add_announcement" %}"> + <i class="material-icons left">add</i> + {% trans "Publish new announcement" %} + </a> + <table class="highlight"> + <thead> + <tr> + <th>{% trans "Title" %}</th> + <th>{% trans "Valid from" %}</th> + <th>{% trans "Valid until" %}</th> + <th>{% trans "Recipients" %}</th> + <th>{% trans "Actions" %}</th> + </tr> + </thead> + <tbody> + {% for announcement in announcements %} + <tr> + <td>{{ announcement.title }}</td> + <td>{{ announcement.valid_from }}</td> + <td>{{ announcement.valid_until }}</td> + <td>{{ announcement.recipients.all|join:", " }}</td> + <td> + <a class="btn-flat waves-effect waves-orange orange-text" + href="{% url "edit_announcement" announcement.id %}"> + <i class="material-icons left">edit</i> + {% trans "Edit" %} + </a> + <form action="{% url "delete_announcement" announcement.id %}" method="post"> + {% csrf_token %} + <button class="btn-flat waves-effect waves-re red-text" type="submit"> + <i class="material-icons left">delete</i> + {% trans "Delete" %} + </button> + </form> + </td> + </tr> + {% empty %} + <tr> + <td colspan="5"> + <p class="flow-text center-align">{% trans "There are no announcements." %}</p> + </td> + </tr> + {% endfor %} + </tbody> + </table> +{% endblock %} diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index 6f1d4275e0fd15f2b1cd315353beb56d219342fd..e5320dd02f05a884a8891c7793305f8c0672d7fc 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -35,6 +35,10 @@ urlpatterns = [ path("group/<int:id_>/edit", views.edit_group, name="edit_group_by_id"), path("", views.index, name="index"), path("notifications/mark-read/<int:id_>", views.notification_mark_read, name="notification_mark_read"), + path("announcements/", views.announcements, name="announcements"), + path("announcement/create/", views.announcement_form, name="add_announcement"), + path("announcement/edit/<int:pk>/", views.announcement_form, name="edit_announcement"), + path("announcement/delete/<int:pk>/", views.delete_announcement, name="delete_announcement"), path("maintenance-mode/", include("maintenance_mode.urls")), path("impersonate/", include("impersonate.urls")), path("__i18n__/", include("django.conf.urls.i18n")), diff --git a/aleksis/core/views.py b/aleksis/core/views.py index 98db80da4a5d30df58f6bc2a8d3d037f226a0044..68773c76398542863bef6df3316ca3799a643e6a 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -15,6 +15,7 @@ from .forms import ( EditSchoolForm, EditTermForm, PersonsAccountsFormSet, + AnnouncementForm, ) from .models import Activity, Group, Notification, Person, School, DashboardWidget, Announcement from .tables import GroupsTable, PersonsTable @@ -271,3 +272,51 @@ def notification_mark_read(request: HttpRequest, id_: int) -> HttpResponse: raise PermissionDenied(_("You are not allowed to mark notifications from other users as read!")) return redirect("index") + + +@admin_required +def announcements(request: HttpRequest) -> HttpResponse: + context = {} + + # Get all persons + announcements = Announcement.objects.all() + context["announcements"] = announcements + + return render(request, "core/announcement/list.html", context) + + +@admin_required +def announcement_form(request: HttpRequest, pk: Optional[int] = None) -> HttpResponse: + context = {} + + if pk: + announcement = get_object_or_404(Announcement, pk=pk) + form = AnnouncementForm( + request.POST or None, + instance=announcement + ) + context["mode"] = "edit" + else: + form = AnnouncementForm(request.POST or None) + context["mode"] = "add" + + if request.method == "POST": + if form.is_valid(): + form.save() + + messages.success(request, _("The announcement has been saved.")) + return redirect("announcements") + + context["form"] = form + + return render(request, "core/announcement/form.html", context) + + +@admin_required +def delete_announcement(request: HttpRequest, pk: int) -> HttpResponse: + if request.method == "POST": + announcement = get_object_or_404(Announcement, pk=pk) + announcement.delete() + messages.success(request, _("The announcement has been deleted.")) + + return redirect("announcements")