diff --git a/django/cantusdb_project/main_app/permissions.py b/django/cantusdb_project/main_app/permissions.py index cc7ae78bc..6b48b7253 100644 --- a/django/cantusdb_project/main_app/permissions.py +++ b/django/cantusdb_project/main_app/permissions.py @@ -6,6 +6,7 @@ Sequence, ) from users.models import User +from django.core.exceptions import PermissionDenied def user_can_edit_chants_in_source(user: User, source: Optional[Source]) -> bool: @@ -167,3 +168,15 @@ def user_can_manage_source_editors(user: User) -> bool: or user.is_staff or user.groups.filter(name="project manager").exists() ) + + +def user_is_project_manager(user: User) -> bool: + """ + A callback function that will be called by the user_passes_test decorator of content_overview. + + Takes in a logged-in user as an argument. + Returns True if they are in a "project manager" group, raises PermissionDenied otherwise. + """ + if user.groups.filter(name="project manager").exists(): + return True + raise PermissionDenied diff --git a/django/cantusdb_project/main_app/urls.py b/django/cantusdb_project/main_app/urls.py index 29bd7c526..864383e11 100644 --- a/django/cantusdb_project/main_app/urls.py +++ b/django/cantusdb_project/main_app/urls.py @@ -7,7 +7,34 @@ PasswordResetConfirmView, PasswordResetCompleteView, ) -from main_app.views import views +from main_app.views.api import ( + ajax_melody_list, + ajax_melody_search, + ajax_search_bar, + json_cid_export, + json_nextchants, + json_sources_export, + notation_json_export, + json_node_export, + json_melody_export, + provenance_json_export, + csv_export, + articles_list_export, + flatpages_list_export, +) +from main_app.views.redirect import ( + redirect_chants, + redirect_genre, + redirect_office, + redirect_source_inventory, + csv_export_redirect_from_old_path, + redirect_search, + redirect_node_url, + redirect_indexer, + redirect_documents, +) +from main_app.views.site_stats import items_count, content_overview +from main_app.views.contact import contact from main_app.views.century import ( CenturyDetailView, ) @@ -62,7 +89,7 @@ UserListView, UserSourceListView, ) -from main_app.views.views import ( +from main_app.views.autocomplete import ( CurrentEditorsAutocomplete, AllUsersAutocomplete, CenturyAutocomplete, @@ -74,11 +101,12 @@ ProofreadByAutocomplete, HoldingAutocomplete, ) +from main_app.views.auth import change_password urlpatterns = [ path( "contact/", - views.contact, + contact, name="contact", ), # login/logout/user @@ -109,7 +137,7 @@ ), path( "change-password/", - views.change_password, + change_password, name="change-password", ), # password reset views @@ -190,7 +218,7 @@ ), path( "chants/", - views.redirect_chants, + redirect_chants, name="redirect-chants", ), # /chants/?source={source id} # feast @@ -217,7 +245,7 @@ ), path( "genre/", - views.redirect_genre, + redirect_genre, name="redirect-genre", ), # indexer @@ -245,7 +273,7 @@ ), path( "office/", - views.redirect_office, + redirect_office, name="redirect-office", ), # provenance @@ -293,7 +321,7 @@ ), path( "index/", - views.redirect_source_inventory, + redirect_source_inventory, name="redirect-source-inventory", ), path( @@ -319,49 +347,49 @@ ), path( "ajax/melody/", - views.ajax_melody_list, + ajax_melody_list, name="ajax-melody", ), path( "ajax/melody-search/", - views.ajax_melody_search, + ajax_melody_search, name="ajax-melody-search", ), # json api path( "json-sources/", - views.json_sources_export, + json_sources_export, name="json-sources-export", ), path( "json-nextchants/", - views.json_nextchants, + json_nextchants, name="json-nextchants", ), path( "json-melody/", - views.json_melody_export, + json_melody_export, name="json-melody-export", ), path( "json-cid/", - views.json_cid_export, + json_cid_export, name="json-cid-export", ), # JSON APIs for returning data on individual objects in the database path( "json-node/", - views.json_node_export, + json_node_export, name="json-node-export", ), path( "notation//json", - views.notation_json_export, + notation_json_export, name="notation-json-export", ), path( "provenance//json", - views.provenance_json_export, + provenance_json_export, name="provenance-json-export", ), # misc search @@ -377,93 +405,93 @@ ), path( "search/", - views.redirect_search, + redirect_search, name="redirect-search", ), path( "ajax/search-bar/", - views.ajax_search_bar, + ajax_search_bar, name="ajax-search-bar", ), # misc path( "content-statistics", - views.items_count, + items_count, name="items-count", ), path( "source//csv/", - views.csv_export, + csv_export, name="csv-export", ), path( "sites/default/files/csv/.csv", - views.csv_export_redirect_from_old_path, + csv_export_redirect_from_old_path, name="csv-export-old-path", ), # content overview (for project managers) path( "content-overview/", - views.content_overview, + content_overview, name="content-overview", ), # /node/ url redirects path( "node/", - views.redirect_node_url, + redirect_node_url, name="redirect-node-url", ), # /indexer/ url redirects path( "indexer/", - views.redirect_indexer, + redirect_indexer, name="redirect-indexer", ), # links to APIs that list URLs of all pages that live in the database path( "articles-list/", - views.articles_list_export, + articles_list_export, name="articles-list-export", ), path( "flatpages-list/", - views.flatpages_list_export, + flatpages_list_export, name="flatpages-list-export", ), # redirects for static files present on OldCantus path( "sites/default/files/documents/1. Quick Guide to Liturgy.pdf", - views.redirect_documents, + redirect_documents, name="redirect-quick-guide-to-liturgy", ), path( "sites/default/files/documents/2. Volpiano Protocols.pdf", - views.redirect_documents, + redirect_documents, name="redirect-volpiano-protocols", ), path( "sites/default/files/documents/3. Volpiano Neumes for Review.docx", - views.redirect_documents, + redirect_documents, name="redirect-volpiano-neumes-for-review", ), path( "sites/default/files/documents/4. Volpiano Neume Protocols.pdf", - views.redirect_documents, + redirect_documents, name="redirect-volpiano-neume-protocols", ), path( "sites/default/files/documents/5. Volpiano Editing Guidelines.pdf", - views.redirect_documents, + redirect_documents, name="redirect-volpiano-editing-guidelines", ), path( "sites/default/files/documents/7. Guide to Graduals.pdf", - views.redirect_documents, + redirect_documents, name="redirect-guide-to-graduals", ), path( "sites/default/files/HOW TO - manuscript descriptions-Nov6-20.pdf", - views.redirect_documents, + redirect_documents, name="redirect-how-to-manuscript-descriptions", ), path( diff --git a/django/cantusdb_project/main_app/views/views.py b/django/cantusdb_project/main_app/views/api.py similarity index 63% rename from django/cantusdb_project/main_app/views/views.py rename to django/cantusdb_project/main_app/views/api.py index e7c4f81de..8c66b2a9c 100644 --- a/django/cantusdb_project/main_app/views/views.py +++ b/django/cantusdb_project/main_app/views/api.py @@ -1,73 +1,31 @@ import csv from typing import List from typing import Optional - -from dal import autocomplete -from django.contrib import messages from django.contrib.auth import get_user_model -from django.contrib.auth import update_session_auth_hash -from django.contrib.auth.decorators import login_required, user_passes_test -from django.contrib.auth.forms import PasswordChangeForm from django.contrib.flatpages.models import FlatPage -from django.core.exceptions import PermissionDenied, BadRequest -from django.core.paginator import Paginator -from django.db.models import Q +from django.core.exceptions import PermissionDenied from django.db.models.query import QuerySet -from django.http import Http404 -from django.http import HttpResponse, HttpResponseNotFound, HttpRequest +from django.db.models import Model from django.http.response import JsonResponse -from django.shortcuts import get_object_or_404 -from django.shortcuts import render, redirect -from django.templatetags.static import static -from django.urls import reverse -from django.utils.http import urlencode - +from django.http import HttpResponse, HttpResponseNotFound +from django.urls.base import reverse from articles.models import Article from main_app.models import ( - Century, Chant, - Differentia, - Feast, - Genre, Notation, - Office, Provenance, Segment, Sequence, Source, - Institution, ) -from main_app.models.base_model import BaseModel from next_chants import next_chants - - -@login_required -def items_count(request): - """ - Function-based view for the ``items count`` page, accessed with ``content-statistics`` - - Update 2022-01-05: - This page has been changed on the original Cantus. It is now in the private domain - - Args: - request (request): The request - - Returns: - HttpResponse: Render the page - """ - # in items count, the number on old cantus shows the total count of a type of object (chant, seq) - # no matter published or not - # but for the count of sources, it only shows the count of published sources - chant_count = Chant.objects.count() - sequence_count = Sequence.objects.count() - source_count = Source.objects.filter(published=True).count() - - context = { - "chant_count": chant_count, - "sequence_count": sequence_count, - "source_count": source_count, - } - return render(request, "items_count.html", context) +from django.http import Http404 +from django.core.exceptions import PermissionDenied +from django.urls import reverse +from django.contrib.auth import get_user_model +from typing import List +from django.contrib.flatpages.models import FlatPage +from django.shortcuts import get_object_or_404 def ajax_melody_list(request, cantus_id) -> JsonResponse: @@ -230,23 +188,6 @@ def csv_export(request, source_id): return response -def csv_export_redirect_from_old_path(request, source_id): - return redirect(reverse("csv-export", args=[source_id])) - - -def contact(request): - """ - Function-based view that renders the contact page ``contact`` - - Args: - request (request): The request - - Returns: - HttpResponse: Render the contact page - """ - return render(request, "contact.html") - - def ajax_melody_search(request): """ Function-based view responding to melody search AJAX calls, accessed with ``melody`` @@ -607,7 +548,7 @@ def build_json_cid_dictionary(chant, request) -> dict: return dictionary -def record_exists(rec_type: BaseModel, pk: int) -> bool: +def record_exists(rec_type: type[Model], pk: int) -> bool: """Determines whether record of specific type (chant, source, sequence, article) exists for a given pk Args: @@ -770,363 +711,3 @@ def flatpages_list_export(request) -> HttpResponse: for flatpage in flatpages ] return HttpResponse(" ".join(flatpage_urls), content_type="text/plain") - - -def redirect_node_url(request, pk: int) -> HttpResponse: - """ - A function that will redirect /node/ URLs from OldCantus to their corresponding page in NewCantus. - This makes NewCantus links backwards compatible for users who may have bookmarked these types of URLs in OldCantus. - In addition, this function (paired with get_user_id() below) account for the different numbering systems in both versions of CantusDB, notably for /indexer/ paths which are now at /user/. - - Takes in a request and the primary key (ID following /node/ in the URL) as arguments. - Returns the matching page in NewCantus if it exists and a 404 otherwise. - """ - - if pk >= NODE_ID_CUTOFF: - raise Http404("Invalid ID for /node/ path.") - - user_id = get_user_id_from_old_indexer_id(pk) - if get_user_id_from_old_indexer_id(pk) is not None: - return redirect("user-detail", user_id) - - for rec_type, view in NODE_TYPES_AND_VIEWS: - if record_exists(rec_type, pk): - # if an object is found, a redirect() call to the appropriate view is returned - return redirect(view, pk) - - # if it reaches the end of the types with finding an existing object, a 404 will be returned - raise Http404("No record found matching the /node/ query.") - - -@login_required -def change_password(request): - if request.method == "POST": - form = PasswordChangeForm(request.user, request.POST) - if form.is_valid(): - user = form.save() - update_session_auth_hash(request, user) - messages.success(request, "Your password was successfully updated!") - else: - form = PasswordChangeForm(request.user) - return render(request, "registration/change_password.html", {"form": form}) - - -def project_manager_check(user): - """ - A callback function that will be called by the user_passes_test decorator of content_overview. - - Takes in a logged-in user as an argument. - Returns True if they are in a "project manager" group, raises PermissionDenied otherwise. - """ - if user.groups.filter(name="project manager").exists(): - return True - raise PermissionDenied - - -# first give the user a chance to login -@login_required -# if they're logged in but they're not a project manager, raise 403 -@user_passes_test(project_manager_check) -def content_overview(request): - models = [ - Source, - Chant, - Feast, - Sequence, - Office, - Provenance, - Genre, - Notation, - Century, - ] - - model_names = [model._meta.verbose_name_plural for model in models] - selected_model_name = request.GET.get("model", None) - selected_model = None - if selected_model_name in model_names: - selected_model = models[model_names.index(selected_model_name)] - - objects = [] - if selected_model: - objects = selected_model.objects.all().order_by("-date_updated") - - paginator = Paginator(objects, 100) - page_number = request.GET.get("page") - page_obj = paginator.get_page(page_number) - - context = { - "models": model_names, - "selected_model_name": selected_model_name, - "page_obj": page_obj, - } - - return render(request, "content_overview.html", context) - - -def redirect_indexer(request, pk: int) -> HttpResponse: - """ - A function that will redirect /indexer/ URLs from OldCantus to their corresponding /user/ page in NewCantus. - This makes NewCantus links backwards compatible for users who may have bookmarked these types of URLs in OldCantus. - - Takes in a request and the Indexer ID as arguments. - Returns the matching User page in NewCantus if it exists and a 404 otherwise. - """ - user_id = get_user_id_from_old_indexer_id(pk) - if get_user_id_from_old_indexer_id(pk) is not None: - return redirect("user-detail", user_id) - - raise Http404("No indexer found matching the query.") - - -def redirect_office(request) -> HttpResponse: - """ - Redirects from office/ (à la OldCantus) to offices/ (à la NewCantus) - - Args: - request - - Returns: - HttpResponse - """ - return redirect("office-list") - - -def redirect_genre(request) -> HttpResponse: - """ - Redirects from genre/ (à la OldCantus) to genres/ (à la NewCantus) - - Args: - request - - Returns: - HttpResponse - """ - return redirect("genre-list") - - -def redirect_search(request: HttpRequest) -> HttpResponse: - """ - Redirects from search/ (à la OldCantus) to chant-search/ (à la NewCantus) - - Args: - request - - Returns: - HttpResponse - """ - return redirect("chant-search", permanent=True) - - -def redirect_documents(request) -> HttpResponse: - """Handle requests to old paths for various - documents on OldCantus, returning an HTTP Response - redirecting the user to the updated path - - Args: - request: the request to the old path - - Returns: - HttpResponse: response redirecting to the new path - """ - mapping = { - "/sites/default/files/documents/1. Quick Guide to Liturgy.pdf": static( - "documents/1. Quick Guide to Liturgy.pdf" - ), - "/sites/default/files/documents/2. Volpiano Protocols.pdf": static( - "documents/2. Volpiano Protocols.pdf" - ), - "/sites/default/files/documents/3. Volpiano Neumes for Review.docx": static( - "documents/3. Volpiano Neumes for Review.docx" - ), - "/sites/default/files/documents/4. Volpiano Neume Protocols.pdf": static( - "documents/4. Volpiano Neume Protocols.pdf" - ), - "/sites/default/files/documents/5. Volpiano Editing Guidelines.pdf": static( - "documents/5. Volpiano Editing Guidelines.pdf" - ), - "/sites/default/files/documents/7. Guide to Graduals.pdf": static( - "documents/7. Guide to Graduals.pdf" - ), - "/sites/default/files/HOW TO - manuscript descriptions-Nov6-20.pdf": static( - "documents/HOW TO - manuscript descriptions-Nov6-20.pdf" - ), - } - old_path = request.path - try: - new_path = mapping[old_path] - except KeyError: - raise Http404 - return redirect(new_path) - - -def redirect_chants(request) -> HttpResponse: - # in OldCantus, the Browse Chants page was accessed via - # `/chants/?source=` - # This view redirects to `/source//chants` to - # maintain backwards compatibility - source_id: Optional[str] = request.GET.get("source") - if source_id is None: - # source parameter must be provided - raise BadRequest("Source parameter must be provided") - - base_url: str = reverse("browse-chants", args=[source_id]) - - # optional search params - feast_id: Optional[str] = request.GET.get("feast") - genre_id: Optional[str] = request.GET.get("genre") - folio: Optional[str] = request.GET.get("folio") - search_text: Optional[str] = request.GET.get("search_text") - - d: dict = { - "feast": feast_id, - "genre": genre_id, - "folio": folio, - "search_text": search_text, - } - params: dict = {k: v for k, v in d.items() if v is not None} - - query_string: str = urlencode(params) - url: str = f"{base_url}?{query_string}" if query_string else base_url - - return redirect(url, permanent=True) - - -def redirect_source_inventory(request) -> HttpResponse: - source_id: str = request.GET.get("source") - if source_id is None: - # source parameter must be provided - raise BadRequest("Source parameter must be provided") - url: str = reverse("source-inventory", args=[source_id]) - return redirect(url, permanent=True) - - -class CurrentEditorsAutocomplete(autocomplete.Select2QuerySetView): - def get_queryset(self): - if not self.request.user.is_authenticated: - return get_user_model().objects.none() - qs = ( - get_user_model() - .objects.filter( - Q(groups__name="project manager") - | Q(groups__name="editor") - | Q(groups__name="contributor") - ) - .order_by("full_name") - ) - if self.q: - qs = qs.filter( - Q(full_name__istartswith=self.q) | Q(email__istartswith=self.q) - ) - return qs - - -class AllUsersAutocomplete(autocomplete.Select2QuerySetView): - def get_queryset(self): - if not self.request.user.is_authenticated: - return get_user_model().objects.none() - qs = get_user_model().objects.all().order_by("full_name") - if self.q: - qs = qs.filter( - Q(full_name__istartswith=self.q) | Q(email__istartswith=self.q) - ) - return qs - - -class CenturyAutocomplete(autocomplete.Select2QuerySetView): - def get_queryset(self): - if not self.request.user.is_authenticated: - return Century.objects.none() - qs = Century.objects.all().order_by("name") - if self.q: - qs = qs.filter(name__istartswith=self.q) - return qs - - -class FeastAutocomplete(autocomplete.Select2QuerySetView): - def get_queryset(self): - if not self.request.user.is_authenticated: - return Feast.objects.none() - qs = Feast.objects.all().order_by("name") - if self.q: - qs = qs.filter(name__icontains=self.q) - return qs - - -class OfficeAutocomplete(autocomplete.Select2QuerySetView): - def get_result_label(self, office): - return f"{office.name} - {office.description}" - - def get_queryset(self): - if not self.request.user.is_authenticated: - return Office.objects.none() - qs = Office.objects.all().order_by("name") - if self.q: - qs = qs.filter( - Q(name__istartswith=self.q) | Q(description__icontains=self.q) - ) - return qs - - -class GenreAutocomplete(autocomplete.Select2QuerySetView): - def get_result_label(self, genre): - return f"{genre.name} - {genre.description}" - - def get_queryset(self): - if not self.request.user.is_authenticated: - return Genre.objects.none() - qs = Genre.objects.all().order_by("name") - if self.q: - qs = qs.filter( - Q(name__istartswith=self.q) | Q(description__icontains=self.q) - ) - return qs - - -class DifferentiaAutocomplete(autocomplete.Select2QuerySetView): - def get_queryset(self): - if not self.request.user.is_authenticated: - return Differentia.objects.none() - qs = Differentia.objects.all().order_by("differentia_id") - if self.q: - qs = qs.filter(differentia_id__istartswith=self.q) - return qs - - -class HoldingAutocomplete(autocomplete.Select2QuerySetView): - def get_queryset(self): - if not self.request.user.is_authenticated: - return Institution.objects.none() - - qs = Institution.objects.all().order_by("name") - if self.q: - qs = qs.filter(Q(name__istartswith=self.q) | Q(siglum__istartswith=self.q)) - return qs - - -class ProvenanceAutocomplete(autocomplete.Select2QuerySetView): - def get_queryset(self): - if not self.request.user.is_authenticated: - return Provenance.objects.none() - qs = Provenance.objects.all().order_by("name") - if self.q: - qs = qs.filter(name__icontains=self.q) - return qs - - -class ProofreadByAutocomplete(autocomplete.Select2QuerySetView): - def get_queryset(self): - if not self.request.user.is_authenticated: - return get_user_model().objects.none() - qs = ( - get_user_model() - .objects.filter( - Q(groups__name="project manager") | Q(groups__name="editor") - ) - .distinct() - .order_by("full_name") - ) - if self.q: - qs = qs.filter( - Q(full_name__istartswith=self.q) | Q(email__istartswith=self.q) - ) - return qs diff --git a/django/cantusdb_project/main_app/views/auth.py b/django/cantusdb_project/main_app/views/auth.py new file mode 100644 index 000000000..98cfb2451 --- /dev/null +++ b/django/cantusdb_project/main_app/views/auth.py @@ -0,0 +1,18 @@ +from django.contrib.auth import update_session_auth_hash +from django.contrib.auth.forms import PasswordChangeForm +from django.contrib import messages +from django.shortcuts import render +from django.contrib.auth.decorators import login_required + + +@login_required +def change_password(request): + if request.method == "POST": + form = PasswordChangeForm(request.user, request.POST) + if form.is_valid(): + user = form.save() + update_session_auth_hash(request, user) + messages.success(request, "Your password was successfully updated!") + else: + form = PasswordChangeForm(request.user) + return render(request, "registration/change_password.html", {"form": form}) diff --git a/django/cantusdb_project/main_app/views/autocomplete.py b/django/cantusdb_project/main_app/views/autocomplete.py new file mode 100644 index 000000000..47c3b8ac2 --- /dev/null +++ b/django/cantusdb_project/main_app/views/autocomplete.py @@ -0,0 +1,146 @@ +from dal import autocomplete +from django.db.models import Q +from django.contrib.auth import get_user_model + +from main_app.models import ( + Century, + Differentia, + Feast, + Genre, + Office, + Institution, + Provenance, +) + + +class CurrentEditorsAutocomplete(autocomplete.Select2QuerySetView): + def get_queryset(self): + if not self.request.user.is_authenticated: + return get_user_model().objects.none() + # pylint: disable=unsupported-binary-operation + qs = ( + get_user_model() + .objects.filter( + Q(groups__name="project manager") + | Q(groups__name="editor") + | Q(groups__name="contributor") + ) + .order_by("full_name") + ) + if self.q: + qs = qs.filter( + Q(full_name__istartswith=self.q) | Q(email__istartswith=self.q) + ) + return qs + + +class AllUsersAutocomplete(autocomplete.Select2QuerySetView): + def get_queryset(self): + if not self.request.user.is_authenticated: + return get_user_model().objects.none() + qs = get_user_model().objects.all().order_by("full_name") + if self.q: + qs = qs.filter( + Q(full_name__istartswith=self.q) | Q(email__istartswith=self.q) + ) + return qs + + +class CenturyAutocomplete(autocomplete.Select2QuerySetView): + def get_queryset(self): + if not self.request.user.is_authenticated: + return Century.objects.none() + qs = Century.objects.all().order_by("name") + if self.q: + qs = qs.filter(name__istartswith=self.q) + return qs + + +class FeastAutocomplete(autocomplete.Select2QuerySetView): + def get_queryset(self): + if not self.request.user.is_authenticated: + return Feast.objects.none() + qs = Feast.objects.all().order_by("name") + if self.q: + qs = qs.filter(name__icontains=self.q) + return qs + + +class OfficeAutocomplete(autocomplete.Select2QuerySetView): + def get_result_label(self, result): + return f"{result.name} - {result.description}" + + def get_queryset(self): + if not self.request.user.is_authenticated: + return Office.objects.none() + qs = Office.objects.all().order_by("name") + if self.q: + qs = qs.filter( + Q(name__istartswith=self.q) | Q(description__icontains=self.q) + ) + return qs + + +class GenreAutocomplete(autocomplete.Select2QuerySetView): + def get_result_label(self, result): + return f"{result.name} - {result.description}" + + def get_queryset(self): + if not self.request.user.is_authenticated: + return Genre.objects.none() + qs = Genre.objects.all().order_by("name") + if self.q: + qs = qs.filter( + Q(name__istartswith=self.q) | Q(description__icontains=self.q) + ) + return qs + + +class DifferentiaAutocomplete(autocomplete.Select2QuerySetView): + def get_queryset(self): + if not self.request.user.is_authenticated: + return Differentia.objects.none() + qs = Differentia.objects.all().order_by("differentia_id") + if self.q: + qs = qs.filter(differentia_id__istartswith=self.q) + return qs + + +class ProvenanceAutocomplete(autocomplete.Select2QuerySetView): + def get_queryset(self): + if not self.request.user.is_authenticated: + return Provenance.objects.none() + qs = Provenance.objects.all().order_by("name") + if self.q: + qs = qs.filter(name__icontains=self.q) + return qs + + +class ProofreadByAutocomplete(autocomplete.Select2QuerySetView): + def get_queryset(self): + if not self.request.user.is_authenticated: + return get_user_model().objects.none() + qs = ( + get_user_model() + .objects.filter( + Q(groups__name="project manager") | Q(groups__name="editor") + ) + .distinct() + .order_by("full_name") + ) + if self.q: + qs = qs.filter( + Q(full_name__istartswith=self.q) | Q(email__istartswith=self.q) + ) + return qs + + +class HoldingAutocomplete(autocomplete.Select2QuerySetView): + def get_queryset(self): + if not self.request.user.is_authenticated: + return Institution.objects.none() + + qs = Institution.objects.all().order_by("name") + if self.q: + qs = qs.filter(Q(name__istartswith=self.q) | Q(siglum__istartswith=self.q)) + return qs diff --git a/django/cantusdb_project/main_app/views/contact.py b/django/cantusdb_project/main_app/views/contact.py new file mode 100644 index 000000000..228f47f67 --- /dev/null +++ b/django/cantusdb_project/main_app/views/contact.py @@ -0,0 +1,18 @@ +""" +View to render the contact flatpage +""" + +from django.shortcuts import render + + +def contact(request): + """ + Function-based view that renders the contact page ``contact`` + + Args: + request (request): The request + + Returns: + HttpResponse: Render the contact page + """ + return render(request, "contact.html") diff --git a/django/cantusdb_project/main_app/views/redirect.py b/django/cantusdb_project/main_app/views/redirect.py new file mode 100644 index 000000000..ff4593274 --- /dev/null +++ b/django/cantusdb_project/main_app/views/redirect.py @@ -0,0 +1,187 @@ +from typing import Optional + +from django.shortcuts import redirect +from django.urls.base import reverse +from django.http import HttpResponse, Http404, HttpRequest +from django.templatetags.static import static +from django.core.exceptions import BadRequest +from django.utils.http import urlencode + +from main_app.views.api import ( + NODE_TYPES_AND_VIEWS, + record_exists, + NODE_ID_CUTOFF, + get_user_id_from_old_indexer_id, +) + + +def csv_export_redirect_from_old_path(request, source_id): + return redirect(reverse("csv-export", args=[source_id])) + + +def redirect_node_url(request, pk: int) -> HttpResponse: + """ + A function that will redirect /node/ URLs from OldCantus to their + corresponding page in NewCantus. This makes NewCantus links backwards + compatible for users who may have bookmarked these types of URLs in OldCantus. + In addition, this function (paired with get_user_id() below) account for the + different numbering systems in both versions of CantusDB, notably for /indexer/ + paths which are now at /user/. + + Takes in a request and the primary key (ID following /node/ in the URL) as arguments. + Returns the matching page in NewCantus if it exists and a 404 otherwise. + """ + + if pk >= NODE_ID_CUTOFF: + raise Http404("Invalid ID for /node/ path.") + + user_id = get_user_id_from_old_indexer_id(pk) + if get_user_id_from_old_indexer_id(pk) is not None: + return redirect("user-detail", user_id) + + for rec_type, view in NODE_TYPES_AND_VIEWS: + if record_exists(rec_type, pk): + # if an object is found, a redirect() call to the appropriate view is returned + return redirect(view, pk) + + # if it reaches the end of the types with finding an existing object, a 404 will be returned + raise Http404("No record found matching the /node/ query.") + + +def redirect_indexer(request, pk: int) -> HttpResponse: + """ + A function that will redirect /indexer/ URLs from OldCantus to + their corresponding /user/ page in NewCantus. + This makes NewCantus links backwards compatible for users who + may have bookmarked these types of URLs in OldCantus. + + Takes in a request and the Indexer ID as arguments. + Returns the matching User page in NewCantus if it exists and a 404 otherwise. + """ + user_id = get_user_id_from_old_indexer_id(pk) + if get_user_id_from_old_indexer_id(pk) is not None: + return redirect("user-detail", user_id) + + raise Http404("No indexer found matching the query.") + + +def redirect_office(request) -> HttpResponse: + """ + Redirects from office/ (à la OldCantus) to offices/ (à la NewCantus) + + Args: + request + + Returns: + HttpResponse + """ + return redirect("office-list") + + +def redirect_genre(request) -> HttpResponse: + """ + Redirects from genre/ (à la OldCantus) to genres/ (à la NewCantus) + + Args: + request + + Returns: + HttpResponse + """ + return redirect("genre-list") + + +def redirect_search(request: HttpRequest) -> HttpResponse: + """ + Redirects from search/ (à la OldCantus) to chant-search/ (à la NewCantus) + + Args: + request + + Returns: + HttpResponse + """ + return redirect("chant-search", permanent=True) + + +def redirect_documents(request) -> HttpResponse: + """Handle requests to old paths for various + documents on OldCantus, returning an HTTP Response + redirecting the user to the updated path + + Args: + request: the request to the old path + + Returns: + HttpResponse: response redirecting to the new path + """ + mapping = { + "/sites/default/files/documents/1. Quick Guide to Liturgy.pdf": static( + "documents/1. Quick Guide to Liturgy.pdf" + ), + "/sites/default/files/documents/2. Volpiano Protocols.pdf": static( + "documents/2. Volpiano Protocols.pdf" + ), + "/sites/default/files/documents/3. Volpiano Neumes for Review.docx": static( + "documents/3. Volpiano Neumes for Review.docx" + ), + "/sites/default/files/documents/4. Volpiano Neume Protocols.pdf": static( + "documents/4. Volpiano Neume Protocols.pdf" + ), + "/sites/default/files/documents/5. Volpiano Editing Guidelines.pdf": static( + "documents/5. Volpiano Editing Guidelines.pdf" + ), + "/sites/default/files/documents/7. Guide to Graduals.pdf": static( + "documents/7. Guide to Graduals.pdf" + ), + "/sites/default/files/HOW TO - manuscript descriptions-Nov6-20.pdf": static( + "documents/HOW TO - manuscript descriptions-Nov6-20.pdf" + ), + } + old_path = request.path + try: + new_path = mapping[old_path] + except KeyError as exc: + raise Http404 from exc + return redirect(new_path) + + +def redirect_chants(request) -> HttpResponse: + # in OldCantus, the Browse Chants page was accessed via + # `/chants/?source=` + # This view redirects to `/source//chants` to + # maintain backwards compatibility + source_id: Optional[str] = request.GET.get("source") + if source_id is None: + # source parameter must be provided + raise BadRequest("Source parameter must be provided") + + base_url: str = reverse("browse-chants", args=[source_id]) + + # optional search params + feast_id: Optional[str] = request.GET.get("feast") + genre_id: Optional[str] = request.GET.get("genre") + folio: Optional[str] = request.GET.get("folio") + search_text: Optional[str] = request.GET.get("search_text") + + d: dict = { + "feast": feast_id, + "genre": genre_id, + "folio": folio, + "search_text": search_text, + } + params: dict = {k: v for k, v in d.items() if v is not None} + + query_string: str = urlencode(params) + url: str = f"{base_url}?{query_string}" if query_string else base_url + + return redirect(url, permanent=True) + + +def redirect_source_inventory(request) -> HttpResponse: + source_id: str = request.GET.get("source") + if source_id is None: + # source parameter must be provided + raise BadRequest("Source parameter must be provided") + url: str = reverse("source-inventory", args=[source_id]) + return redirect(url, permanent=True) diff --git a/django/cantusdb_project/main_app/views/site_stats.py b/django/cantusdb_project/main_app/views/site_stats.py new file mode 100644 index 000000000..fd547b545 --- /dev/null +++ b/django/cantusdb_project/main_app/views/site_stats.py @@ -0,0 +1,90 @@ +""" +A module containing views for pages that provide general site statistics. +""" + +from main_app.models import ( + Chant, + Sequence, + Source, + Feast, + Office, + Provenance, + Genre, + Notation, + Century, +) +from main_app.permissions import user_is_project_manager + +from django.contrib.auth.decorators import login_required, user_passes_test +from django.core.paginator import Paginator +from django.shortcuts import render + + +@login_required +def items_count(request): + """ + Function-based view for the ``items count`` page, accessed with ``content-statistics`` + + Update 2022-01-05: + This page has been changed on the original Cantus. It is now in the private domain + + Args: + request (request): The request + + Returns: + HttpResponse: Render the page + """ + # in items count, the number on old cantus shows the total count of a type of object (chant, seq) + # no matter published or not + # but for the count of sources, it only shows the count of published sources + chant_count = Chant.objects.count() + sequence_count = Sequence.objects.count() + source_count = Source.objects.filter(published=True).count() + + context = { + "chant_count": chant_count, + "sequence_count": sequence_count, + "source_count": source_count, + } + return render(request, "items_count.html", context) + + +# first give the user a chance to login +@login_required +# if they're logged in but they're not a project manager, raise 403 +@user_passes_test(user_is_project_manager) +def content_overview(request): + objects = [] + models = [ + Source, + Chant, + Feast, + Sequence, + Office, + Provenance, + Genre, + Notation, + Century, + ] + + model_names = [model._meta.verbose_name_plural for model in models] + selected_model_name = request.GET.get("model", None) + selected_model = None + if selected_model_name in model_names: + selected_model = models[model_names.index(selected_model_name)] + + objects = [] + if selected_model: + objects = selected_model.objects.all().order_by("-date_updated") + + paginator = Paginator(objects, 100) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + context = { + "models": model_names, + "selected_model_name": selected_model_name, + "page_obj": page_obj, + } + + return render(request, "content_overview.html", context)