diff --git a/indigo_api/models/places.py b/indigo_api/models/places.py index 92dbca5a2..699291afb 100644 --- a/indigo_api/models/places.py +++ b/indigo_api/models/places.py @@ -126,6 +126,22 @@ def get_country_locality(cls, code): return country, locality +class AllPlace: + """A fake country that mimics a country but is used to represent all places in the system.""" + place_code = code = iso = 'all' + name = _('All places') + + @property + def country(self): + return self + + @classmethod + def filter_works_queryset(cls, works, user): + if not user.is_superuser: + works = works.filter(country__in=user.editor.permitted_countries.all()) + return works + + @receiver(signals.post_save, sender=Country) def post_save_country(sender, instance, **kwargs): """ When a country is saved, make sure a PlaceSettings exists for it. diff --git a/indigo_app/forms/works.py b/indigo_app/forms/works.py index 6f976b431..9bf8486f5 100644 --- a/indigo_app/forms/works.py +++ b/indigo_app/forms/works.py @@ -16,7 +16,7 @@ from cobalt import FrbrUri from indigo.tasks import TaskBroker from indigo_api.models import Work, VocabularyTopic, TaxonomyTopic, Amendment, Subtype, Locality, PublicationDocument, \ - Commencement, Workflow, Task, Country, WorkAlias, ArbitraryExpressionDate + Commencement, Workflow, Task, Country, WorkAlias, ArbitraryExpressionDate, AllPlace from indigo_app.forms.mixins import FormAsUrlMixin @@ -741,6 +741,8 @@ class Facet: class WorkFilterForm(forms.Form, FormAsUrlMixin): q = forms.CharField() + place = forms.MultipleChoiceField() + assent_date_start = forms.DateField(input_formats=['%Y-%m-%d']) assent_date_end = forms.DateField(input_formats=['%Y-%m-%d']) @@ -810,6 +812,11 @@ def __init__(self, country, *args, **kwargs): settings.INDIGO['EXTRA_DOCTYPES'].get(self.country.code, [])] subtypes = [(s.abbreviation, s.name) for s in Subtype.objects.all()] self.fields['subtype'] = forms.MultipleChoiceField(required=False, choices=doctypes + subtypes) + self.fields['place'].choices = [ + (c.code, c.name) for c in Country.objects.all() + ] + [ + (loc.place_code, loc.name) for loc in Locality.objects.all() + ] def show_advanced_filters(self): # Should we show the advanced options box by default? @@ -837,6 +844,13 @@ def filter_queryset(self, queryset, exclude=None): if uris: queryset = queryset.filter(frbr_uri__in=uris) + if exclude != "place": + q = Q() + for place in self.cleaned_data.get('place', []): + country, locality = Country.get_country_locality(place) + q |= Q(country=country, locality=locality) + queryset = queryset.filter(q) + # filter by work in progress if exclude != "work_in_progress": work_in_progress_filter = self.cleaned_data.get('work_in_progress', []) @@ -1054,7 +1068,7 @@ def facet(self, name, type, items): items = [self.facet_item(name, value, count) for value, count in items] return Facet(self.fields[name].label, name, type, items) - def work_facets(self, queryset, taxonomy_toc): + def work_facets(self, queryset, taxonomy_toc, places_toc): work_facets = [] self.facet_subtype(work_facets, queryset) self.facet_work_in_progress(work_facets, queryset) @@ -1068,6 +1082,7 @@ def work_facets(self, queryset, taxonomy_toc): self.facet_consolidation(work_facets, queryset) self.facet_repeal(work_facets, queryset) self.facet_taxonomy(taxonomy_toc, queryset) + self.facet_place(places_toc, queryset) return work_facets def document_facets(self, queryset): @@ -1282,6 +1297,33 @@ def decorate(item): for item in taxonomy_tree: decorate(item) + def facet_place(self, place_tree, qs): + if not place_tree: + return + + qs = self.filter_queryset(qs, exclude="place") + + # count works per place + counts = {} + for row in qs.values("country__country__pk", "locality__code").annotate(count=Count("id")).order_by(): + code = row["country__country__pk"].lower() + code = code + ("-" + row["locality__code"] if row["locality__code"] else "") + counts[code] = row["count"] + + # fold the counts into the taxonomy tree + def decorate(item): + total = 0 + for child in item.get('children', []): + total = total + decorate(child) + # count for this item + item['data']['count'] = counts.get(item["data"]["slug"]) + # total of count for descendants + item['data']['total'] = total + return total + (item['data']['count'] or 0) + + for item in place_tree: + decorate(item) + class WorkChooserForm(forms.Form): country = forms.ModelChoiceField(queryset=Country.objects) @@ -1319,7 +1361,22 @@ def clean_all_work_pks(self): return self.cleaned_data.get('all_work_pks').split() or [] -class WorkBulkUpdateForm(forms.Form): +class WorkBulkActionFormBase(forms.Form): + """Base form for bulk work actions in the works listing view. Ensures that the works queryset is + limited to the appropriate country, locality and user permissions. + """ + works = forms.ModelMultipleChoiceField(queryset=Work.objects, required=True) + + def __init__(self, country, locality, user, *args, **kwargs): + super().__init__(*args, **kwargs) + + if country.place_code == 'all': + self.fields['works'].queryset = AllPlace.filter_works_queryset(self.fields['works'].queryset, user) + else: + self.fields['works'].queryset = self.fields['works'].queryset.filter(country=country, locality=locality) + + +class WorkBulkUpdateForm(WorkBulkActionFormBase): save = forms.BooleanField(required=False) works = forms.ModelMultipleChoiceField(queryset=Work.objects, required=False) add_taxonomy_topics = forms.ModelMultipleChoiceField( @@ -1339,10 +1396,10 @@ def save_changes(self): work.taxonomy_topics.remove(*self.cleaned_data['del_taxonomy_topics']) -class WorkBulkApproveForm(forms.Form): +class WorkBulkApproveForm(WorkBulkActionFormBase): TASK_CHOICES = [('', 'Create tasks'), ('block', _('Create and block tasks')), ('cancel', _('Create and cancel tasks'))] - works_in_progress = forms.ModelMultipleChoiceField(queryset=Work.objects, required=False) + works = forms.ModelMultipleChoiceField(queryset=Work.objects, required=False) conversion_task_description = forms.CharField(required=False) import_task_description = forms.CharField(required=False) gazette_task_description = forms.CharField(required=False) @@ -1358,7 +1415,7 @@ def __init__(self, *args, **kwargs): broker_class = kwargs.pop('broker_class', TaskBroker) super().__init__(*args, **kwargs) if self.is_valid(): - self.broker = broker_class(self.cleaned_data.get('works_in_progress', [])) + self.broker = broker_class(self.cleaned_data.get('works', [])) self.add_amendment_task_description_fields() self.full_clean() @@ -1373,8 +1430,8 @@ def save_changes(self, request): self.broker.create_tasks(request.user, self.cleaned_data) -class WorkBulkUnapproveForm(forms.Form): - approved_works = forms.ModelMultipleChoiceField(queryset=Work.objects, required=False) +class WorkBulkUnapproveForm(WorkBulkActionFormBase): + works = forms.ModelMultipleChoiceField(queryset=Work.objects, required=False) unapprove = forms.BooleanField(required=False) diff --git a/indigo_app/js/components/TaxonomyTOC.vue b/indigo_app/js/components/TaxonomyTOC.vue index 48ee2d299..15bfda64b 100644 --- a/indigo_app/js/components/TaxonomyTOC.vue +++ b/indigo_app/js/components/TaxonomyTOC.vue @@ -5,7 +5,7 @@ expand-all-btn-classes="btn btn-sm btn-secondary" title-filter-clear-btn-classes="btn btn-sm btn-secondary" title-filter-input-classes="form-field" - title-filter-placeholder="Filter by topic" + title-filter-placeholder="Filter..." class="taxonomy-sidebar" > @@ -19,9 +19,9 @@ */ export default { name: 'TaxonomyTOC', - props: ['checkbox', 'selected'], - data () { - const taxonomy = JSON.parse(document.querySelector('#taxonomy_toc').textContent); + props: ['checkbox', 'selected', 'tree'], + data (self) { + const taxonomy = JSON.parse(document.querySelector(self.tree || '#taxonomy_toc').textContent); const selected = (this.selected || '').split(' '); // Set expanded state of current item and its parents @@ -38,8 +38,7 @@ export default { }; }, mounted () { - const toc = document.getElementsByTagName('la-table-of-contents-controller'); - toc[0].addEventListener('itemRendered', (e) => { + this.$el.addEventListener('itemRendered', (e) => { const tocItem = e.target; if (!tocItem) return; diff --git a/indigo_app/templates/indigo_api/work_overview.html b/indigo_app/templates/indigo_api/work_overview.html index 514c7b23b..4814d956d 100644 --- a/indigo_app/templates/indigo_api/work_overview.html +++ b/indigo_app/templates/indigo_api/work_overview.html @@ -13,7 +13,7 @@ hx-target="#work-approve-modal" > {% csrf_token %} - + - - {% endif %} + {% if place.place_code != "all" %} +
+
+ {% trans "Add new work" %} + {% if perms.indigo_api.bulk_add_work %} + + + {% endif %} +
- + {% endif %} {% endblock %} diff --git a/indigo_app/templates/main.html b/indigo_app/templates/main.html index 7cc31e84e..52f45c180 100644 --- a/indigo_app/templates/main.html +++ b/indigo_app/templates/main.html @@ -36,22 +36,8 @@ {% if request.user.is_authenticated %} + {% endif %} - {% block navbar-help-menu %} - - {% endblock %} {% endblock %} @@ -81,6 +67,21 @@ {% endif %} {% endblock %} + {% block navbar-help-menu %} + + {% endblock %} diff --git a/indigo_app/templates/place/tabbed_layout.html b/indigo_app/templates/place/tabbed_layout.html index fb9be3e63..7bb452368 100644 --- a/indigo_app/templates/place/tabbed_layout.html +++ b/indigo_app/templates/place/tabbed_layout.html @@ -34,30 +34,34 @@ diff --git a/indigo_app/tests/test_places.py b/indigo_app/tests/test_places.py index 18991f5f6..b0ee64181 100644 --- a/indigo_app/tests/test_places.py +++ b/indigo_app/tests/test_places.py @@ -35,6 +35,20 @@ def test_place_works(self): response = self.client.get('/places/za/works') self.assertEqual(response.status_code, 200) + response = self.client.get('/places/za/works/facets') + self.assertEqual(response.status_code, 200) + + def test_all_place_works(self): + response = self.client.get('/places/all/works') + self.assertEqual(response.status_code, 200) + + response = self.client.get('/places/all/works/facets') + self.assertEqual(response.status_code, 200) + + def test_all_place_tasks_404(self): + response = self.client.get('/places/all/tasks') + self.assertEqual(response.status_code, 404) + def test_place_works_xlsx(self): response = self.client.get('/places/za/works?format=xlsx') self.assertEqual(response.status_code, 200) diff --git a/indigo_app/views/base.py b/indigo_app/views/base.py index dcaa43521..e6ef6d142 100644 --- a/indigo_app/views/base.py +++ b/indigo_app/views/base.py @@ -5,7 +5,7 @@ from django.http import Http404 from indigo_api.authz import is_maintenance_mode -from indigo_api.models import Country +from indigo_api.models import Country, Work, AllPlace class IndigoJSViewMixin(object): @@ -67,11 +67,15 @@ class PlaceViewBase(AbstractAuthedIndigoView): The place is determined and set on the view right at the start of dispatch, and `country`, `locality` and `place` set accordingly. + + If the allow_all_place attribute is set to True, the view will allow the special + 'all' place, which is a special case that means all places in the system. """ country = None locality = None place = None permission_required = ('indigo_api.view_country',) + allow_all_place = False def dispatch(self, request, *args, **kwargs): self.determine_place() @@ -85,20 +89,23 @@ def get_context_data(self, **kwargs): return super().get_context_data(**kwargs) def determine_place(self): - parts = self.kwargs['place'].split('-', 1) - country = parts[0] - locality = parts[1] if len(parts) > 1 else None - - try: - self.country = Country.for_code(country) - except Country.DoesNotExist: - raise Http404 - - if locality: - self.locality = self.country.localities.filter(code=locality).first() - if not self.locality: + if self.kwargs['place'] == 'all' and self.allow_all_place: + self.country = AllPlace() + else: + parts = self.kwargs['place'].split('-', 1) + country = parts[0] + locality = parts[1] if len(parts) > 1 else None + + try: + self.country = Country.for_code(country) + except Country.DoesNotExist: raise Http404 + if locality: + self.locality = self.country.localities.filter(code=locality).first() + if not self.locality: + raise Http404 + self.place = self.locality or self.country def has_permission(self): @@ -112,6 +119,9 @@ def has_country_permission(self): raise Exception("This request will change state and country permissions are required, " "but self.country is None.") + if self.country.place_code == 'all': + return self.has_all_country_permission() + return self.request.user.editor.has_country_permission(self.country) def doctypes(self): @@ -122,3 +132,20 @@ def doctypes(self): return doctypes + extras return doctypes + + def has_all_country_permission(self): + return False + + +class PlaceWorksViewBase(PlaceViewBase): + """Base view for views that display a list of works for a place, that adds support + for filtering by the special All place.""" + queryset = Work.objects.order_by('-created_at').prefetch_related('country', 'locality') + + def get_base_queryset(self): + queryset = self.queryset + if self.country.place_code == "all": + queryset = AllPlace.filter_works_queryset(queryset, self.request.user) + else: + queryset = queryset.filter(country=self.country, locality=self.locality) + return queryset diff --git a/indigo_app/views/places.py b/indigo_app/views/places.py index 89cf45ce4..c9dbacb90 100644 --- a/indigo_app/views/places.py +++ b/indigo_app/views/places.py @@ -17,13 +17,13 @@ from django_htmx.http import push_url from lxml import etree -from indigo_api.models import Country, Task, Work, Subtype, Locality, TaskLabel, Document, TaxonomyTopic +from indigo_api.models import Country, Task, Work, Subtype, Locality, TaskLabel, Document, TaxonomyTopic, AllPlace from indigo_api.timeline import describe_publication_event from indigo_app.forms import WorkFilterForm, PlaceSettingsForm, PlaceUsersForm, ExplorerForm, WorkBulkActionsForm, \ WorkChooserForm, WorkBulkUpdateForm, WorkBulkApproveForm, WorkBulkUnapproveForm from indigo_app.xlsx_exporter import XlsxExporter from indigo_social.badges import badges -from .base import AbstractAuthedIndigoView, PlaceViewBase +from .base import AbstractAuthedIndigoView, PlaceViewBase, PlaceWorksViewBase log = logging.getLogger(__name__) @@ -123,6 +123,12 @@ def get_context_data(self, **kwargs): class PlaceDetailView(PlaceViewBase, TemplateView): template_name = 'place/detail.html' tab = 'overview' + allow_all_place = True + + def get(self, request, *args, **kwargs): + if self.place.place_code == 'all': + return redirect('place_works', place='all') + return super().get(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -507,12 +513,12 @@ def get_success_url(self): return reverse('place_users', kwargs={'place': self.kwargs['place']}) -class PlaceWorksIndexView(PlaceViewBase, TemplateView): +class PlaceWorksIndexView(PlaceWorksViewBase, TemplateView): tab = 'place_settings' permission_required = ('indigo_api.change_placesettings',) def get(self, request, *args, **kwargs): - works = Work.objects.filter(country=self.country, locality=self.locality).order_by('publication_date') + works = self.get_base_queryset().order_by('publication_date') filename = _("Full index for %(place)s.xlsx") % {"place": self.place} exporter = XlsxExporter(self.country, self.locality) return exporter.generate_xlsx(works, filename, True) @@ -541,13 +547,14 @@ def get_context_data(self, **kwargs): return context -class PlaceWorksView(PlaceViewBase, ListView): +class PlaceWorksView(PlaceWorksViewBase, ListView): template_name = 'indigo_app/place/works.html' tab = 'works' context_object_name = 'works' paginate_by = 50 http_method_names = ['post', 'get'] filter_form_class = WorkFilterForm + allow_all_place = True def post(self, request, *args, **kwargs): return self.get(request, *args, **kwargs) @@ -564,10 +571,7 @@ def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) def get_queryset(self): - queryset = Work.objects \ - .filter(country=self.country, locality=self.locality) \ - .order_by('-created_at') - return self.form.filter_queryset(queryset) + return self.form.filter_queryset(self.get_base_queryset()) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -575,16 +579,17 @@ def get_context_data(self, **kwargs): # using .only("pk") makes the query much faster; values_list just gives us the pks work_pks_list = list(self.get_queryset().only("pk").values_list("pk", flat=True)) context['work_pks'] = ' '.join(str(pk) for pk in work_pks_list) - context['total_works'] = Work.objects.filter(country=self.country, locality=self.locality).count() + context['total_works'] = self.get_base_queryset().count() query_url = (self.request.POST or self.request.GET).urlencode() context['facets_url'] = ( reverse('place_works_facets', kwargs={'place': self.kwargs['place']}) + '?' + query_url ) - context['download_xsl_url'] = ( - reverse('place_works', kwargs={'place': self.kwargs['place']}) + - '?' + query_url + '&format=xlsx' - ) + if self.country.place_code != 'all': + context['download_xsl_url'] = ( + reverse('place_works', kwargs={'place': self.kwargs['place']}) + + '?' + query_url + '&format=xlsx' + ) return context def render_to_response(self, context, **response_kwargs): @@ -600,9 +605,13 @@ def get_template_names(self): return ['indigo_app/place/_works_list.html'] return super().get_template_names() + def has_all_country_permission(self): + return True + -class PlaceWorksFacetsView(PlaceViewBase, TemplateView): +class PlaceWorksFacetsView(PlaceWorksViewBase, TemplateView): template_name = 'indigo_app/place/_works_facets.html' + allow_all_place = True def get(self, request, *args, **kwargs): self.form = PlaceWorksView.filter_form_class(self.country, request.GET) @@ -615,18 +624,44 @@ def get_context_data(self, **kwargs): context['form'] = self.form context['taxonomy_toc'] = TaxonomyTopic.get_toc_tree(self.request.GET, all_topics=False) - qs = Work.objects.filter(country=self.country, locality=self.locality) + if self.country.place_code == 'all': + # dump the places tree for the places the user has permissions for + if self.request.user.is_superuser: + countries = Country.objects + else: + countries = self.request.user.editor.permitted_countries.all() + countries = countries.prefetch_related('country', 'localities') + context['places_toc'] = [{ + 'title': country.name, + 'data': { + 'slug': country.code, + 'count': 0, + 'total': 0, + }, + 'children': [{ + 'title': loc.name, + 'data': { + 'slug': loc.place_code, + 'count': 0, + 'total': 0, + }, + 'children': [], + } for loc in country.localities.all()] + } for country in countries] + + qs = self.get_base_queryset() # build facets - context["work_facets"] = self.form.work_facets(qs, context['taxonomy_toc']) + context["work_facets"] = self.form.work_facets(qs, context['taxonomy_toc'], context.get('places_toc', [])) context["document_facets"] = self.form.document_facets(qs) return context -class WorkActionsView(PlaceViewBase, FormView): +class WorkActionsView(PlaceWorksViewBase, FormView): form_class = WorkBulkActionsForm template_name = "indigo_app/place/_works_actions.html" + allow_all_place = True def form_valid(self, form): # this form just gets the works and gives the context to the actions toolbar @@ -635,18 +670,52 @@ def form_valid(self, form): def get_context_data(self, form, **kwargs): context = super().get_context_data(**kwargs) if self.request.user.has_perm('indigo_api.bulk_add_work'): - works = self.get_works(form) + works, disallowed = self.get_works(form) context["works"] = works context["works_in_progress"] = works.filter(work_in_progress=True) if works else [] context["approved_works"] = works.filter(work_in_progress=False) if works else [] + context["n_disallowed"] = disallowed return context + def has_all_country_permission(self): + # we'll double check permissions elsewhere + return True + def get_works(self, form): - works = Work.objects.filter(pk__in=form.cleaned_data.get("all_work_pks")) - return works or form.cleaned_data.get("works", []) + if form.cleaned_data.get("all_work_pks"): + works = self.get_base_queryset().filter(pk__in=form.cleaned_data.get("all_work_pks")) + else: + works = form.cleaned_data.get("works", self.get_base_queryset().none()) + + disallowed = 0 + if self.country.place_code == 'all': + # restrict to those places that the user has perms for + n_works = works.count() + works = AllPlace.filter_works_queryset(works, self.request.user) + disallowed = n_works - works.count() + + return works, disallowed + + +class WorkBulkActionBase(PlaceViewBase, FormView): + """Base view for bulk actions on works. Ensures permissions for the "all" country, and passes + place information to the form. Assumes the form class extends WorkBulkActionFormBase. + """ + allow_all_place = True + + def has_all_country_permission(self): + # the forms must filter the works to the countries for which the user has permissions + return True + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['country'] = self.country + kwargs['locality'] = self.locality + kwargs['user'] = self.request.user + return kwargs -class WorkBulkUpdateView(PlaceViewBase, FormView): +class WorkBulkUpdateView(WorkBulkActionBase): form_class = WorkBulkUpdateForm template_name = "indigo_app/place/_bulk_update_form.html" @@ -666,7 +735,7 @@ def get_context_data(self, form, **kwargs): return context -class WorkBulkApproveView(PlaceViewBase, FormView): +class WorkBulkApproveView(WorkBulkActionBase): form_class = WorkBulkApproveForm template_name = "indigo_app/place/_bulk_approve_form.html" @@ -687,7 +756,7 @@ def send_success_messages(self, form): messages.success(self.request, _("Created %(amendment_tasks_count)s Amendment tasks.") % {"amendment_tasks_count": len(form.broker.amendment_tasks)}) -class WorkBulkUnapproveView(PlaceViewBase, FormView): +class WorkBulkUnapproveView(WorkBulkActionBase): form_class = WorkBulkUnapproveForm template_name = "indigo_app/place/_bulk_unapprove_form.html"