From d965c17512ec9728dfa7a2393ce4613274d499ac Mon Sep 17 00:00:00 2001 From: Anselm Lingnau <anselm@strathspey.org> Date: Tue, 23 May 2023 02:06:32 +0200 Subject: [PATCH] feature: Add group visibility for collections. Also, a collection settings form. --- src/ace4/db/forms.py | 46 +++++++++++++++++++ .../db/migrations/0019_collection_group.py | 28 +++++++++++ src/ace4/db/models/colls.py | 11 ++++- .../db/templates/db/coll_settings_modal.html | 13 ++++++ .../db/templates/db/collection_detail.html | 10 ++++ src/ace4/db/urls.py | 2 + src/ace4/db/views/__init__.py | 1 + src/ace4/db/views/colls.py | 28 ++++++++++- 8 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 src/ace4/db/migrations/0019_collection_group.py create mode 100644 src/ace4/db/templates/db/coll_settings_modal.html diff --git a/src/ace4/db/forms.py b/src/ace4/db/forms.py index 8ec03cc0..15f6ae5e 100644 --- a/src/ace4/db/forms.py +++ b/src/ace4/db/forms.py @@ -796,6 +796,52 @@ class IssueForm(CommentSecurityForm): return value +class CollectionSettingsForm(forms.ModelForm): + + class Meta: + model = Collection + fields = ( + 'name', 'group', + ) + help_texts = { + 'group': ( + "Group whose members can look at this collection. " + "“(Private)” means the collection isn't being shared " + "with a group." + ), + } + + def __init__(self, *args, **kwargs): + user = kwargs.pop('user', None) + super().__init__(*args, **kwargs) + self.fields['group'].label = "Visibility (Group)" + self.fields['group'].empty_label = "(Private)" + self.fields['group'].choices = ((None, "(Private)"),) + tuple( + (g.id, g.name) + for g in user.groups.order_by('name') + ) + + def clean_name(self): + name = self.cleaned_data['name'] + + # Complain about a duplicate name only if the name has changed + # compared to that in the instance (if there is one). Otherwise + # unrelated changes (e.g., to the privacy settings) would cause + # the check to trigger an error because the name is basically + # a duplicate of itself. + + if hasattr(self, 'instance') and name != self.instance.name: + try: + Collection.objects.get(owner=self.request.user, name=name) + raise ValidationError( + "You already have another collection by that name. " + "Pick a different name.") + except DanceList.DoesNotExist: + pass + + return name + + class CollectionImportForm(forms.Form): csv_file = forms.FileField(label=_('CSV File')) diff --git a/src/ace4/db/migrations/0019_collection_group.py b/src/ace4/db/migrations/0019_collection_group.py new file mode 100644 index 00000000..fff1984c --- /dev/null +++ b/src/ace4/db/migrations/0019_collection_group.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.1 on 2023-05-22 23:08 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("db", "0018_trigram_ext"), + ] + + operations = [ + migrations.AddField( + model_name="collection", + name="group", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="collections", + to="auth.group", + verbose_name="group", + ), + ), + ] diff --git a/src/ace4/db/models/colls.py b/src/ace4/db/models/colls.py index 1c505adf..dd2c6892 100644 --- a/src/ace4/db/models/colls.py +++ b/src/ace4/db/models/colls.py @@ -10,7 +10,7 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from django.contrib.auth.models import User +from django.contrib.auth.models import User, Group from ace4.db import models as d @@ -39,6 +39,10 @@ class Collection(d.DBBaseModel): owner = models.ForeignKey( User, verbose_name=_('owner'), related_name='collections', on_delete=models.CASCADE) + group = models.ForeignKey( + Group, verbose_name=_('group'), related_name='collections', + null=True, blank=True, default=None, + on_delete=models.SET_NULL) notes = models.TextField(_('notes'), default='', blank=True) code = models.SlugField(_('code'), max_length=10, default='', blank=True) url_base = models.CharField( @@ -85,7 +89,10 @@ class Collection(d.DBBaseModel): @staticmethod def get_visible(request): if request.user.is_authenticated: - return Collection.objects.filter(owner=request.user) + return ( + Collection.objects.filter(owner=request.user) + | Collection.objects.filter(group__user=request.user) + ).distinct() return Collection.objects.none() def publications(self): diff --git a/src/ace4/db/templates/db/coll_settings_modal.html b/src/ace4/db/templates/db/coll_settings_modal.html new file mode 100644 index 00000000..1c45e7b9 --- /dev/null +++ b/src/ace4/db/templates/db/coll_settings_modal.html @@ -0,0 +1,13 @@ +{% load htmx_tags crispy_forms_tags %} +{% modal "Edit Collection Settings" %} +<div class="modal-body"> + <form id="coll_settings_form" + hx-post="{{ request.path }}" hx-target="#modals-here"> + {% crispy form %} + </form> +</div> +<div class="modal-footer"> + <button type="button" class="btn btn-secondary" onclick="closeModal()">Close</button> + <button type="submit" class="btn btn-primary" form="coll_settings_form">Save Settings</button> +</div> +{% endmodal %} diff --git a/src/ace4/db/templates/db/collection_detail.html b/src/ace4/db/templates/db/collection_detail.html index d535683c..fb55f082 100644 --- a/src/ace4/db/templates/db/collection_detail.html +++ b/src/ace4/db/templates/db/collection_detail.html @@ -20,6 +20,13 @@ </dd> <dt class="col-md-2 text-md-end">Owner</dt> <dd class="col-md-10">{{ object.owner.get_full_name }}</dd> + <dt class="col-md-2 text-md-end">Visibility</dt> + <dd class="col-md-10">{% if object.group %}Group “{{ object.group.name }}”{% else %}Private{% endif %} + {% if not readonly %} + (<a href="" hx-get="{% url 'db-collection-settings' pk=object.pk %}" + hx-target="#modals-here" hx-trigger="click" + _="on htmx:afterOnLoad wait 10ms then add .show to #modal then add .show to #modal-backdrop">Change visibility</a>){% endif %} + </dd> <dt class="col-md-2 text-md-end">Last modified</dt> <dd class="col-md-10">{{ object.last_modified }}</dd> <dt class="col-md-2 text-md-end">Sharing Link</dt> @@ -50,6 +57,9 @@ Manage </button> <ul class="dropdown-menu" aria-labelledby="manageDropdown"> + <li>{% url 'db-collection-settings' pk=object.pk as dbCollSettingsUrl %} + {% modalbutton "dropdown-item" dbCollSettingsUrl "Edit List Settings …" %}</li> + <li><hr class="dropdown-divider"></li> <li><a class="dropdown-item" href="{% url 'db-collection-export-csv' pk=object.id %}">Export in CSV format</a></li> <li>{% url 'db-collection-import-csv' pk=object.pk as dbCollImportUrl %} {% modalbutton "dropdown-item" dbCollImportUrl "Import from CSV file …" %}</li> diff --git a/src/ace4/db/urls.py b/src/ace4/db/urls.py index a87a8cab..4bbf6dbe 100644 --- a/src/ace4/db/urls.py +++ b/src/ace4/db/urls.py @@ -286,6 +286,8 @@ urlpatterns = [ path('collection/<int:pk>/ajax/rtab/', db_views.CollectionsAjaxViewRtab.as_view(), name='db-collections-ajax-rtab'), + path('collection/<int:pk>/settings/', db_views.collection_edit_settings, + name='db-collection-settings'), path('collection/<int:pk>/export/csv/', db_views.collection_export_csv, name='db-collection-export-csv'), path('collection/<int:pk>/import/csv/', db_views.collection_import_csv, diff --git a/src/ace4/db/views/__init__.py b/src/ace4/db/views/__init__.py index d99f6d6c..079f3509 100644 --- a/src/ace4/db/views/__init__.py +++ b/src/ace4/db/views/__init__.py @@ -79,6 +79,7 @@ from .colls import ( # noqa: F401 CollectionDetailViewReadOnly, CollectionsAjaxViewPtab, CollectionsAjaxViewDtab, CollectionsAjaxViewAtab, CollectionsAjaxViewRtab, + collection_edit_settings, collection_add, collection_export_csv, collection_import_csv, collection_clear, collection_delete, collection_item_add, collection_edit_comment, collection_item_delete, diff --git a/src/ace4/db/views/colls.py b/src/ace4/db/views/colls.py index 036a826e..831979ee 100644 --- a/src/ace4/db/views/colls.py +++ b/src/ace4/db/views/colls.py @@ -18,7 +18,7 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required from ace4.db.forms import ( - CollectionImportForm, + CollectionSettingsForm, CollectionImportForm, CollectionClearForm, CollectionDeleteForm, ) from ace4.db.models import ( @@ -40,6 +40,7 @@ class CollectionListView(DBTableListView): [_('Name'), {'type': 'html'}], [_('Content'), {'type': 'html'}], [_('Owner'), {'type': 'string'}], + [_('Visibility'), {'type': 'string'}], [_('Last Change'), {'type': 'date', 'width': '12em'}], ] @@ -65,6 +66,7 @@ class CollectionsAjaxView(DBAjaxListView): return (detail_link(object, 'collection'), object.get_ctype_display(), object.owner.get_full_name(), + object.group.name if object.group is not None else "(Private)", object.last_modified.strftime("%Y-%m-%d %H:%M:%S")) @@ -174,7 +176,7 @@ class CollectionDetailView(DBDetailView): CollectionAlbumItem.objects.filter(collection=object)) context['recitems'] = ( CollectionRecordingItem.objects.filter(collection=object)) - context['readonly'] = False + context['readonly'] = self.request.user != object.owner return context @@ -334,6 +336,28 @@ def collection_add(request): return http.HttpResponseRedirect(reverse('db-collections')) +@login_required +def collection_edit_settings(request, pk): + collection = get_object_or_404(Collection, owner=request.user, id=pk) + if request.user != collection.owner: + return http.HttpResponseForbidden("Not your collection") + + form = CollectionSettingsForm( + request.POST or None, user=request.user, instance=collection + ) + if request.method == "POST" and form.is_valid(): + form.save() + response = http.HttpResponse("") + response["HX-Redirect"] = reverse( + "db-collection", kwargs={"pk": collection.pk} + ) + return response + + return TemplateResponse( + request, "db/coll_settings_modal.html", {"form": form} + ) + + @login_required def collection_export_csv(request, pk): # Export (dance or music) collection as CSV -- GitLab