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