diff --git a/.github/workflows/broken-link-checker.yaml b/.github/workflows/broken-link-checker.yaml index fcd6faf0b..d8e356a99 100644 --- a/.github/workflows/broken-link-checker.yaml +++ b/.github/workflows/broken-link-checker.yaml @@ -31,7 +31,7 @@ jobs: id: lychee uses: lycheeverse/lychee-action@v1.8.0 with: - args: --exclude http:\/\/cantus\.sk.* ${{ matrix.links }} + args: --exclude http:\/\/cantus\.sk.* --exclude https:\/\/us06web\.zoom\.us* ${{ matrix.links }} format: json output: /tmp/link-checker-output.txt - name: Curating Link Checker Output diff --git a/.github/workflows/django_tests.yml b/.github/workflows/django_tests.yml index b175071e2..465a4ca9a 100644 --- a/.github/workflows/django_tests.yml +++ b/.github/workflows/django_tests.yml @@ -24,6 +24,6 @@ jobs: envkey_AWS_EMAIL_HOST_PASSWORD: test_password directory: config/envs file_name: dev_env - - run: docker compose -f docker-compose-development.yml build - - run: docker compose -f docker-compose-development.yml up -d - - run: docker compose -f docker-compose-development.yml exec -T django python manage.py test main_app.tests \ No newline at end of file + - run: docker compose -f docker-compose-test-runner.yml build + - run: docker compose -f docker-compose-test-runner.yml up -d + - run: docker compose -f docker-compose-test-runner.yml exec -T django python manage.py test main_app.tests \ No newline at end of file diff --git a/config/nginx/conf.d/cantusdb.conf.development b/config/nginx/conf.d/cantusdb.conf.development index 25ec86df2..dbd1d181b 100644 --- a/config/nginx/conf.d/cantusdb.conf.development +++ b/config/nginx/conf.d/cantusdb.conf.development @@ -10,7 +10,7 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $host; proxy_redirect off; - proxy_intercept_errors on; + proxy_intercept_errors off; } location /static { diff --git a/cron/postgres/db_backup.sh b/cron/postgres/db_backup.sh index 4c04727ee..eb6a294b7 100644 --- a/cron/postgres/db_backup.sh +++ b/cron/postgres/db_backup.sh @@ -19,7 +19,7 @@ BACKUP_FILENAME=$(date "+%Y-%m-%dT%H:%M:%S").sql.gz # This is th # Create the backup and copy it to the daily backup directory -mkdir -p $BACKUP_DIR/daily $BACKUP_DIR/weekly $BACKUP_DIR/monthly $BACKUP_DIR/yearly +mkdir -p $BACKUP_DIR/daily $BACKUP_DIR/weekly $BACKUP_DIR/monthly $BACKUP_DIR/yearly $BACKUP_DIR/rism /usr/bin/docker exec cantusdb-postgres-1 /usr/local/bin/postgres_backup.sh $BACKUP_FILENAME /usr/bin/docker cp cantusdb-postgres-1:/var/lib/postgresql/backups/$BACKUP_FILENAME $BACKUP_DIR/daily /usr/bin/docker exec cantusdb-postgres-1 rm /var/lib/postgresql/backups/$BACKUP_FILENAME @@ -34,10 +34,14 @@ MONTH_OF_YEAR=$(date "+%m") # Retain weekly backups on Mondays # Manage retention of weekly backups +# Copy the partial export for RISM purposes (created weekly on Mondays) if [ $DAY_OF_WEEK -eq 1 ]; then cp $BACKUP_DIR/daily/$BACKUP_FILENAME $BACKUP_DIR/weekly FILES_TO_REMOVE=$(ls -td $BACKUP_DIR/weekly/* | tail -n +9) [[ ! -z "$FILES_TO_REMOVE" ]] && rm $FILES_TO_REMOVE + # Copy the partial DB backup for RISM purposes to $BACKUP_DIR/rism + /usr/bin/docker cp cantusdb-postgres-1:/var/lib/postgresql/backups/cantusdb_rism_export.sql.gz $BACKUP_DIR/rism + /usr/bin/docker exec cantusdb-postgres-1 rm /var/lib/postgresql/backups/cantusdb_rism_export.sql.gz fi # Retain a monthly backup on the first day of the month diff --git a/django/cantusdb_project/cantusdb/settings.py b/django/cantusdb_project/cantusdb/settings.py index db85f497e..2818a031b 100644 --- a/django/cantusdb_project/cantusdb/settings.py +++ b/django/cantusdb_project/cantusdb/settings.py @@ -208,5 +208,6 @@ if DEBUG: INSTALLED_APPS.append("debug_toolbar") + INSTALLED_APPS.append("django_extensions") # debug toolbar must be inserted as early in the middleware as possible MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware") diff --git a/django/cantusdb_project/cantusindex.py b/django/cantusdb_project/cantusindex.py index 7980b2cc6..6befffd07 100644 --- a/django/cantusdb_project/cantusindex.py +++ b/django/cantusdb_project/cantusindex.py @@ -3,131 +3,117 @@ Cantus Index's (CI's) various APIs. """ -import requests -from typing import Optional, Union, Callable -from main_app.models import Genre import json +from typing import Optional, Union, Callable, TypedDict, Any + +import requests from requests.exceptions import SSLError, Timeout, HTTPError +from main_app.models import Genre + CANTUS_INDEX_DOMAIN: str = "https://cantusindex.uwaterloo.ca" OLD_CANTUS_INDEX_DOMAIN: str = "https://cantusindex.org" DEFAULT_TIMEOUT: float = 2 # seconds -NUMBER_OF_SUGGESTED_CHANTS: int = 3 # this number can't be too large, -# since for each suggested chant, we make a request to Cantus Index. -# We haven't yet parallelized this process, so setting this number -# too high will cause the Chant Create page to take a very long time -# to load. If/when we parallelize this process, we want to limit -# the size of the burst of requests sent to CantusIndex. +NUMBER_OF_SUGGESTED_CHANTS: int = 5 # default number of suggested chants to return +# with the get_suggested_chants function + + +class SuggestedChant(TypedDict): + """ + Dictionary containing information required for + the suggested chants feature on the Chant Create form. + """ + + cantus_id: str + occurrences: int + fulltext: Optional[str] + genre_name: Optional[str] + genre_id: Optional[int] def get_suggested_chants( cantus_id: str, number_of_suggestions: int = NUMBER_OF_SUGGESTED_CHANTS -) -> Optional[list[dict]]: +) -> Optional[list[SuggestedChant]]: + """ + Given a Cantus ID, query Cantus Index's /nextchants API for a list of + Cantus IDs that follow the given Cantus ID in existing manuscripts. + Sort the list by the number of occurrences of each Cantus ID, and return + a list of dictionaries containing information about the suggested Cantus IDs + with the highest number of occurrences. + + Args: + cantus_id (str): a Cantus ID + number_of_suggestions (int): the number of suggested Cantus IDs to return + + Returns: + Optional[list[dict]]: A list of dictionaries, each containing information + about a suggested Cantus ID: + - "cantus_id": the suggested Cantus ID + - "occurrences": the number of times the suggested Cantus ID follows + the given Cantus ID in existing manuscripts + - "fulltext": the full text of the suggested Cantus ID + - "genre_name": the genre of the suggested Cantus ID + - "genre_id": the ID of the genre of the suggested Cantus ID + If no suggestions are available, returns None. + """ endpoint_path: str = f"/json-nextchants/{cantus_id}" - all_suggestions: Union[list, dict, None] = get_json_from_ci_api(endpoint_path) + all_suggestions = get_json_from_ci_api(endpoint_path) - if not isinstance(all_suggestions, list): - # get_json_from_ci_api timed out - # or CI returned a response with no suggestions. + if all_suggestions is None: return None - # when Cantus ID doesn't exist within CI, CI's api returns a 200 response with `['Cantus ID is not valid']` + # when Cantus ID doesn't exist within CI, CI's api returns a + # 200 response with `['Cantus ID is not valid']` first_suggestion = all_suggestions[0] if not isinstance(first_suggestion, dict): return None - sort_by_occurrences: Callable[[dict], int] = lambda suggestion: int( + sort_by_occurrences: Callable[[dict[Any, Any]], int] = lambda suggestion: int( suggestion["count"] ) - sorted_suggestions: list = sorted( + sorted_suggestions: list[dict[Any, Any]] = sorted( all_suggestions, key=sort_by_occurrences, reverse=True ) - trimmed_suggestions: list = sorted_suggestions[:number_of_suggestions] + trimmed_suggestions = sorted_suggestions[:number_of_suggestions] - suggested_chants: list[Optional[dict]] = [] + suggested_chants: list[SuggestedChant] = [] for suggestion in trimmed_suggestions: - cantus_id: str = suggestion["cid"] - occurrences: int = int(suggestion["count"]) - suggested_chants.append(get_suggested_chant(cantus_id, occurrences)) - - # filter out Cantus IDs where get_suggested_chant timed out - filtered_suggestions: list[dict] = [ - sugg for sugg in suggested_chants if sugg is not None - ] - - return filtered_suggestions - - -def get_suggested_chant( - cantus_id: str, occurrences: int, timeout: float = DEFAULT_TIMEOUT -) -> Optional[dict]: - """Given a Cantus ID and a number of occurrences, query one of Cantus Index's - APIs for information on that Cantus ID and return a dictionary - containing a full text, an incipit, the ID of that Cantus ID's genre, and - the number of occurrences for that Cantus ID - - (Number of occurrences: this function is used on the Chant Create page, - to suggest Cantus IDs of chants that might follow a chant with the Cantus ID - of the most recently created chant within the current source. Number of occurrences - is provided by Cantus Index's /nextchants API, based on which chants follow which - other chants in existing manuscripts) - - Args: - cantus_id (str): a Cantus ID - occurrences (int): the number of times chants with this Cantus ID follow chants - with the Cantus ID of the most recently created chant. - - Returns: - Optional[dict]: A dictionary with the following keys: - - "cantus_id" - - "occurrences" - - "fulltext" - - "incipit" - - "genre_id" - ...but if get_json_from_ci_api timed out, returns None instead - """ - endpoint_path: str = f"/json-cid/{cantus_id}" - json: Union[dict, list, None] = get_json_from_ci_api(endpoint_path, timeout=timeout) - - if not isinstance(json, dict): - # mostly, in case of a timeout within get_json_from_ci_api - return None + sugg_cantus_id = suggestion["cid"] + occurences = int(suggestion["count"]) + suggestion_info = suggestion.get("info") + if suggestion_info: + fulltext = suggestion_info.get("field_full_text") + genre_name = suggestion_info.get("field_genre") + else: + fulltext = None + genre_name = None + try: + genre_id = Genre.objects.get(name=genre_name).id + except Genre.DoesNotExist: + genre_id = None + suggested_chants.append( + { + "cantus_id": sugg_cantus_id, + "occurrences": occurences, + "fulltext": fulltext, + "genre_name": genre_name, + "genre_id": genre_id, + } + ) - try: - fulltext: str = json["info"]["field_full_text"] - incipit: str = " ".join(fulltext.split(" ")[:5]) - genre_name: str = json["info"]["field_genre"] - except TypeError: - return None - genre_id: Optional[int] = None - try: - genre_id = Genre.objects.get(name=genre_name).id - except Genre.DoesNotExist: - pass - - clean_cantus_id = cantus_id.replace(".", "d").replace(":", "c") - # "d"ot "c"olon - return { - "cantus_id": cantus_id, - "occurrences": occurrences, - "fulltext": fulltext, - "incipit": incipit, - "genre_name": genre_name, - "genre_id": genre_id, - "clean_cantus_id": clean_cantus_id, - } + return suggested_chants def get_suggested_fulltext(cantus_id: str) -> Optional[str]: endpoint_path: str = f"/json-cid/{cantus_id}" - json: Union[dict, list, None] = get_json_from_ci_api(endpoint_path) + json_response: Union[dict, list, None] = get_json_from_ci_api(endpoint_path) - if not isinstance(json, dict): + if not isinstance(json_response, dict): # mostly, in case of a timeout within get_json_from_ci_api return None try: - suggested_fulltext = json["info"]["field_full_text"] + suggested_fulltext = json_response["info"]["field_full_text"] except KeyError: return None @@ -205,7 +191,7 @@ def get_ci_text_search(search_term: str) -> Optional[list[Optional[dict]]]: def get_json_from_ci_api( path: str, timeout: float = DEFAULT_TIMEOUT -) -> Union[dict, list, None]: +) -> Union[dict[Any, Any], list[Any], None]: """Given a path, send a request to Cantus Index at that path, decode the response to remove its Byte Order Marker, parse it, and return it as a dictionary or list. @@ -219,7 +205,7 @@ def get_json_from_ci_api( Union[dict, list, None]: If the JSON returned from Cantus Index is a JSON object, returns a dict. If the JSON returned is a JSON array, returns a list. - In case the request times out, returns None. + If the request times out, or other types are returned, returns None. """ if not path.startswith("/"): @@ -241,4 +227,9 @@ def get_json_from_ci_api( # there are no suggested chants return None - return response.json() + parsed_response = response.json() + + if not isinstance(parsed_response, (dict, list)): + return None + + return parsed_response diff --git a/django/cantusdb_project/main_app/admin/__init__.py b/django/cantusdb_project/main_app/admin/__init__.py index 6c0fac619..49a75893d 100644 --- a/django/cantusdb_project/main_app/admin/__init__.py +++ b/django/cantusdb_project/main_app/admin/__init__.py @@ -4,7 +4,7 @@ from main_app.admin.feast import FeastAdmin from main_app.admin.genre import GenreAdmin from main_app.admin.notation import NotationAdmin -from main_app.admin.office import OfficeAdmin +from main_app.admin.service import ServiceAdmin from main_app.admin.provenance import ProvenanceAdmin from main_app.admin.segment import SegmentAdmin from main_app.admin.sequence import SequenceAdmin diff --git a/django/cantusdb_project/main_app/admin/chant.py b/django/cantusdb_project/main_app/admin/chant.py index ce2c8b775..b12f31621 100644 --- a/django/cantusdb_project/main_app/admin/chant.py +++ b/django/cantusdb_project/main_app/admin/chant.py @@ -1,17 +1,34 @@ from django.contrib import admin from main_app.admin.base_admin import EXCLUDE, READ_ONLY, BaseModelAdmin +from main_app.admin.filters import InputFilter from main_app.forms import AdminChantForm from main_app.models import Chant +class SourceKeyFilter(InputFilter): + parameter_name = "source_id" + title = "Source ID" + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(source_id=self.value()) + + @admin.register(Chant) class ChantAdmin(BaseModelAdmin): + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .select_related("source__holding_institution", "genre", "service") + ) + @admin.display(description="Source Siglum") def get_source_siglum(self, obj): if obj.source: - return obj.source.siglum + return obj.source.short_heading list_display = ( "incipit", @@ -23,13 +40,15 @@ def get_source_siglum(self, obj): "incipit", "cantus_id", "id", + "source__holding_institution__siglum", ) readonly_fields = READ_ONLY + ("incipit",) list_filter = ( + SourceKeyFilter, "genre", - "office", + "service", ) exclude = EXCLUDE + ( "col1", @@ -50,4 +69,4 @@ def get_source_siglum(self, obj): "source", "feast", ) - ordering = ("source__siglum",) + ordering = ("source__holding_institution__siglum", "source__shelfmark") diff --git a/django/cantusdb_project/main_app/admin/filters.py b/django/cantusdb_project/main_app/admin/filters.py new file mode 100644 index 000000000..72b1265ea --- /dev/null +++ b/django/cantusdb_project/main_app/admin/filters.py @@ -0,0 +1,17 @@ +from django.contrib.admin import SimpleListFilter + + +class InputFilter(SimpleListFilter): + template = "admin/input_filter.html" + + def lookups(self, request, model_admin): + return ((),) + + def choices(self, changelist): + all_choice = next(super().choices(changelist)) + all_choice["query_parts"] = ( + (key, value) + for key, value in changelist.get_filters_params().items() + if key != self.parameter_name + ) + yield all_choice diff --git a/django/cantusdb_project/main_app/admin/institution.py b/django/cantusdb_project/main_app/admin/institution.py index 71d9c9d48..204492079 100644 --- a/django/cantusdb_project/main_app/admin/institution.py +++ b/django/cantusdb_project/main_app/admin/institution.py @@ -1,7 +1,21 @@ from django.contrib import admin +from django.urls import reverse +from django.utils.safestring import mark_safe from main_app.admin.base_admin import BaseModelAdmin -from main_app.models import Institution, InstitutionIdentifier +from main_app.models import Institution, InstitutionIdentifier, Source + + +class InstitutionSourceInline(admin.TabularInline): + model = Source + extra = 0 + fields = ("link_id_field", "shelfmark", "published") + readonly_fields = ("link_id_field", "published", "shelfmark") + can_delete = False + + def link_id_field(self, obj): + change_url = reverse("admin:main_app_source_change", args=(obj.pk,)) + return mark_safe(f'{obj.pk}') class InstitutionIdentifierInline(admin.TabularInline): @@ -12,10 +26,45 @@ class InstitutionIdentifierInline(admin.TabularInline): @admin.register(Institution) class InstitutionAdmin(BaseModelAdmin): - list_display = ("name", "siglum", "get_city_region", "country") - search_fields = ("name", "siglum", "city", "region", "alternate_names") - list_filter = ("city",) - inlines = (InstitutionIdentifierInline,) + list_display = ( + "name", + "siglum", + "get_city_region", + "country", + "is_private_collector", + "is_private_collection", + ) + search_fields = ( + "name", + "siglum", + "city", + "region", + "alternate_names", + "migrated_identifier", + ) + readonly_fields = ("migrated_identifier",) + list_filter = ("is_private_collector", "is_private_collection", "city") + inlines = (InstitutionIdentifierInline, InstitutionSourceInline) + fieldsets = [ + ( + None, + { + "fields": ( + "name", + "city", + "region", + "country", + "alternate_names", + "former_sigla", + "private_notes", + "is_private_collection", + "migrated_identifier", + ) + }, + ), + ("Private Collector", {"fields": ["is_private_collector"]}), + ("Holding Institution", {"fields": ["siglum"]}), + ] def get_city_region(self, obj) -> str: city: str = obj.city if obj.city else "[No city]" diff --git a/django/cantusdb_project/main_app/admin/office.py b/django/cantusdb_project/main_app/admin/office.py deleted file mode 100644 index d31d56534..000000000 --- a/django/cantusdb_project/main_app/admin/office.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.contrib import admin - -from main_app.admin.base_admin import BaseModelAdmin -from main_app.forms import AdminOfficeForm -from main_app.models import Office - - -@admin.register(Office) -class OfficeAdmin(BaseModelAdmin): - search_fields = ("name",) - form = AdminOfficeForm diff --git a/django/cantusdb_project/main_app/admin/sequence.py b/django/cantusdb_project/main_app/admin/sequence.py index 9c7868e4d..252e1515f 100644 --- a/django/cantusdb_project/main_app/admin/sequence.py +++ b/django/cantusdb_project/main_app/admin/sequence.py @@ -7,10 +7,17 @@ @admin.register(Sequence) class SequenceAdmin(BaseModelAdmin): + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .select_related("source__holding_institution", "genre", "service") + ) + @admin.display(description="Source Siglum") def get_source_siglum(self, obj): if obj.source: - return obj.source.siglum + return obj.source.short_heading search_fields = ( "title", @@ -27,12 +34,12 @@ def get_source_siglum(self, obj): list_display = ("incipit", "get_source_siglum", "genre") list_filter = ( "genre", - "office", + "service", ) raw_id_fields = ( "source", "feast", ) readonly_fields = READ_ONLY + ("incipit",) - ordering = ("source__siglum",) + ordering = ("source__holding_institution__siglum", "source__shelfmark") form = AdminSequenceForm diff --git a/django/cantusdb_project/main_app/admin/service.py b/django/cantusdb_project/main_app/admin/service.py new file mode 100644 index 000000000..77ad37858 --- /dev/null +++ b/django/cantusdb_project/main_app/admin/service.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from main_app.admin.base_admin import BaseModelAdmin +from main_app.forms import AdminServiceForm +from main_app.models import Service + + +@admin.register(Service) +class ServiceAdmin(BaseModelAdmin): + search_fields = ("name",) + form = AdminServiceForm diff --git a/django/cantusdb_project/main_app/admin/source.py b/django/cantusdb_project/main_app/admin/source.py index d047d518d..79818d5d2 100644 --- a/django/cantusdb_project/main_app/admin/source.py +++ b/django/cantusdb_project/main_app/admin/source.py @@ -1,25 +1,54 @@ from django.contrib import admin from main_app.admin.base_admin import BaseModelAdmin, EXCLUDE, READ_ONLY +from main_app.admin.filters import InputFilter from main_app.forms import AdminSourceForm -from main_app.models import Source +from main_app.models import Source, SourceIdentifier + + +class SourceKeyFilter(InputFilter): + parameter_name = "holding_institution__siglum" + title = "Institution Siglum" + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(holding_institution__siglum__icontains=self.value()) + + +class IdentifiersInline(admin.TabularInline): + model = SourceIdentifier + extra = 0 + + def get_queryset(self, request): + return super().get_queryset(request).select_related("source__holding_institution") @admin.register(Source) class SourceAdmin(BaseModelAdmin): exclude = EXCLUDE + ("source_status",) + raw_id_fields = ("holding_institution",) + inlines = (IdentifiersInline,) # These search fields are also available on the user-source inline relationship in the user admin page search_fields = ( - "siglum", - "title", + "shelfmark", + "holding_institution__siglum", + "holding_institution__name", + "holding_institution__migrated_identifier", "id", + "provenance_notes", + "name", + "identifiers__identifier" ) - readonly_fields = READ_ONLY + ( - "number_of_chants", - "number_of_melodies", - "date_created", - "date_updated", + readonly_fields = ( + ("title", "siglum") + + READ_ONLY + + ( + "number_of_chants", + "number_of_melodies", + "date_created", + "date_updated", + ) ) # from the Django docs: # Adding a ManyToManyField to this list will instead use a nifty unobtrusive JavaScript “filter” interface @@ -36,19 +65,25 @@ class SourceAdmin(BaseModelAdmin): ) list_display = ( - "title", - "siglum", + "shelfmark", + "holding_institution", "id", ) list_filter = ( + SourceKeyFilter, "full_source", "segment", "source_status", "published", "century", + "holding_institution__is_private_collector", ) - ordering = ("siglum",) + ordering = ("holding_institution__siglum", "shelfmark") form = AdminSourceForm + + def get_queryset(self, request): + queryset = super().get_queryset(request) + return queryset.select_related("holding_institution") diff --git a/django/cantusdb_project/main_app/forms.py b/django/cantusdb_project/main_app/forms.py index 3c366d35f..8ec2597c7 100644 --- a/django/cantusdb_project/main_app/forms.py +++ b/django/cantusdb_project/main_app/forms.py @@ -1,9 +1,19 @@ from django import forms from django.contrib.auth.forms import ReadOnlyPasswordHashField +from django.contrib.auth import get_user_model +from django.db.models import Q +from django.contrib.admin.widgets import ( + FilteredSelectMultiple, +) +from django.forms.widgets import CheckboxSelectMultiple +from dal import autocomplete +from volpiano_display_utilities.cantus_text_syllabification import syllabify_text +from volpiano_display_utilities.latin_word_syllabification import LatinError from .models import ( Chant, - Office, + Service, Genre, + Institution, Notation, Feast, Source, @@ -21,24 +31,25 @@ SelectWidget, CheckboxWidget, ) -from django.contrib.auth import get_user_model -from django.db.models import Q -from django.contrib.admin.widgets import ( - FilteredSelectMultiple, -) -from dal import autocomplete # ModelForm allows to build a form directly from a model # see https://docs.djangoproject.com/en/3.0/topics/forms/modelforms/ +# Define choices for the Source model's +# complete_inventory BooleanField +COMPLETE_INVENTORY_FORM_CHOICES = ( + (True, "Full inventory"), + (False, "Partial inventory"), +) + class NameModelChoiceField(forms.ModelChoiceField): """ A custom ModelChoiceField that overrides the label_from_instance method to display the object's name attribute instead of str(object). - This field is specifically designed for handling genre and office objects. + This field is specifically designed for handling genre and service objects. Rather than displaying the name along with its description, sometimes we - only want the shorthand notation for the genre and office objects. + only want the shorthand notation for the genre and service objects. (Eg. [AV] Antiphon verse --> AV) """ @@ -56,6 +67,62 @@ class SelectWidgetNameModelChoiceField(NameModelChoiceField): widget = SelectWidget() +class CheckboxNameModelMultipleChoiceField(forms.ModelMultipleChoiceField): + """ + A custom ModelMultipleChoiceField that overrides the label_from_instance method + to display the object's name attribute instead of str(object) and uses + the CheckboxMulitpleSelect widget. + """ + + def label_from_instance(self, obj): + return obj.name + + widget = CheckboxSelectMultiple() + + +class CantusDBLatinField(forms.CharField): + """ + A custom CharField for chant text fields. Validates that the text + can be syllabified (essentially, that it does not have any improper + characters). + """ + + def validate(self, value): + super().validate(value) + if value: + try: + syllabify_text(value) + except LatinError as err: + raise forms.ValidationError(str(err)) + except ValueError as exc: + raise forms.ValidationError("Invalid characters in text.") from exc + + +class CantusDBSyllabifiedLatinField(forms.CharField): + """ + A custom CharField for chant syllabified text fields. Validates that the text + can be syllabified (essentially, that it does not have any improper + characters). + """ + + def validate(self, value): + super().validate(value) + if value: + try: + syllabify_text(value, text_presyllabified=True) + except ValueError as exc: + raise forms.ValidationError("Invalid characters in text.") from exc + + +class StyledChoiceField(forms.ChoiceField): + """ + A custom ChoiceField that uses the custom SelectWidget defined in widgets.py + as its widget (for styling). + """ + + widget = SelectWidget() + + class ChantCreateForm(forms.ModelForm): class Meta: model = Chant @@ -64,7 +131,7 @@ class Meta: "marginalia", "folio", "c_sequence", - "office", + "service", "genre", "position", "cantus_id", @@ -99,7 +166,7 @@ class Meta: "marginalia": TextInputWidget(), # folio: defined below (required) # c_sequence: defined below (required) - "office": autocomplete.ModelSelect2(url="office-autocomplete"), + "service": autocomplete.ModelSelect2(url="service-autocomplete"), "genre": autocomplete.ModelSelect2(url="genre-autocomplete"), "position": TextInputWidget(), "cantus_id": TextInputWidget(), @@ -110,8 +177,8 @@ class Meta: "finalis": TextInputWidget(), "extra": TextInputWidget(), "chant_range": VolpianoInputWidget(), - # manuscript_full_text_std_spelling: defined below (required) - "manuscript_full_text": TextAreaWidget(), + # manuscript_full_text_std_spelling: defined below (required & special field) + # "manuscript_full_text": defined below (special field) "volpiano": VolpianoAreaWidget(), "image_link": TextInputWidget(), "melody_id": TextInputWidget(), @@ -138,14 +205,18 @@ class Meta: help_text="Each folio starts with '1'.", ) - manuscript_full_text_std_spelling = forms.CharField( + manuscript_full_text_std_spelling = CantusDBLatinField( + widget=TextAreaWidget, + help_text=Chant._meta.get_field("manuscript_full_text_std_spelling").help_text, + label="Full text as in Source (standardized spelling)", required=True, + ) + + manuscript_full_text = CantusDBLatinField( widget=TextAreaWidget, - help_text="Manuscript full text with standardized spelling. Enter the words " - "according to the manuscript but normalize their spellings following " - "Classical Latin forms. Use upper-case letters for proper nouns, " - 'the first word of each chant, and the first word after "Alleluia" for ' - "Mass Alleluias. Punctuation is omitted.", + label="Full text as in Source (source spelling)", + help_text=Chant._meta.get_field("manuscript_full_text").help_text, + required=False, ) project = SelectWidgetNameModelChoiceField( @@ -172,8 +243,12 @@ class SourceCreateForm(forms.ModelForm): class Meta: model = Source fields = [ - "title", - "siglum", + # "title", + # "siglum", + "holding_institution", + "shelfmark", + "name", + "segment_m2m", "provenance", "provenance_notes", "full_source", @@ -194,11 +269,15 @@ class Meta: "fragmentarium_id", "dact_id", "indexing_notes", + "production_method", + "source_completeness", ] widgets = { - "title": TextInputWidget(), - "siglum": TextInputWidget(), + # "title": TextInputWidget(), + # "siglum": TextInputWidget(), + "shelfmark": TextInputWidget(), "provenance": autocomplete.ModelSelect2(url="provenance-autocomplete"), + "name": TextInputWidget(), "provenance_notes": TextInputWidget(), "date": TextInputWidget(), "cursus": SelectWidget(), @@ -228,24 +307,21 @@ class Meta: "other_editors": autocomplete.ModelSelect2Multiple( url="all-users-autocomplete" ), + "production_method": SelectWidget(), + "source_completeness": SelectWidget(), + } + field_classes = { + "segment_m2m": CheckboxNameModelMultipleChoiceField, } - TRUE_FALSE_CHOICES_SOURCE = ( - (True, "Full source"), - (False, "Fragment or Fragmented"), - ) - - full_source = forms.ChoiceField(choices=TRUE_FALSE_CHOICES_SOURCE, required=False) - full_source.widget.attrs.update( - {"class": "form-control custom-select custom-select-sm"} + holding_institution = forms.ModelChoiceField( + queryset=Institution.objects.all(), + widget=autocomplete.ModelSelect2(url="holding-autocomplete"), + required=False, ) - TRUE_FALSE_CHOICES_INVEN = ((True, "Complete"), (False, "Incomplete")) - complete_inventory = forms.ChoiceField( - choices=TRUE_FALSE_CHOICES_INVEN, required=False - ) - complete_inventory.widget.attrs.update( - {"class": "form-control custom-select custom-select-sm"} + complete_inventory = StyledChoiceField( + choices=COMPLETE_INVENTORY_FORM_CHOICES, required=False ) @@ -260,7 +336,7 @@ class Meta: "folio", "c_sequence", "feast", - "office", + "service", "genre", "position", "cantus_id", @@ -287,14 +363,14 @@ class Meta: "rubrics", ] widgets = { - # manuscript_full_text_std_spelling: defined below (required) - "manuscript_full_text": TextAreaWidget(), + # manuscript_full_text_std_spelling: defined below (required) & special field + # manuscript_full_text: defined below (special field) "volpiano": VolpianoAreaWidget(), "marginalia": TextInputWidget(), # folio: defined below (required) # c_sequence: defined below (required) "feast": autocomplete.ModelSelect2(url="feast-autocomplete"), - "office": autocomplete.ModelSelect2(url="office-autocomplete"), + "service": autocomplete.ModelSelect2(url="service-autocomplete"), "genre": autocomplete.ModelSelect2(url="genre-autocomplete"), "position": TextInputWidget(), "cantus_id": TextInputWidget(), @@ -322,14 +398,18 @@ class Meta: "rubrics": TextInputWidget(), } - manuscript_full_text_std_spelling = forms.CharField( + manuscript_full_text_std_spelling = CantusDBLatinField( + widget=TextAreaWidget, + help_text=Chant._meta.get_field("manuscript_full_text_std_spelling").help_text, + label="Full text as in Source (standardized spelling)", required=True, + ) + + manuscript_full_text = CantusDBLatinField( widget=TextAreaWidget, - help_text="Manuscript full text with standardized spelling. Enter the words " - "according to the manuscript but normalize their spellings following " - "Classical Latin forms. Use upper-case letters for proper nouns, " - 'the first word of each chant, and the first word after "Alleluia" for ' - "Mass Alleluias. Punctuation is omitted.", + label="Full text as in Source (source spelling)", + help_text=Chant._meta.get_field("manuscript_full_text").help_text, + required=False, ) folio = forms.CharField( @@ -347,7 +427,7 @@ class Meta: project = SelectWidgetNameModelChoiceField( queryset=Project.objects.all().order_by("id"), help_text="Select the project (if any) that the chant belongs to.", - required = False, + required=False, ) @@ -355,8 +435,12 @@ class SourceEditForm(forms.ModelForm): class Meta: model = Source fields = [ - "title", - "siglum", + # "title", + # "siglum", + "holding_institution", + "shelfmark", + "name", + "segment_m2m", "provenance", "provenance_notes", "full_source", @@ -378,13 +462,17 @@ class Meta: "full_text_entered_by", "proofreaders", "other_editors", + "production_method", + "source_completeness", ] widgets = { - "title": TextInputWidget(), - "siglum": TextInputWidget(), + "shelfmark": TextInputWidget(), + "segment_m2m": CheckboxSelectMultiple(), + "name": TextInputWidget(), "provenance": autocomplete.ModelSelect2(url="provenance-autocomplete"), "provenance_notes": TextInputWidget(), "date": TextInputWidget(), + "cursus": SelectWidget(), "summary": TextAreaWidget(), "liturgical_occasions": TextAreaWidget(), "description": TextAreaWidget(), @@ -412,33 +500,21 @@ class Meta: "other_editors": autocomplete.ModelSelect2Multiple( url="all-users-autocomplete" ), + "production_method": SelectWidget(), + "source_completeness": SelectWidget(), + } + field_classes = { + "segment_m2m": CheckboxNameModelMultipleChoiceField, } - CHOICES_FULL_SOURCE = ( - (None, "None"), - (True, "Full source"), - (False, "Fragment or Fragmented"), - ) - full_source = forms.ChoiceField(choices=CHOICES_FULL_SOURCE, required=False) - full_source.widget.attrs.update( - {"class": "form-control custom-select custom-select-sm"} - ) - - CHOICES_CURSUS = ( - (None, "None"), - ("Monastic", "Monastic"), - ("Secular", "Secular"), + holding_institution = forms.ModelChoiceField( + queryset=Institution.objects.all(), + widget=autocomplete.ModelSelect2(url="holding-autocomplete"), + required=False, ) - cursus = forms.ChoiceField(choices=CHOICES_CURSUS, required=False) - cursus.widget.attrs.update({"class": "form-control custom-select custom-select-sm"}) - CHOICES_COMPLETE_INV = ( - (True, "complete inventory"), - (False, "partial inventory"), - ) - complete_inventory = forms.ChoiceField(choices=CHOICES_COMPLETE_INV, required=False) - complete_inventory.widget.attrs.update( - {"class": "form-control custom-select custom-select-sm"} + complete_inventory = StyledChoiceField( + choices=COMPLETE_INVENTORY_FORM_CHOICES, required=False ) @@ -447,7 +523,7 @@ class Meta: model = Sequence fields = [ "title", - "siglum", + # "siglum", "incipit", "folio", "s_sequence", @@ -466,7 +542,7 @@ class Meta: ] widgets = { "title": TextInputWidget(), - "siglum": TextInputWidget(), + # "siglum": TextInputWidget(), "incipit": TextInputWidget(), "folio": TextInputWidget(), "s_sequence": TextInputWidget(), @@ -502,10 +578,14 @@ class Meta: "manuscript_full_text", "manuscript_syllabized_full_text", ] - widgets = { - "manuscript_full_text": TextAreaWidget(), - "manuscript_syllabized_full_text": TextAreaWidget(), - } + + manuscript_full_text = CantusDBLatinField( + widget=TextAreaWidget, label="Full text as in Source (source spelling)" + ) + + manuscript_syllabized_full_text = CantusDBSyllabifiedLatinField( + widget=TextAreaWidget, label="Syllabized full text" + ) class AdminCenturyForm(forms.ModelForm): @@ -558,10 +638,10 @@ class Meta: label="Sequence", ) - # We use NameModelChoiceField here so the dropdown list of office/mass displays the name + # We use NameModelChoiceField here so the dropdown list of service/mass displays the name # instead of [name] + description - office = NameModelChoiceField( - queryset=Office.objects.all().order_by("name"), + service = NameModelChoiceField( + queryset=Service.objects.all().order_by("name"), required=False, ) # We use NameModelChoiceField here so the dropdown list of genres displays the name @@ -606,9 +686,9 @@ class Meta: name.widget.attrs.update({"style": "width: 400px;"}) -class AdminOfficeForm(forms.ModelForm): +class AdminServiceForm(forms.ModelForm): class Meta: - model = Office + model = Service fields = "__all__" name = forms.CharField(required=True, widget=TextInputWidget) @@ -645,10 +725,10 @@ class Meta: "chant_range": VolpianoAreaWidget(), } - # We use NameModelChoiceField here so the dropdown list of office/mass displays the name + # We use NameModelChoiceField here so the dropdown list of service/mass displays the name # instead of [name] + description - office = NameModelChoiceField( - queryset=Office.objects.all().order_by("name"), + service = NameModelChoiceField( + queryset=Service.objects.all().order_by("name"), required=False, ) # We use NameModelChoiceField here so the dropdown list of genres displays the name @@ -672,29 +752,35 @@ class Meta: model = Source fields = "__all__" - title = forms.CharField( + # title = forms.CharField( + # required=True, + # widget=TextInputWidget, + # help_text="Full Source Identification (City, Archive, Shelf-mark)", + # ) + # title.widget.attrs.update({"style": "width: 610px;"}) + # + # siglum = forms.CharField( + # required=True, + # widget=TextInputWidget, + # help_text="RISM-style siglum + Shelf-mark (e.g. GB-Ob 202).", + # ) + + shelfmark = forms.CharField( required=True, widget=TextInputWidget, - help_text="Full Source Identification (City, Archive, Shelf-mark)", ) - title.widget.attrs.update({"style": "width: 610px;"}) - siglum = forms.CharField( - required=True, - widget=TextInputWidget, - help_text="RISM-style siglum + Shelf-mark (e.g. GB-Ob 202).", + name = forms.CharField(required=False, widget=TextInputWidget) + + holding_institution = forms.ModelChoiceField( + queryset=Institution.objects.all().order_by("city", "name"), + required=False, ) provenance = forms.ModelChoiceField( queryset=Provenance.objects.all().order_by("name"), required=False, ) - TRUE_FALSE_CHOICES_SOURCE = ( - (True, "Full source"), - (False, "Fragment or Fragmented"), - ) - - full_source = forms.ChoiceField(choices=TRUE_FALSE_CHOICES_SOURCE, required=False) century = forms.ModelMultipleChoiceField( queryset=Century.objects.all().order_by("name"), @@ -749,10 +835,8 @@ class Meta: widget=FilteredSelectMultiple(verbose_name="other editors", is_stacked=False), ) - TRUE_FALSE_CHOICES_INVEN = ((True, "Complete"), (False, "Incomplete")) - complete_inventory = forms.ChoiceField( - choices=TRUE_FALSE_CHOICES_INVEN, required=False + choices=COMPLETE_INVENTORY_FORM_CHOICES, required=False ) diff --git a/django/cantusdb_project/main_app/identifiers.py b/django/cantusdb_project/main_app/identifiers.py index 4ab27e298..b74bd2798 100644 --- a/django/cantusdb_project/main_app/identifiers.py +++ b/django/cantusdb_project/main_app/identifiers.py @@ -5,6 +5,7 @@ class ExternalIdentifiers: GND = 4 BNF = 5 LC = 6 + DIAMM = 7 IDENTIFIER_TYPES = ( @@ -14,6 +15,7 @@ class ExternalIdentifiers: (ExternalIdentifiers.GND, "GND (Gemeinsame Normdatei)"), (ExternalIdentifiers.BNF, "Bibliothèque national de France"), (ExternalIdentifiers.LC, "Library of Congress"), + (ExternalIdentifiers.DIAMM, "Digital Image Archive of Medieval Music"), ) TYPE_PREFIX = { @@ -23,4 +25,5 @@ class ExternalIdentifiers: ExternalIdentifiers.GND: ("dnb", "https://d-nb.info/gnd/"), ExternalIdentifiers.BNF: ("bnf", "https://catalogue.bnf.fr/ark:/12148/cb"), ExternalIdentifiers.LC: ("lc", "https://id.loc.gov/authorities/"), + ExternalIdentifiers.DIAMM: ("diamm", "https://www.diamm.ac.uk/"), } diff --git a/django/cantusdb_project/main_app/management/commands/map_cantus_ids.py b/django/cantusdb_project/main_app/management/commands/map_cantus_ids.py new file mode 100644 index 000000000..ad25ce5ae --- /dev/null +++ b/django/cantusdb_project/main_app/management/commands/map_cantus_ids.py @@ -0,0 +1,46 @@ +""" +This is a (potentially temporary) command to change one Cantus ID to another +Cantus ID. For the moment (October 2024), Cantus Index and Cantus Database are +working out some issues in the process of merging Cantus ID's, so this command +gives us a more fine-grained (but more general than changing them all manually) +approach to changing Cantus ID's. +""" + +from django.core.management.base import BaseCommand +import reversion # type: ignore[import-untyped] + +from main_app.models import Chant + + +class Command(BaseCommand): + help = "Change one Cantus ID to another Cantus ID." + + def add_arguments(self, parser): + parser.add_argument( + "old_cantus_id", + type=str, + help="The Cantus ID to change.", + ) + parser.add_argument( + "new_cantus_id", + type=str, + help="The Cantus ID to change to.", + ) + + def handle(self, *args, **options): + old_cantus_id = options["old_cantus_id"] + new_cantus_id = options["new_cantus_id"] + with reversion.create_revision(): + chants = Chant.objects.filter(cantus_id=old_cantus_id) + num_chants = chants.count() + for chant in chants.iterator(chunk_size=1_000): + chant.cantus_id = new_cantus_id + chant.save() + self.stdout.write( + self.style.SUCCESS( + f"Changed {old_cantus_id} to {new_cantus_id} in {num_chants} chants." + ) + ) + reversion.set_comment( + f"Changed Cantus ID: {old_cantus_id} to {new_cantus_id}" + ) diff --git a/django/cantusdb_project/main_app/management/commands/migrate_institution_source_records.py b/django/cantusdb_project/main_app/management/commands/migrate_institution_source_records.py new file mode 100644 index 000000000..c05f9a247 --- /dev/null +++ b/django/cantusdb_project/main_app/management/commands/migrate_institution_source_records.py @@ -0,0 +1,287 @@ +from collections import defaultdict + +import requests +from django.core.exceptions import ValidationError +from django.core.management import BaseCommand + +from main_app.identifiers import ExternalIdentifiers +from main_app.models import Source, Institution, InstitutionIdentifier + +sigla_to_skip = { + "N-N.miss.imp.1519", + "McGill, Fragment 21", + "D-WÜ/imp1583", + "Unknown", + "Test-Test IV", + "Test-Test VI", + "Test-test VII", + "D-P/imp1511", + "MA Impr. 1537", + "GOTTSCHALK", + "BEAUVAIS", + "D-A/imp:1498", +} + +private_collections = { + "US-SLpc", + "US-CinOHpc", + "US-AshORpc", + "US-CTpc", + "IRL-Dpc", + "US-NYpc", + "GB-Oxpc", + "US-Phpc", + "D-ROTTpc", + "D-Berpc", + "US-Nevpc", + "US-IssWApc", + "US-RiCTpc", + "US-RiCTpc,", + "US-OakCApc", + "US-CApc", + "ZA-Newpc", + "CDN-MtlQCpc", + "CDN-MasQCpc", + "CDN-HalNSpc", + "CDN-WatONpc", + "CDN-LonONpc", + "CDN-VicBCpc", + "US-BosMApc", + "US-RiCTpc", + "US-Unpc", + "US-SalNHpc", + "F-Villpc", + "GB-Brpc", + "CDN-NVanBCpc", + "CDN-SYpc", + "NL-EINpc", + "BR-PApc", +} + +siglum_to_country = { + "A": "Austria", + "AUS": "Australia", + "B": "Belgium", + "BR": "Brazil", + "CDN": "Canada", + "CH": "Switzerland", + "CZ": "Czechia", + "D": "Germany", + "DK": "Denmark", + "E": "Spain", + "EC": "Ecuador", + "F": "France", + "FIN": "Finland", + "GB": "United Kingdom", + "GR": "Greece", + "H": "Hungary", + "HR": "Croatia", + "I": "Italy", + "IRL": "Ireland", + "NL": "Netherlands", + "NZ": "New Zealand", + "P": "Portugal", + "PL": "Poland", + "RO": "Romania", + "SI": "Slovenia", + "SK": "Slovakia", + "SA": "South Africa", + "ZA": "South Africa", + "T": "Taiwan", + "TR": "Turkey", + "US": "United States", + "V": "Vatican City", + "XX": "Unknown", +} + +prints = { + "MA Impr. 1537", + "N-N.miss.imp.1519", + "D-A/imp:1498", + "D-P/imp1511", + "D-WÜ/imp1583", +} + + +class Command(BaseCommand): + help = "Creates institution records based on the entries in the Sources model" + + def add_arguments(self, parser): + parser.add_argument("-e", "--errors", action="store_true") + parser.add_argument("-l", "--lookup", action="store_true") + parser.add_argument("-d", "--dry-run", action="store_true") + parser.add_argument("-m", "--empty", action="store_true") + + def handle(self, *args, **options): + if options["empty"]: + print(self.style.WARNING("Deleting records...")) + Source.objects.all().update(holding_institution=None) + Institution.objects.all().delete() + InstitutionIdentifier.objects.all().delete() + + insts_name = defaultdict(set) + insts_ids = defaultdict(set) + insts_city = defaultdict(set) + insts_rism = {} + bad_sigla = set() + source_shelfmarks = {} + + for source in Source.objects.all().order_by("siglum"): + source_name = source.title + source_siglum = source.siglum + + try: + city, institution_name, shelfmark = source_name.split(",", 2) + source_shelfmarks[source.id] = shelfmark.strip() + except ValueError: + print( + self.style.WARNING( + f"{source.id:^11}| Could not extract institution name for {source_name}" + ) + ) + city = "[Unknown]" + institution_name = source_name + source_shelfmarks[source.id] = source_siglum.strip() + shelfmark = source_siglum.strip() + + try: + siglum, _ = source_siglum.split(" ", 1) + except ValueError: + print( + self.style.WARNING( + f"{source.id:^11}| Could not extract siglum for {source_siglum}" + ) + ) + bad_sigla.add(source_siglum) + siglum = source_siglum + + insts_name[siglum].add(institution_name.strip()) + insts_city[siglum].add(city.strip()) + insts_ids[siglum].add(source.id) + + if options["lookup"] and ( + siglum not in bad_sigla + or siglum not in private_collections + or siglum not in insts_rism + ): + req = requests.get( + f"https://rism.online/sigla/{siglum}", + allow_redirects=True, + headers={"Accept": "application/ld+json"}, + ) + if req.status_code != 200: + print( + self.style.WARNING( + f"{source.id:^11}| Could not fetch siglum {siglum}" + ) + ) + bad_sigla.add(siglum) + else: + resp = req.json() + inst_ident = resp.get("id", "") + rism_id = "/".join(inst_ident.split("/")[-2:]) + insts_rism[siglum] = rism_id + + print( + self.style.SUCCESS( + f"{source.id:^11}|{city:^31}|{institution_name:^101}|{siglum:^11}|{shelfmark}" + ) + ) + + if options["lookup"]: + print("Bad Sigla: ") + for sig in bad_sigla: + names = list(insts_name[sig]) + print(sig, ",", names if len(names) > 0 else "No name") + + print("Here are the institutions that I will create:") + print("siglum,city,country,name,alt_names") + + print_inst = Institution.objects.create( + name="Print (Multiple Copies)", siglum="XX-NN", city=None + ) + + for sig, names in insts_name.items(): + print("Sig: ", sig) + inst_id = insts_ids[sig] + + if sig not in prints: + if sig == "MA Impr. 1537": + print("WHAHAHAHATTT?T??T?TTT?T") + + inst_city = insts_city[sig] + main_city = list(inst_city)[0] if len(inst_city) > 0 else "" + main_name = list(names)[0] if len(names) > 0 else "" + alt_names = "; ".join(list(names)[1:]) + alt_names_fmt = f'"{alt_names}"' if alt_names else "" + + try: + inst_country = siglum_to_country[sig.split("-")[0]] + inst_sig = sig + except KeyError: + print(self.style.WARNING(f"Unknown country for siglum {sig}.")) + inst_country = None + # Setting siglum to None will make it XX-NN + inst_sig = None + + print( + f"{inst_sig},{main_city},{inst_country},{main_name},{alt_names_fmt}" + ) + + if options["dry_run"]: + continue + + iobj = { + "city": main_city if main_city != "[Unknown]" else None, + "country": inst_country, + "name": main_name, + "alternate_names": "\n".join(list(names)[1:]), + } + + if inst_sig in private_collections: + iobj["is_private_collector"] = True + elif inst_sig is not None: + iobj["siglum"] = inst_sig + else: + print( + self.style.WARNING( + f"Could not create {inst_id}. Setting siglum to XX-NN" + ) + ) + iobj["siglum"] = "XX-NN" + + try: + holding_institution = Institution.objects.create(**iobj) + except ValidationError: + print( + self.style.WARNING( + f"Could not create {sig} {main_name}. Setting institution to None" + ) + ) + holding_institution = None + + if holding_institution: + print("Created", holding_institution) + else: + holding_institution = print_inst + + if rismid := insts_rism.get(sig): + instid = InstitutionIdentifier.objects.create( + identifier=rismid, + identifier_type=ExternalIdentifiers.RISM, + institution=holding_institution, + ) + instid.save() + + for source_id in list(inst_id): + shelfmark = source_shelfmarks.get(source_id) + + s = Source.objects.get(id=source_id) + if not shelfmark: + shelfmark = s.siglum + + print(s) + s.holding_institution = holding_institution + s.shelfmark = shelfmark.strip() + s.save() + print(self.style.SUCCESS(f"Saved update to Source {s.id}")) diff --git a/django/cantusdb_project/main_app/management/commands/migrate_records.py b/django/cantusdb_project/main_app/management/commands/migrate_records.py new file mode 100644 index 000000000..fca35a564 --- /dev/null +++ b/django/cantusdb_project/main_app/management/commands/migrate_records.py @@ -0,0 +1,259 @@ +from typing import Optional + +import requests +from django.core.management import BaseCommand + +from main_app.identifiers import ExternalIdentifiers +from main_app.models import Source, Institution, InstitutionIdentifier + +private_collections = { + "US-SLpc", + "US-CinOHpc", + "US-AshORpc", + "US-CTpc", + "IRL-Dpc", + "US-NYpc", + "GB-Oxpc", + "US-Phpc", + "D-ROTTpc", + "D-Berpc", + "US-Nevpc", + "US-IssWApc", + "US-RiCTpc", + "US-RiCTpc,", + "US-OakCApc", + "US-CApc", + "ZA-Newpc", + "CDN-pc", + "CDN-pc(AB)", + "CDN-pc(BC)", + "US-pc(Texas),", + "US-pc(MA)", + "US-pc(OH)", + "US-pc(CA)", + "US-pc(VA)", + "US-pc(Washington)", + "CDN-MtlQCpc", + "CDN-MasQCpc", + "CDN-HalNSpc", + "CDN-WatONpc", + "CDN-LonONpc", + "CDN-VicBCpc", + "US-BosMApc", + "US-RiCTpc", + "US-Unpc", + "US-SalNHpc", + "F-Villpc", + "GB-Brpc", + "CDN-NVanBCpc", + "CDN-SYpc", + "NL-EINpc", + "BR-PApc", +} + +siglum_to_country = { + "A": "Austria", + "AUS": "Australia", + "B": "Belgium", + "BR": "Brazil", + "CDN": "Canada", + "CH": "Switzerland", + "CZ": "Czechia", + "D": "Germany", + "DK": "Denmark", + "E": "Spain", + "EC": "Ecuador", + "F": "France", + "FIN": "Finland", + "GB": "United Kingdom", + "GR": "Greece", + "H": "Hungary", + "HR": "Croatia", + "I": "Italy", + "IRL": "Ireland", + "N": "Norway", + "NL": "Netherlands", + "NZ": "New Zealand", + "P": "Portugal", + "PL": "Poland", + "RO": "Romania", + "SI": "Slovenia", + "SK": "Slovakia", + "SA": "South Africa", + "ZA": "South Africa", + "S": "Sweden", + "T": "Taiwan", + "TR": "Turkey", + "US": "United States", + "V": "Vatican City", + "XX": "Unknown", +} + +prints = { + "MA Impr. 1537", + "N-N.miss.imp.1519", + "D-A/imp:1498", + "D-P/imp1511", + "D-WÜ/imp1583", +} + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument("-e", "--empty", action="store_true") + + def handle(self, *args, **options): + if options["empty"]: + print(self.style.WARNING("Deleting records...")) + Source.objects.all().update(holding_institution=None) + Institution.objects.all().delete() + InstitutionIdentifier.objects.all().delete() + + print_inst = Institution.objects.create( + name="Print (Multiple Copies)", siglum="XX-NN", city=None + ) + + # Store a siglum: id + created_institutions = {} + # Track the sources with a bad siglum so that we don't try and look it up and fail. + bad_siglum = set() + + for source in Source.objects.all().order_by("siglum"): + print(self.style.SUCCESS(f"Processing {source.id}")) + source_name = source.title + source_siglum = source.siglum + + try: + city, institution_name, shelfmark = source_name.split(",", 2) + except ValueError: + print( + self.style.WARNING( + f"{source.id:^11}| Could not extract institution name for {source_name}" + ) + ) + city = "[Unknown]" + institution_name = source_name + shelfmark = source_siglum + + try: + siglum, _ = source_siglum.split(" ", 1) + except ValueError: + print( + self.style.WARNING( + f"{source.id:^11}| Could not extract siglum for {source_siglum}" + ) + ) + siglum = "XX-NN" + bad_siglum.add(source.id) + + try: + country = siglum_to_country[siglum.split("-")[0]] + except KeyError: + print( + self.style.WARNING( + f"{source.id:^11}| Could not extract country for {source_siglum}" + ) + ) + country = "[Unknown Country]" + bad_siglum.add(source.id) + + if source_siglum in prints: + print( + self.style.SUCCESS( + f"Adding {source_siglum} to the printed records institution" + ) + ) + institution = print_inst + elif siglum in created_institutions and source.id not in bad_siglum: + print( + self.style.SUCCESS( + f"Re-using the pre-created institution for {siglum}" + ) + ) + + institution = created_institutions[siglum] + + # if the source we're publishing has a different institution name than the + # one that already exists, save the source name as an alternate name. + if institution_name and institution_name != institution.name: + existing_alt_names: list = ( + institution.alternate_names.split("\n") + if institution.alternate_names + else [] + ) + existing_alt_names.append(institution_name.strip()) + deduped_names = set(existing_alt_names) + institution.alternate_names = "\n".join(list(deduped_names)) + + institution.save() + elif siglum not in created_institutions and source.id not in bad_siglum: + print(self.style.SUCCESS(f"Creating institution record for {siglum}")) + + iobj = { + "city": city.strip() if city else None, + "country": country, + "name": institution_name.strip(), + } + + if siglum in private_collections: + iobj["is_private_collector"] = True + iobj["is_private_collection"] = True + iobj["migrated_identifier"] = siglum.strip() + else: + iobj["siglum"] = siglum + + if "private" in institution_name.lower(): + iobj["is_private_collection"] = True + + institution = Institution.objects.create(**iobj) + + rism_id = None + if source.id not in bad_siglum and siglum not in private_collections: + rism_id = get_rism_id(siglum) + elif siglum == "XX-NN": + rism_id = "institutions/51003803" + + if rism_id: + print( + self.style.SUCCESS( + f"Adding {rism_id} to the identifiers for {siglum}" + ) + ) + + instid = InstitutionIdentifier.objects.create( + identifier=rism_id, + identifier_type=ExternalIdentifiers.RISM, + institution=institution, + ) + instid.save() + + created_institutions[siglum] = institution + + else: + source.shelfmark = shelfmark.strip() + source.save() + print( + self.style.ERROR( + f"Could not determine the holding institution for {source}" + ) + ) + continue + + source.holding_institution = institution + source.shelfmark = shelfmark.strip() + source.save() + + +def get_rism_id(siglum) -> Optional[str]: + req = requests.get( + f"https://rism.online/sigla/{siglum}", + allow_redirects=True, + headers={"Accept": "application/ld+json"}, + ) + if req.status_code != 200: + return None + else: + resp = req.json() + inst_ident = resp.get("id", "") + rism_id = "/".join(inst_ident.split("/")[-2:]) + return rism_id diff --git a/django/cantusdb_project/main_app/management/commands/populate_diff_id_fields.py b/django/cantusdb_project/main_app/management/commands/populate_diff_id_fields.py index c6d0a099a..b99fb6b09 100644 --- a/django/cantusdb_project/main_app/management/commands/populate_diff_id_fields.py +++ b/django/cantusdb_project/main_app/management/commands/populate_diff_id_fields.py @@ -2,44 +2,42 @@ from django.core.management.base import BaseCommand from django.db.models import Q from typing import Optional +from django.db import transaction class Command(BaseCommand): + @transaction.atomic def handle(self, *args, **kwargs): - CHUNK_SIZE = 500 chants = Chant.objects.filter( Q(differentiae_database__isnull=False) & Q(diff_db__isnull=True) - ) - chants_count = chants.count() - start_index = 0 + ).iterator(chunk_size=500) + chants_total = Chant.objects.filter( + Q(differentiae_database__isnull=False) & Q(diff_db__isnull=True) + ).count() count = 0 - while start_index <= chants_count: - self.stdout.write(f"processing chunk with {start_index=}") - chunk = chants[start_index : start_index + CHUNK_SIZE] - for chant in chunk: - try: - differentia_id: Optional[str] = chant.differentiae_database - differentia = Differentia.objects.get(differentia_id=differentia_id) - if differentia: - chant.diff_db = differentia - else: - # If the Differentia doesn't exist, create a new one - differentia = Differentia( - differentia_id=differentia_id, - ) - differentia.save() - chant.diff_db = differentia - chant.save() - except Differentia.DoesNotExist: - print(f"Differentia not found for chant: {chant}") - count += 1 - if count % 100 == 0: - print( - f"------------------ {count} of {chants_count} chants updated ------------------" + for chant in chants: + try: + differentia_id: Optional[str] = chant.differentiae_database + differentia = Differentia.objects.get(differentia_id=differentia_id) + except Differentia.DoesNotExist: + # If the Differentia doesn't exist, create a new one + differentia = Differentia(differentia_id=differentia_id) + differentia.save() + self.stdout.write( + self.style.WARNING(f"Differentia created for chant: {chant}") + ) + + chant.diff_db = differentia + chant.save() + + count += 1 + if count % 100 == 0: + self.stdout.write( + self.style.SUCCESS( + f"------------------ {count} of {chants_total} chants updated ------------------" ) - del chunk # make sure we don't use too much RAM - start_index += CHUNK_SIZE + ) self.stdout.write( self.style.SUCCESS("Success! Command has run to completion.\n") diff --git a/django/cantusdb_project/main_app/management/commands/populate_source_completeness.py b/django/cantusdb_project/main_app/management/commands/populate_source_completeness.py new file mode 100644 index 000000000..7a16fa6f5 --- /dev/null +++ b/django/cantusdb_project/main_app/management/commands/populate_source_completeness.py @@ -0,0 +1,22 @@ +""" +A temporary command to populate the source_completeness field in the Source model, +based on the full_source field. This command will be removed once the source_completeness +is initially populated. +""" + +from django.core.management.base import BaseCommand +from main_app.models import Source + + +class Command(BaseCommand): + def handle(self, *args, **options): + sources = Source.objects.all() + for source in sources: + if source.full_source or source.full_source is None: + source.source_completeness = ( + source.SourceCompletenessChoices.FULL_SOURCE + ) + else: + source.source_completeness = source.SourceCompletenessChoices.FRAGMENT + source.save() + self.stdout.write(self.style.SUCCESS("Source completeness populated")) diff --git a/django/cantusdb_project/main_app/management/commands/reassign_feasts.py b/django/cantusdb_project/main_app/management/commands/reassign_feasts.py new file mode 100644 index 000000000..5ad542595 --- /dev/null +++ b/django/cantusdb_project/main_app/management/commands/reassign_feasts.py @@ -0,0 +1,58 @@ +from django.core.management.base import BaseCommand +from main_app.models import Feast, Chant, Sequence + + +FEAST_MAPPING = { + 2456: 4474, + 2094: 4475, +} + + +class Command(BaseCommand): + help = "Reassign feasts and update chants accordingly" + + def handle(self, *args, **options): + + for old_feast_id, new_feast_id in FEAST_MAPPING.items(): + try: + old_feast = Feast.objects.get(id=old_feast_id) + new_feast = Feast.objects.get(id=new_feast_id) + except Feast.DoesNotExist as e: + self.stderr.write(self.style.ERROR(f"Feast not found: {e}")) + continue + + # Transfer data (if necessary) + new_feast.name = new_feast.name or old_feast.name + new_feast.description = new_feast.description or old_feast.description + new_feast.feast_code = new_feast.feast_code or old_feast.feast_code + new_feast.notes = new_feast.notes or old_feast.notes + new_feast.month = new_feast.month or old_feast.month + new_feast.day = new_feast.day or old_feast.day + + # Calling save method will update 'prefix' field + new_feast.save() + + # Reassign chants + chants_updated = Chant.objects.filter(feast=old_feast).update( + feast=new_feast + ) + self.stdout.write( + self.style.SUCCESS( + f"Reassigned {chants_updated} chants from feast {old_feast_id} to {new_feast_id}" + ) + ) + + # Reassign sequences + sequences_updated = Sequence.objects.filter(feast=old_feast).update( + feast=new_feast + ) + self.stdout.write( + self.style.SUCCESS( + f"Reassigned {sequences_updated} sequences from feast {old_feast_id} to {new_feast_id}" + ) + ) + + old_feast.delete() + self.stdout.write(self.style.SUCCESS(f"Deleted old feast {old_feast_id}")) + + self.stdout.write(self.style.SUCCESS("Feast reassignment complete.")) diff --git a/django/cantusdb_project/main_app/management/commands/reformat_source_ids.py b/django/cantusdb_project/main_app/management/commands/reformat_source_ids.py new file mode 100644 index 000000000..b11f3941a --- /dev/null +++ b/django/cantusdb_project/main_app/management/commands/reformat_source_ids.py @@ -0,0 +1,49 @@ +""" +A command designed to do a one-time reformatting of DACT IDs and Fragment +IDs in the database. + +Fragment IDs should be of the form "F-XXXX" where XXXX is some alphanumeric. +Fragment IDs are currently assumed to be in the form "F-XXXX" or "XXXX". +DACT IDs should be of the form "D:0XXXX" where XXXX is the Fragment ID alphanumeric. +DACT IDs are currently assumed to be in the form "0XXXX" or "D-0XXXX". + +This command simply adds the prefix "F-" to all Fragment IDs and "D:" to all +DACT IDs where they are missing. +""" + +from django.core.management.base import BaseCommand + +from main_app.models import Source + + +class Command(BaseCommand): + help = "Reformat DACT IDs and Fragment IDs in the database." + + def handle(self, *args, **options): + sources = Source.objects.all() + for source in sources: + if source.dact_id: + if len(source.dact_id) == 5 and source.dact_id.startswith("0"): + source.dact_id = f"D:{source.dact_id}" + elif len(source.dact_id) == 7 and source.dact_id.startswith("D-0"): + source.dact_id = f"D:{source.dact_id[2:]}" + else: + self.stdout.write( + self.style.WARNING( + f"{source.id} | DACT ID {source.dact_id} is not in the correct format." + ) + ) + if source.fragmentarium_id: + if len(source.fragmentarium_id) == 4: + source.fragmentarium_id = f"F-{source.fragmentarium_id}" + elif len( + source.fragmentarium_id + ) == 6 and source.fragmentarium_id.startswith("F-"): + pass + else: + self.stdout.write( + self.style.WARNING( + f"{source.id} | Fragment ID {source.fragmentarium_id} is not in the correct format." + ) + ) + source.save() diff --git a/django/cantusdb_project/main_app/management/commands/remap_user_ids.py b/django/cantusdb_project/main_app/management/commands/remap_user_ids.py index a0a734ffc..51002d048 100644 --- a/django/cantusdb_project/main_app/management/commands/remap_user_ids.py +++ b/django/cantusdb_project/main_app/management/commands/remap_user_ids.py @@ -1,9 +1,9 @@ -from main_app.models import Source, Chant from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand -from sys import stdout from django.db.models.query import QuerySet -from typing import Optional +import reversion # type: ignore[import-untyped] + +from main_app.models import Source, Chant User = get_user_model() @@ -49,89 +49,66 @@ } -def reassign_sources() -> None: - CHUNK_SIZE = 1_000 - sources: QuerySet[Source] = Source.objects.all() - sources_count: int = sources.count() - start_index: int = 0 - while start_index <= sources_count: - stdout.write(f"processing chunk with {start_index=}\n") - chunk: QuerySet[Source] = sources[start_index : start_index + CHUNK_SIZE] - for source in chunk: - old_creator: Optional[User] = source.created_by - - updated_id: Optional[int] = None - try: - updated_id: int = USER_ID_MAPPING[old_creator.id] - except ( - KeyError, # old_creator.id not in USER_ID_MAPPING - AttributeError, # old_creator is None - ): - pass +class Command(BaseCommand): + def reassign_sources(self) -> None: + sources: QuerySet[Source] = Source.objects.filter( + created_by__in=USER_ID_MAPPING.keys() + ) + num_sources = sources.count() + self.stdout.write( + self.style.NOTICE(f"Reassigning {num_sources} sources to new users.") + ) + source_counter = 0 + for source in sources.iterator(chunk_size=1_000): + old_creator = source.created_by - if updated_id is None: - # user ID doesn't need to be remapped - continue + # We know the old_creator is in USER_ID_MAPPING.keys() because of the filter + # on the queryset. + updated_id = USER_ID_MAPPING[old_creator.id] # type: ignore[union-attr] - updated_creator: Optional[User] = None - try: - updated_creator = User.objects.get(id=updated_id) - except ( - User.DoesNotExist, - AttributeError, - ): - pass + updated_creator = User.objects.get(id=updated_id) source.created_by = updated_creator source.save() - start_index += CHUNK_SIZE - - -def reassign_chants() -> None: - CHUNK_SIZE = 1_000 - chants: QuerySet[Chant] = Chant.objects.all() - chants_count: int = chants.count() - start_index: int = 0 - while start_index <= chants_count: - stdout.write(f"processing chunk with {start_index=}\n") - chunk: QuerySet[Chant] = chants[start_index : start_index + CHUNK_SIZE] - for chant in chunk: - old_creator: Optional[User] = chant.created_by - - updated_id: Optional[int] = None - try: - updated_id: int = USER_ID_MAPPING[old_creator.id] - except ( - KeyError, # old_creator.id not in USER_ID_MAPPING - AttributeError, # old_creator is None - ): - pass - - if updated_id is None: - # user ID doesn't need to be remapped - continue - - updated_creator: Optional[User] = None - try: - updated_creator = User.objects.get(id=updated_id) - except User.DoesNotExist: - pass - + source_counter += 1 + if source_counter % 100 == 0: + self.stdout.write( + self.style.NOTICE(f"Reassigned {source_counter} sources.") + ) + + def reassign_chants(self) -> None: + chants: QuerySet[Chant] = Chant.objects.filter( + created_by__in=USER_ID_MAPPING.keys() + ) + num_chants = chants.count() + self.stdout.write( + self.style.NOTICE(f"Reassigning {num_chants} sources to new users.") + ) + chant_counter = 0 + for chant in chants.iterator(chunk_size=1_000): + old_creator = chant.created_by + # We know the old_creator is in USER_ID_MAPPING.keys() because of the filter + # on the queryset. + updated_id: int = USER_ID_MAPPING[old_creator.id] # type: ignore[union-attr] + updated_creator = User.objects.get(id=updated_id) chant.created_by = updated_creator chant.save() - start_index += CHUNK_SIZE - + chant_counter += 1 + if chant_counter % 100 == 0: + self.stdout.write( + self.style.NOTICE(f"Reassigned {chant_counter} chants.") + ) -class Command(BaseCommand): def handle(self, *args, **kwargs) -> None: - error_message = ( - "As of late November 2023, this command is not working. " - "It has been temporarily disabled until the bugs have been worked out." - ) - raise NotImplementedError(error_message) - stdout.write("\n\n==== Reassigning Sources ====\n") - reassign_sources() - stdout.write("\n== All sources successfully remapped! ==\n") - stdout.write("\n\n==== Reassigning Chants ====\n") - reassign_chants() - stdout.write("\n== All chants successfully remapped! ==\n") + with reversion.create_revision(): + self.stdout.write(self.style.NOTICE("==== Reassigning Sources ====")) + self.reassign_sources() + self.stdout.write( + self.style.SUCCESS("== All sources successfully remapped! ==") + ) + self.stdout.write(self.style.NOTICE("==== Reassigning Chants ====")) + self.reassign_chants() + self.stdout.write( + self.style.SUCCESS("== All chants successfully remapped! ==") + ) + reversion.set_comment("Command: remap user IDs") diff --git a/django/cantusdb_project/main_app/management/commands/update_cached_concordances.py b/django/cantusdb_project/main_app/management/commands/update_cached_concordances.py index 47015a58b..820e02d2a 100644 --- a/django/cantusdb_project/main_app/management/commands/update_cached_concordances.py +++ b/django/cantusdb_project/main_app/management/commands/update_cached_concordances.py @@ -62,7 +62,7 @@ def get_concordances() -> list[dict]: "source", "feast", "genre", - "office", + "service", ).values( "id", "source_id", @@ -72,7 +72,7 @@ def get_concordances() -> list[dict]: "incipit", "feast__name", "genre__name", - "office__name", + "service__name", "position", "cantus_id", "image_link", @@ -112,7 +112,10 @@ def make_chant_dict(chant: dict) -> dict: "incipit": chant["incipit"], "feast": chant["feast__name"], "genre": chant["genre__name"], - "office": chant["office__name"], + "office": chant[ + "service__name" + ], # We keep the office key for backwards compatibility + # with external applications (e.g. Cantus Index) using this export "position": chant["position"], "cantus_id": chant["cantus_id"], "image": chant["image_link"], diff --git a/django/cantusdb_project/main_app/migrations/0020_institution_is_private_collector_and_more.py b/django/cantusdb_project/main_app/migrations/0020_institution_is_private_collector_and_more.py new file mode 100644 index 000000000..c0bf8af80 --- /dev/null +++ b/django/cantusdb_project/main_app/migrations/0020_institution_is_private_collector_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.1.6 on 2024-06-14 13:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main_app", "0019_remove_source_rism_siglum_delete_rismsiglum"), + ] + + operations = [ + migrations.AddField( + model_name="institution", + name="is_private_collector", + field=models.BooleanField( + default=False, help_text="Mark this institution as private collector." + ), + ), + migrations.AddField( + model_name="institution", + name="private_notes", + field=models.TextField( + blank=True, + help_text="Notes about this institution that are not publicly visible.", + null=True, + ), + ), + ] diff --git a/django/cantusdb_project/main_app/migrations/0021_source_shelfmark.py b/django/cantusdb_project/main_app/migrations/0021_source_shelfmark.py new file mode 100644 index 000000000..9815e733b --- /dev/null +++ b/django/cantusdb_project/main_app/migrations/0021_source_shelfmark.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.6 on 2024-06-14 13:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main_app", "0020_institution_is_private_collector_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="source", + name="shelfmark", + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/django/cantusdb_project/main_app/migrations/0022_alter_source_siglum_alter_source_title.py b/django/cantusdb_project/main_app/migrations/0022_alter_source_siglum_alter_source_title.py new file mode 100644 index 000000000..6bae4039d --- /dev/null +++ b/django/cantusdb_project/main_app/migrations/0022_alter_source_siglum_alter_source_title.py @@ -0,0 +1,32 @@ +# Generated by Django 4.1.6 on 2024-06-18 12:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main_app", "0021_source_shelfmark"), + ] + + operations = [ + migrations.AlterField( + model_name="source", + name="siglum", + field=models.CharField( + blank=True, + help_text="RISM-style siglum + Shelf-mark (e.g. GB-Ob 202).", + max_length=63, + null=True, + ), + ), + migrations.AlterField( + model_name="source", + name="title", + field=models.CharField( + blank=True, + help_text="Full Source Identification (City, Archive, Shelf-mark)", + max_length=255, + null=True, + ), + ), + ] diff --git a/django/cantusdb_project/main_app/migrations/0023_alter_institution_siglum_and_more.py b/django/cantusdb_project/main_app/migrations/0023_alter_institution_siglum_and_more.py new file mode 100644 index 000000000..d365f1f97 --- /dev/null +++ b/django/cantusdb_project/main_app/migrations/0023_alter_institution_siglum_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 4.1.6 on 2024-06-20 15:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main_app", "0022_alter_source_siglum_alter_source_title"), + ] + + operations = [ + migrations.AlterField( + model_name="institution", + name="siglum", + field=models.CharField( + blank=True, + help_text="Reserved for assigned RISM sigla", + max_length=32, + null=True, + verbose_name="RISM Siglum", + ), + ), + migrations.AddConstraint( + model_name="institution", + constraint=models.CheckConstraint( + check=models.Q( + ("is_private_collector", True), + ("siglum__isnull", False), + _negated=True, + ), + name="siglum_and_private_not_valid", + violation_error_message="Siglum and Private Collector cannot both be specified.", + ), + ), + migrations.AddConstraint( + model_name="institution", + constraint=models.CheckConstraint( + check=models.Q( + ("is_private_collector", True), + ("siglum__isnull", False), + _connector="OR", + ), + name="at_least_one_of_siglum_or_private_collector", + violation_error_message="At least one of Siglum or Private Collector must be specified.", + ), + ), + ] diff --git a/django/cantusdb_project/main_app/migrations/0024_merge_20240714_2153.py b/django/cantusdb_project/main_app/migrations/0024_merge_20240714_2153.py new file mode 100644 index 000000000..52c830bba --- /dev/null +++ b/django/cantusdb_project/main_app/migrations/0024_merge_20240714_2153.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2.11 on 2024-07-14 21:53 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "main_app", + "0020_remove_chant_segment_remove_sequence_segment_project_and_more", + ), + ("main_app", "0023_alter_institution_siglum_and_more"), + ] + + operations = [] diff --git a/django/cantusdb_project/main_app/migrations/0025_alter_source_date.py b/django/cantusdb_project/main_app/migrations/0025_alter_source_date.py new file mode 100644 index 000000000..61282f7d4 --- /dev/null +++ b/django/cantusdb_project/main_app/migrations/0025_alter_source_date.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.11 on 2024-07-17 14:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("main_app", "0024_merge_20240714_2153"), + ] + + operations = [ + migrations.AlterField( + model_name="source", + name="date", + field=models.CharField( + blank=True, + help_text='Date of the source, if known (e.g. "1541")', + max_length=63, + null=True, + ), + ), + ] diff --git a/django/cantusdb_project/main_app/migrations/0026_source_segment_m2m.py b/django/cantusdb_project/main_app/migrations/0026_source_segment_m2m.py new file mode 100644 index 000000000..c3911e6e3 --- /dev/null +++ b/django/cantusdb_project/main_app/migrations/0026_source_segment_m2m.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.11 on 2024-07-26 17:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("main_app", "0025_alter_source_date"), + ] + + operations = [ + migrations.AddField( + model_name="source", + name="segment_m2m", + field=models.ManyToManyField( + blank=True, + related_name="sources", + to="main_app.segment", + verbose_name="Segments", + ), + ), + ] diff --git a/django/cantusdb_project/main_app/migrations/0027_rename_office_service_rename_office_chant_service_and_more.py b/django/cantusdb_project/main_app/migrations/0027_rename_office_service_rename_office_chant_service_and_more.py new file mode 100644 index 000000000..01120ca14 --- /dev/null +++ b/django/cantusdb_project/main_app/migrations/0027_rename_office_service_rename_office_chant_service_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.11 on 2024-08-07 17:10 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("main_app", "0026_source_segment_m2m"), + ] + + operations = [ + migrations.RenameModel( + old_name="Office", + new_name="Service", + ), + migrations.RenameField( + model_name="chant", + old_name="office", + new_name="service", + ), + migrations.RenameField( + model_name="sequence", + old_name="office", + new_name="service", + ), + ] diff --git a/django/cantusdb_project/main_app/migrations/0028_alter_institution_options_and_more.py b/django/cantusdb_project/main_app/migrations/0028_alter_institution_options_and_more.py new file mode 100644 index 000000000..0493ded5d --- /dev/null +++ b/django/cantusdb_project/main_app/migrations/0028_alter_institution_options_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.14 on 2024-08-12 14:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("main_app", "0027_rename_office_service_rename_office_chant_service_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="institution", + options={"ordering": ["country", "city", "name"]}, + ), + migrations.AlterField( + model_name="institutionidentifier", + name="identifier_type", + field=models.IntegerField( + choices=[ + (1, "RISM Online"), + (2, "VIAF"), + (3, "Wikidata"), + (4, "GND (Gemeinsame Normdatei)"), + (5, "Bibliothèque national de France"), + (6, "Library of Congress"), + (7, "Digital Image Archive of Medieval Music"), + ] + ), + ), + ] diff --git a/django/cantusdb_project/main_app/migrations/0029_institution_migrated_identifier.py b/django/cantusdb_project/main_app/migrations/0029_institution_migrated_identifier.py new file mode 100644 index 000000000..aec52294a --- /dev/null +++ b/django/cantusdb_project/main_app/migrations/0029_institution_migrated_identifier.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.14 on 2024-08-23 08:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("main_app", "0028_alter_institution_options_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="institution", + name="migrated_identifier", + field=models.CharField( + blank=True, + help_text="Former Cantus identifier. Should not be used for new records.", + max_length=64, + null=True, + ), + ), + ] diff --git a/django/cantusdb_project/main_app/migrations/0030_institution_is_private_collection.py b/django/cantusdb_project/main_app/migrations/0030_institution_is_private_collection.py new file mode 100644 index 000000000..1d8ebab7c --- /dev/null +++ b/django/cantusdb_project/main_app/migrations/0030_institution_is_private_collection.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.14 on 2024-08-23 08:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("main_app", "0029_institution_migrated_identifier"), + ] + + operations = [ + migrations.AddField( + model_name="institution", + name="is_private_collection", + field=models.BooleanField( + default=False, + help_text="Mark this instititution as being a private collection. This is used to identify private \ncollectors regardless of whether they have a RISM siglum or not.\n", + ), + ), + ] diff --git a/django/cantusdb_project/main_app/migrations/0031_source_name_source_production_method_and_more.py b/django/cantusdb_project/main_app/migrations/0031_source_name_source_production_method_and_more.py new file mode 100644 index 000000000..b0440b7f8 --- /dev/null +++ b/django/cantusdb_project/main_app/migrations/0031_source_name_source_production_method_and_more.py @@ -0,0 +1,104 @@ +# Generated by Django 4.2.14 on 2024-10-02 17:01 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("main_app", "0030_institution_is_private_collection"), + ] + + operations = [ + migrations.AddField( + model_name="source", + name="name", + field=models.CharField( + blank=True, + help_text="A colloquial or commonly-used name for the source", + max_length=255, + null=True, + ), + ), + migrations.AddField( + model_name="source", + name="production_method", + field=models.IntegerField( + choices=[(1, "Manuscript"), (2, "Printed")], + default=1, + verbose_name="Manuscript/Printed", + ), + ), + migrations.AddField( + model_name="source", + name="source_completeness", + field=models.IntegerField( + choices=[ + (1, "Full source"), + (2, "Fragment/Fragmented"), + (3, "Reconstruction"), + ], + default=1, + verbose_name="Full Source/Fragment", + ), + ), + migrations.AlterField( + model_name="institution", + name="country", + field=models.CharField(default="[No Country]", max_length=64), + ), + migrations.AlterField( + model_name="institution", + name="name", + field=models.CharField(default="[No Name]", max_length=255), + ), + migrations.AlterField( + model_name="source", + name="shelfmark", + field=models.CharField( + default="[No Shelfmark]", + help_text="Primary Cantus Database identifier for the source (e.g. library shelfmark, DACT ID, etc.)", + max_length=255, + ), + ), + migrations.CreateModel( + name="SourceIdentifier", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("identifier", models.CharField(max_length=255)), + ( + "type", + models.IntegerField( + choices=[ + (1, "Other catalogues"), + (2, "olim (Former shelfmark)"), + (3, "Alternative names"), + (4, "RISM Online"), + ] + ), + ), + ("note", models.TextField(blank=True, null=True)), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="identifiers", + to="main_app.source", + ), + ), + ], + options={ + "verbose_name": "Source Identifier", + "ordering": ("type",), + }, + ), + ] diff --git a/django/cantusdb_project/main_app/migrations/0032_alter_source_source_completeness.py b/django/cantusdb_project/main_app/migrations/0032_alter_source_source_completeness.py new file mode 100644 index 000000000..9809b8d03 --- /dev/null +++ b/django/cantusdb_project/main_app/migrations/0032_alter_source_source_completeness.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.14 on 2024-10-15 14:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("main_app", "0031_source_name_source_production_method_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="source", + name="source_completeness", + field=models.IntegerField( + choices=[ + (1, "Complete source"), + (2, "Fragment"), + (3, "Reconstruction"), + ], + default=1, + verbose_name="Complete Source/Fragment", + ), + ), + ] diff --git a/django/cantusdb_project/main_app/mixins.py b/django/cantusdb_project/main_app/mixins.py new file mode 100644 index 000000000..a8196905e --- /dev/null +++ b/django/cantusdb_project/main_app/mixins.py @@ -0,0 +1,59 @@ +from typing import Any +from django.http.response import JsonResponse, HttpResponse +from django.http.request import HttpRequest +from django.core.exceptions import ImproperlyConfigured +from django.template.response import TemplateResponse + + +class JSONResponseMixin: + """ + Mixin to negotiate content type. Designed for use with + DetailView and ListView classes only. + + If the request contains an `Accept` header with the value + `application/json`, the response will be a JSON object. + Otherwise, the response will render the HTML template as + usual. + + The parent view must define an attribute "json_fields" that + lists the fields to be included in the JSON response. + """ + + def render_to_response( + self, context: dict[Any, Any], **response_kwargs: dict[Any, Any] + ) -> HttpResponse: + """ + Returns a JSON response if the request accepts JSON. + Otherwise, returns the default response. + """ + try: + request: HttpRequest = self.request # type: ignore[attr-defined] + except AttributeError as exc: + raise ImproperlyConfigured( + "A JSONResponseMixin must be used with a DetailView or ListView." + ) from exc + try: + json_fields = self.json_fields # type: ignore[attr-defined] + except AttributeError as exc: + raise ImproperlyConfigured( + "A JSONResponseMixin must define a json_fields attribute." + ) from exc + if "application/json" in request.META.get("HTTP_ACCEPT", ""): + obj = context.get("object") + if obj: + obj_json = {} + for field in json_fields: + obj_json[field] = getattr(obj, field) + return JsonResponse({obj.get_verbose_name(): obj_json}) + q_s = context["object_list"].values(*json_fields) + q_s_name = str(q_s.model.get_verbose_name_plural()) + return JsonResponse({q_s_name: list(q_s)}) + try: + template_response: TemplateResponse = super().render_to_response( # type: ignore[misc] + context, **response_kwargs + ) + except AttributeError as exc: + raise ImproperlyConfigured( + "A JSONResponseMixin must be used with a DetailView or ListView." + ) from exc + return template_response diff --git a/django/cantusdb_project/main_app/models/__init__.py b/django/cantusdb_project/main_app/models/__init__.py index ef755c898..5ed2a4227 100644 --- a/django/cantusdb_project/main_app/models/__init__.py +++ b/django/cantusdb_project/main_app/models/__init__.py @@ -5,11 +5,12 @@ from main_app.models.feast import Feast from main_app.models.genre import Genre from main_app.models.notation import Notation -from main_app.models.office import Office +from main_app.models.service import Service from main_app.models.provenance import Provenance from main_app.models.segment import Segment from main_app.models.sequence import Sequence from main_app.models.source import Source +from main_app.models.source_identifier import SourceIdentifier from main_app.models.institution import Institution from main_app.models.institution_identifier import InstitutionIdentifier from main_app.models.project import Project diff --git a/django/cantusdb_project/main_app/models/base_chant.py b/django/cantusdb_project/main_app/models/base_chant.py index 7885f538a..ea208a0d1 100644 --- a/django/cantusdb_project/main_app/models/base_chant.py +++ b/django/cantusdb_project/main_app/models/base_chant.py @@ -56,8 +56,8 @@ class Meta: image_link = models.URLField(blank=True, null=True) json_info = models.JSONField(null=True, blank=True) marginalia = models.CharField(max_length=63, null=True, blank=True) - office = models.ForeignKey( - "Office", on_delete=models.PROTECT, null=True, blank=True + service = models.ForeignKey( + "Service", on_delete=models.PROTECT, null=True, blank=True ) position = models.CharField(max_length=63, null=True, blank=True) diff --git a/django/cantusdb_project/main_app/models/chant.py b/django/cantusdb_project/main_app/models/chant.py index 2c5986eb0..4091a2aec 100644 --- a/django/cantusdb_project/main_app/models/chant.py +++ b/django/cantusdb_project/main_app/models/chant.py @@ -28,7 +28,7 @@ def index_components(self) -> dict: source = self.source.title if self.source else None genre = self.genre.name if self.genre else None feast = self.feast.name if self.feast else None - office = self.office.name if self.office else None + service = self.service.name if self.service else None return { "A": ( " ".join( @@ -38,7 +38,7 @@ def index_components(self) -> dict: ) ) ), - "B": (" ".join(filter(None, [genre, feast, office]))), + "B": (" ".join(filter(None, [genre, feast, service]))), } def related_chants_by_cantus_id(self) -> QuerySet: diff --git a/django/cantusdb_project/main_app/models/differentia.py b/django/cantusdb_project/main_app/models/differentia.py index 8d2fed533..af55a6eea 100644 --- a/django/cantusdb_project/main_app/models/differentia.py +++ b/django/cantusdb_project/main_app/models/differentia.py @@ -10,7 +10,7 @@ class Differentia(BaseModel): blank=True, null=True, max_length=255 ) # values like `1--k-k-l-j-k-h--4` get stored here mode = models.CharField(blank=True, null=True, max_length=255) - # differentia database stores information on mode, but we don't expect to ever need to display it + # differentiae database stores information on mode, but we don't expect to ever need to display it # saeculorum - DD stores a volpiano snippet about the "saeculorum" part of the differentia, but we don't expect to ever need to display it # amen - DD stores a volpiano snippet about the "amen" part of the differentia, but we don't expect to ever need to display it diff --git a/django/cantusdb_project/main_app/models/feast.py b/django/cantusdb_project/main_app/models/feast.py index 7caaf0041..b35f8cbcc 100644 --- a/django/cantusdb_project/main_app/models/feast.py +++ b/django/cantusdb_project/main_app/models/feast.py @@ -17,7 +17,6 @@ class Feast(BaseModel): blank=True, null=True, validators=[MinValueValidator(1), MaxValueValidator(31)] ) - # the `prefix` field can be automatically populated by running `python manage.py add_prefix` prefix = models.CharField(max_length=2, blank=True, null=True, editable=False) class Meta: diff --git a/django/cantusdb_project/main_app/models/institution.py b/django/cantusdb_project/main_app/models/institution.py index 02fd1f19e..a5d966f1b 100644 --- a/django/cantusdb_project/main_app/models/institution.py +++ b/django/cantusdb_project/main_app/models/institution.py @@ -1,29 +1,75 @@ from django.db import models +from django.db.models import CheckConstraint, Q from main_app.models import BaseModel region_help_text = """Province / State / Canton / County. Used to disambiguate cities, e.g., "London (Ontario)".""" city_help_text = """City / Town / Village / Settlement""" +private_collector_help = """Mark this institution as private collector.""" +private_collection_help = """Mark this instititution as being a private collection. This is used to identify private +collectors regardless of whether they have a RISM siglum or not. +""" class Institution(BaseModel): - name = models.CharField(max_length=255, default="s.n.") - siglum = models.CharField(max_length=32, default="XX-Nn") + class Meta: + ordering = ["country", "city", "name"] + constraints = [ + CheckConstraint( + check=~(Q(is_private_collector=True) & Q(siglum__isnull=False)), + name="siglum_and_private_not_valid", + violation_error_message="Siglum and Private Collector cannot both be specified.", + ), + CheckConstraint( + check=(Q(is_private_collector=True) | Q(siglum__isnull=False)), + name="at_least_one_of_siglum_or_private_collector", + violation_error_message="At least one of Siglum or Private Collector must be specified.", + ), + ] + + name = models.CharField(max_length=255, default="[No Name]") + siglum = models.CharField( + verbose_name="RISM Siglum", + max_length=32, + blank=True, + null=True, + help_text="Reserved for assigned RISM sigla", + ) + is_private_collector = models.BooleanField( + default=False, + help_text=private_collector_help, + ) + is_private_collection = models.BooleanField( + default=False, + help_text=private_collection_help, + ) city = models.CharField( max_length=64, blank=True, null=True, help_text=city_help_text ) region = models.CharField( max_length=64, blank=True, null=True, help_text=region_help_text ) - country = models.CharField(max_length=64, default="s.l.") + country = models.CharField(max_length=64, default="[No Country]") alternate_names = models.TextField( blank=True, null=True, help_text="Enter alternate names on separate lines." ) former_sigla = models.TextField( blank=True, null=True, help_text="Enter former sigla on separate lines." ) + private_notes = models.TextField( + blank=True, + null=True, + help_text="Notes about this institution that are not publicly visible.", + ) + migrated_identifier = models.CharField( + max_length=64, + blank=True, + null=True, + help_text="Former Cantus identifier. Should not be used for new records.", + ) def __str__(self) -> str: - sigl: str = f"({self.siglum})" if self.siglum else "" - return f"{self.name} {sigl}" + sigl: str = f" ({self.siglum})" if self.siglum else "" + city: str = f"{self.city}, " if self.city else "" + return f"{city}{self.name}{sigl}" diff --git a/django/cantusdb_project/main_app/models/project.py b/django/cantusdb_project/main_app/models/project.py index bba9ca3e0..025664262 100644 --- a/django/cantusdb_project/main_app/models/project.py +++ b/django/cantusdb_project/main_app/models/project.py @@ -13,4 +13,4 @@ class Project(BaseModel): name = models.CharField(max_length=63) def __str__(self): - return f"{self.name} ({self.id})" + return f"{self.name}" diff --git a/django/cantusdb_project/main_app/models/office.py b/django/cantusdb_project/main_app/models/service.py similarity index 93% rename from django/cantusdb_project/main_app/models/office.py rename to django/cantusdb_project/main_app/models/service.py index a4e2ff87d..34c4d119f 100644 --- a/django/cantusdb_project/main_app/models/office.py +++ b/django/cantusdb_project/main_app/models/service.py @@ -2,7 +2,7 @@ from main_app.models import BaseModel -class Office(BaseModel): +class Service(BaseModel): name = models.CharField(max_length=3) description = models.TextField() diff --git a/django/cantusdb_project/main_app/models/source.py b/django/cantusdb_project/main_app/models/source.py index 30abb038f..05fe45a03 100644 --- a/django/cantusdb_project/main_app/models/source.py +++ b/django/cantusdb_project/main_app/models/source.py @@ -27,14 +27,16 @@ class Source(BaseModel): title = models.CharField( max_length=255, + blank=True, + null=True, help_text="Full Source Identification (City, Archive, Shelf-mark)", ) # the siglum field as implemented on the old Cantus is composed of both the RISM siglum and the shelfmark # it is a human-readable ID for a source siglum = models.CharField( max_length=63, - null=False, - blank=False, + null=True, + blank=True, help_text="RISM-style siglum + Shelf-mark (e.g. GB-Ob 202).", ) holding_institution = models.ForeignKey( @@ -43,6 +45,22 @@ class Source(BaseModel): null=True, blank=True, ) + shelfmark = models.CharField( + max_length=255, + blank=False, + null=False, + help_text=( + "Primary Cantus Database identifier for the source " + "(e.g. library shelfmark, DACT ID, etc.)" + ), + default="[No Shelfmark]", + ) + name = models.CharField( + max_length=255, + blank=True, + null=True, + help_text="A colloquial or commonly-used name for the source", + ) provenance = models.ForeignKey( "Provenance", on_delete=models.PROTECT, @@ -58,12 +76,24 @@ class Source(BaseModel): null=True, help_text="More exact indication of the provenance (if necessary)", ) + + class SourceCompletenessChoices(models.IntegerChoices): + FULL_SOURCE = 1, "Complete source" + FRAGMENT = 2, "Fragment" + RECONSTRUCTION = 3, "Reconstruction" + + source_completeness = models.IntegerField( + choices=SourceCompletenessChoices.choices, + default=SourceCompletenessChoices.FULL_SOURCE, + verbose_name="Complete Source/Fragment", + ) + full_source = models.BooleanField(blank=True, null=True) date = models.CharField( blank=True, null=True, max_length=63, - help_text='Date of the source (e.g. "1200s", "1300-1350", etc.)', + help_text='Date of the source, if known (e.g. "1541")', ) century = models.ManyToManyField("Century", related_name="sources", blank=True) notation = models.ManyToManyField("Notation", related_name="sources", blank=True) @@ -98,6 +128,9 @@ class Source(BaseModel): segment = models.ForeignKey( "Segment", on_delete=models.PROTECT, blank=True, null=True ) + segment_m2m = models.ManyToManyField( + "Segment", blank=True, related_name="sources", verbose_name="Segments" + ) source_status = models.CharField( blank=True, null=True, choices=source_status_choices, max_length=255 ) @@ -124,6 +157,16 @@ class Source(BaseModel): blank=False, null=False, default=False ) + class ProductionMethodChoices(models.IntegerChoices): + MANUSCRIPT = 1, "Manuscript" + PRINTED = 2, "Printed" + + production_method = models.IntegerField( + default=ProductionMethodChoices.MANUSCRIPT, + choices=ProductionMethodChoices.choices, + verbose_name="Manuscript/Printed", + ) + # number_of_chants and number_of_melodies are used for rendering the source-list page (perhaps among other places) # they are automatically recalculated in main_app.signals.update_source_chant_count and # main_app.signals.update_source_melody_count every time a chant or sequence is saved or deleted @@ -131,8 +174,7 @@ class Source(BaseModel): number_of_melodies = models.IntegerField(blank=True, null=True) def __str__(self): - string = "[{s}] {t} ({i})".format(s=self.siglum, t=self.title, i=self.id) - return string + return self.heading def save(self, *args, **kwargs): # when creating a source, assign it to "CANTUS Database" segment by default @@ -140,3 +182,41 @@ def save(self, *args, **kwargs): cantus_db_segment = Segment.objects.get(name="CANTUS Database") self.segment = cantus_db_segment super().save(*args, **kwargs) + + @property + def heading(self) -> str: + title = [] + if holdinst := self.holding_institution: + city = f"{holdinst.city}," if holdinst.city else "" + title.append(city) + title.append(f"{holdinst.name},") + else: + title.append("Cantus") + + title.append(self.shelfmark) + + if self.source_completeness == self.SourceCompletenessChoices.FRAGMENT: + title.append("(fragment)") + + if self.name: + title.append(f'("{self.name}")') + + return " ".join(title) + + @property + def short_heading(self) -> str: + title = [] + if holdinst := self.holding_institution: + if holdinst.siglum and holdinst.siglum != "XX-NN": + title.append(f"{holdinst.siglum}") + else: + title.append("Cantus") + else: + title.append("Cantus") + + title.append(self.shelfmark) + + if self.source_completeness == self.SourceCompletenessChoices.FRAGMENT: + title.append("(fragment)") + + return " ".join(title) diff --git a/django/cantusdb_project/main_app/models/source_identifier.py b/django/cantusdb_project/main_app/models/source_identifier.py new file mode 100644 index 000000000..85ccb5f71 --- /dev/null +++ b/django/cantusdb_project/main_app/models/source_identifier.py @@ -0,0 +1,33 @@ +from django.db import models + +class SourceIdentifier(models.Model): + class Meta: + verbose_name = "Source Identifier" + ordering = ('type',) + + OTHER = 1 + OLIM = 2 + ALTN = 3 + RISM_ONLINE = 4 + + IDENTIFIER_TYPES = ( + (OTHER, 'Other catalogues'), + (OLIM, 'olim (Former shelfmark)'), + (ALTN, 'Alternative names'), + (RISM_ONLINE, "RISM Online") + ) + + identifier = models.CharField(max_length=255) + type = models.IntegerField(choices=IDENTIFIER_TYPES) + note = models.TextField(blank=True, null=True) + source = models.ForeignKey("Source", + related_name="identifiers", + on_delete=models.CASCADE) + + def __str__(self): + return f"{self.identifier}" + + @property + def identifier_type(self): + d = dict(self.IDENTIFIER_TYPES) + return d[self.type] 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/templates/400.html b/django/cantusdb_project/main_app/templates/400.html index 3ee52993d..2d12bcd82 100644 --- a/django/cantusdb_project/main_app/templates/400.html +++ b/django/cantusdb_project/main_app/templates/400.html @@ -1,6 +1,5 @@ {% load static %} -
@@ -8,7 +7,7 @@ - +