diff --git a/.gitignore b/.gitignore index 00c8e61a..1e7ee450 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ dist/* .idea # vim swap files .*.swp +.vscode # Unit test / coverage reports htmlcov/* @@ -52,7 +53,7 @@ siteedit2/settings/local_test.py siteedit2/settings/gunweb.py # */migrations -static/core/scss/ +static core/static/core/scss/main.css core/management/commands/playground.py -upload/ \ No newline at end of file +upload/ diff --git a/catalogue/migrations/__init__.py b/catalogue/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/constant.py b/core/constant.py index 942ae27a..788f40a9 100644 --- a/core/constant.py +++ b/core/constant.py @@ -105,6 +105,9 @@ PM_CHANGE_ORGTYPE = 'core.change_cofkunionorgtype' PM_CHANGE_COLLECTWORK = 'uploader.change_cofkcollectwork' PM_CHANGE_LANGUAGE = 'core.change_iso639languagecode' +PM_CHANGE_SUGGESTIONS = 'suggestions.change_cofksuggestions' +PM_VIEW_SUGGESTIONS = 'suggestions.view_cofksuggestions' +PM_ADD_SUGGESTIONS = 'suggestions.add_cofksuggestions' PM_CHANGE_USER = 'login.change_cofkuser' PM_CHANGE_COMMENT = 'core.change_cofkunioncomment' diff --git a/core/management/commands/add_groups_and_permissions.py b/core/management/commands/add_groups_and_permissions.py index b59ab04d..1f7e0a8b 100644 --- a/core/management/commands/add_groups_and_permissions.py +++ b/core/management/commands/add_groups_and_permissions.py @@ -28,7 +28,9 @@ def handle(self, *args, **kwargs): constant.PM_CHANGE_SUBJECT, constant.PM_CHANGE_ORGTYPE, constant.PM_CHANGE_COLLECTWORK, - constant.PM_CHANGE_LANGUAGE + constant.PM_CHANGE_LANGUAGE, + constant.PM_ADD_SUGGESTIONS, + constant.PM_VIEW_SUGGESTIONS, ], } group_permissions_dict[constant.ROLE_SUPER] = group_permissions_dict[constant.ROLE_EDITOR] + [ @@ -43,7 +45,9 @@ def handle(self, *args, **kwargs): ] group_permissions_dict[constant.ROLE_CONTRIBUTING_EDITOR] = [ constant.PM_CHANGE_WORK, - constant.PM_VIEW_LOOKUPCAT + constant.PM_VIEW_LOOKUPCAT, + constant.PM_VIEW_SUGGESTIONS, + constant.PM_CHANGE_SUGGESTIONS, ] # add permissions to groups diff --git a/core/migrations/0012_alter_iso639languagecode_language_id.py b/core/migrations/0012_alter_iso639languagecode_language_id.py new file mode 100644 index 00000000..c506f933 --- /dev/null +++ b/core/migrations/0012_alter_iso639languagecode_language_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.6 on 2025-02-26 03:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0011_cofklookupcatalogue_owner'), + ] + + operations = [ + migrations.AlterField( + model_name='iso639languagecode', + name='language_id', + field=models.IntegerField(unique=True), + ), + ] diff --git a/core/static/core/scss/components/alert.scss b/core/static/core/scss/components/alert.scss new file mode 100644 index 00000000..ac546670 --- /dev/null +++ b/core/static/core/scss/components/alert.scss @@ -0,0 +1,27 @@ +.alert { + padding: 20px; + background-color: #f44336; + color: white; + opacity: 1; + transition: opacity 0.6s; + margin-bottom: 15px; +} + +.alert.success {background-color: #04AA6D;} +.alert.info {background-color: #2196F3;} +.alert.warning {background-color: #ff9800;} + +.closebtn { + margin-left: 15px; + color: white; + font-weight: bold; + float: right; + font-size: 22px; + line-height: 20px; + cursor: pointer; + transition: 0.3s; +} + +.closebtn:hover { + color: black; +} \ No newline at end of file diff --git a/core/static/core/scss/emlo-common.scss b/core/static/core/scss/emlo-common.scss index 602bafc0..f5951e9c 100644 --- a/core/static/core/scss/emlo-common.scss +++ b/core/static/core/scss/emlo-common.scss @@ -461,5 +461,31 @@ $del-red: #800000; font-weight: bold; } +.right { + float: right; + margin-right: 20px; +} + +.padding-right { + padding-right: 10px; +} + +table.suggestion thead tr th { + background-color: lightgray; + font-weight: bold; +} + +table.suggestion tbody tr th { + width: 30%; +} +.suggestion-buttons { + margin-bottom: 20px; +} +.btn-text-width { + width: fit-content; +} +input.btn-text-width { + padding: 0px 5px; +} \ No newline at end of file diff --git a/core/static/core/scss/main.scss b/core/static/core/scss/main.scss index 318908b7..137f1f2c 100644 --- a/core/static/core/scss/main.scss +++ b/core/static/core/scss/main.scss @@ -26,6 +26,7 @@ @import 'components/upload.scss'; @import 'components/lookup.scss'; @import 'components/dashboard.scss'; +@import 'components/alert.scss'; @import "page-header"; @import "page-footer"; diff --git a/core/templates/core/component/alert.html b/core/templates/core/component/alert.html new file mode 100644 index 00000000..9b3bca36 --- /dev/null +++ b/core/templates/core/component/alert.html @@ -0,0 +1,28 @@ + + +{% if messages %} + {% for message in messages %} +
You can also provide suggestions:
+ + {% endif %} {% if constant.PM_TRIGGER_EXPORTER|can_show_for_perm:perms %} diff --git a/person/migrations/0011_alter_cofkunionperson_iperson_id.py b/person/migrations/0011_alter_cofkunionperson_iperson_id.py new file mode 100644 index 00000000..f3428a8d --- /dev/null +++ b/person/migrations/0011_alter_cofkunionperson_iperson_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.6 on 2025-02-26 03:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('person', '0010_searchresultperson'), + ] + + operations = [ + migrations.AlterField( + model_name='cofkunionperson', + name='iperson_id', + field=models.IntegerField(unique=True), + ), + ] diff --git a/person/person_suggestion.py b/person/person_suggestion.py new file mode 100644 index 00000000..6db61145 --- /dev/null +++ b/person/person_suggestion.py @@ -0,0 +1,49 @@ +import dateutil.parser +import logging +from dateutil.parser import ParserError +from django.core.exceptions import ObjectDoesNotExist +from suggestions.models import CofkSuggestions +from suggestions import utils as sug_utils +from person import person_suggestion_fields + +log = logging.getLogger(__name__) + +class PersonSuggestion: + def __init__(self, suggestion_id): + self.suggestion_id = suggestion_id + + def initial_form_values(self): + form_values = {} + try: + sug = CofkSuggestions.objects.get(pk=self.suggestion_id) + if sug.suggestion_status == "Resolved": + log.debug(f"{sug.suggestion_type} suggestion id {sug.suggestion_id} is already in status {sug.suggestion_status}") + return form_values + except ObjectDoesNotExist: + return form_values + parsed_suggestion = sug.parsed_suggestion + for field,sug_value in parsed_suggestion.items(): + form_field = sug_utils.suggestion_fields_dict(person_suggestion_fields.suggestion_fields_map()).get(field, None) + if form_field and sug_value: + match field: + case 'Primary Name' | 'Alternative names' | 'Roles / titles': + form_values[form_field] = sug_value + case 'Gender': + if sug_value.lower() == "male" or sug_value.lower() == "m": + form_values[form_field] = "M" + elif sug_value.lower() == "female" or sug_value.lower() == "f": + form_values[form_field] = "F" + case "Date of birth" | "Date of death" | "Date when flourished": + try: + dt = dateutil.parser.parse(sug_value) + if dt.year: + form_values[f"{form_field}_year"] = dt.year + if dt.month: + form_values[f"{form_field}_month"] = (dt.month, dt.strftime("%b")) + if dt.day: + form_values[f"{form_field}_day"] = dt.day + except ParserError: + pass + if form_values: + form_values['editors_notes'] = f"From suggestion {sug.suggestion_id}\n{sug.suggestion_suggestion}" + return form_values diff --git a/person/person_suggestion_fields.py b/person/person_suggestion_fields.py new file mode 100644 index 00000000..1e1e083c --- /dev/null +++ b/person/person_suggestion_fields.py @@ -0,0 +1,10 @@ +def suggestion_fields_map(): + return ( + ('Primary Name', 'foaf_name'), + ('Gender', 'gender'), + ('Alternative names', 'skos_altlabel'), + ('Roles / titles', 'person_aliases'), + ("Date of birth", 'date_of_birth'), + ("Date of death", 'date_of_death'), + ("Date when flourished", 'flourished') + ) diff --git a/person/views.py b/person/views.py index 91796a10..25afefd0 100644 --- a/person/views.py +++ b/person/views.py @@ -37,7 +37,9 @@ ActivePersonRecrefAdapter, PassivePersonRecrefAdapter, PersonImageRecrefAdapter, PersonLocRecrefAdapter from person.subqueries import create_sql_count_work_by_person from person.view_components import PersonFormDescriptor +from person.person_suggestion import PersonSuggestion from work.forms import AuthorRelationChoices, AddresseeRelationChoices +from suggestions import views as sug_view if TYPE_CHECKING: from core.helper.view_serv import MergeChoiceContext @@ -60,6 +62,9 @@ def resp_form_page(self, request, form): } | create_context_is_org_form(form.initial.get('is_organisation'))) def resp_after_saved(self, request, form, new_instance): + suggestion_id = request.GET.get('from_suggestion', None) + if suggestion_id: + sug_view.save_with_related_info(suggestion_id, new_instance.iperson_id) return redirect('person:full_form', new_instance.iperson_id) @property @@ -72,11 +77,14 @@ def on_form_changed(self, request, form) -> NoReturn: def get(self, request, *args, **kwargs): is_org_form = request and request.GET.get('person_form_type') == 'org' + initial = {} if is_org_form: - initial = {'is_organisation': TRUE_CHAR, } - else: - initial = {} - + initial['is_organisation'] = TRUE_CHAR + if request and request.GET.get('from_suggestion', ''): + from_suggestion = request.GET.get('from_suggestion') + sug_values = PersonSuggestion(from_suggestion).initial_form_values() + if sug_values: + initial.update(sug_values) form = self.form_factory(initial=initial) return self.resp_form_page(request, form) @@ -124,7 +132,7 @@ def load_data(self, pk, *args, request_data=None, request=None, **kwargs): | self.death_loc_handler.create_init_dict(self.person) ) self.person_form = PersonForm(request_data or None, instance=self.person, initial=initial_dict) - self.person_form.base_fields['organisation_type'].reload_choices() + self.person_form.fields['organisation_type'].reload_choices() self.org_handler = MultiRecrefAdapterHandler( request_data, name='organisation', diff --git a/publication/publication_suggestion.py b/publication/publication_suggestion.py new file mode 100644 index 00000000..78a51a3c --- /dev/null +++ b/publication/publication_suggestion.py @@ -0,0 +1,29 @@ +import dateutil.parser +import logging +from dateutil.parser import ParserError +from django.core.exceptions import ObjectDoesNotExist +from suggestions.models import CofkSuggestions +from suggestions import utils as sug_utils +from publication import publication_suggestion_fields + +log = logging.getLogger(__name__) + +class PublicationSuggestion: + def __init__(self, suggestion_id): + self.suggestion_id = suggestion_id + + def initial_form_values(self): + form_values = {} + try: + sug = CofkSuggestions.objects.get(pk=self.suggestion_id) + if sug.suggestion_status == "Resolved": # Close backdoor to resolve button + log.debug(f"{sug.suggestion_type} suggestion id {sug.suggestion_id} is already in status {sug.suggestion_status}") + return form_values + except ObjectDoesNotExist: + return form_values + parsed_suggestion = sug.parsed_suggestion + for field,sug_value in parsed_suggestion.items(): + form_field = sug_utils.suggestion_fields_dict(publication_suggestion_fields.suggestion_fields_map()).get(field, None) + if form_field and sug_value: + form_values[form_field] = sug_value + return form_values diff --git a/publication/publication_suggestion_fields.py b/publication/publication_suggestion_fields.py new file mode 100644 index 00000000..1e284a7a --- /dev/null +++ b/publication/publication_suggestion_fields.py @@ -0,0 +1,5 @@ +def suggestion_fields_map(): + return( + ("Publication details", "publication_details"), + ("Abbreviation", "abbrev"), + ) diff --git a/publication/views.py b/publication/views.py index da125038..fd09ac4d 100644 --- a/publication/views.py +++ b/publication/views.py @@ -14,6 +14,8 @@ from core.helper.view_serv import CommonInitFormViewTemplate, DefaultSearchView, DeleteConfirmView from publication.forms import PublicationForm, GeneralSearchFieldset from publication.models import CofkUnionPublication +from publication.publication_suggestion import PublicationSuggestion +from suggestions import views as sug_view class PubSearchView(LoginRequiredMixin, DefaultSearchView): @@ -73,12 +75,27 @@ def resp_form_page(self, request, form): return render(request, 'publication/init_form.html', {'pub_form': form}) def resp_after_saved(self, request, form, new_instance): + suggestion_id = request.GET.get('from_suggestion', None) + if suggestion_id: + sug_view.save_with_related_info(suggestion_id, new_instance.publication_id) return redirect('publication:search') @property def form_factory(self) -> Callable[..., ModelForm]: return PublicationForm + def get(self, request, *args, **kwargs): + is_org_form = request and request.GET.get('publication_form_type') == 'org' + initial = {} + if is_org_form: + initial['is_organisation'] = TRUE_CHAR + if request and request.GET.get('from_suggestion', ''): + from_suggestion = request.GET.get('from_suggestion') + sug_values = PublicationSuggestion(from_suggestion).initial_form_values() + if sug_values: + initial.update(sug_values) + form = self.form_factory(initial=initial) + return self.resp_form_page(request, form) @login_required def full_form(request, pk): diff --git a/pyproject.toml b/pyproject.toml index 73177d23..421d3439 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ 'django-sass-processor~=1.2.1', 'libsass~=0.21.0', 'Pillow~=9.2.0', + 'python-dateutil', 'requests~=2.28.1', 'gunicorn~=20.1.0', 'beautifulsoup4~=4.11.2', diff --git a/siteedit2/settings/base.py b/siteedit2/settings/base.py index 4907383c..b742404f 100644 --- a/siteedit2/settings/base.py +++ b/siteedit2/settings/base.py @@ -56,7 +56,8 @@ 'publication', 'audit', 'list', - 'catalogue' + 'catalogue', + 'suggestions' ] MIDDLEWARE = [ diff --git a/siteedit2/urls.py b/siteedit2/urls.py index a6568119..edd4cbab 100644 --- a/siteedit2/urls.py +++ b/siteedit2/urls.py @@ -42,6 +42,7 @@ path('user/', include('core.user_urls')), path('misc/', include('core.misc_urls')), path('catalogue/', include('catalogue.urls')), + path('suggestions/', include('suggestions.urls')), ] for url_path, file_path in [ diff --git a/suggestions/__init__.py b/suggestions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/suggestions/admin.py b/suggestions/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/suggestions/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/suggestions/apps.py b/suggestions/apps.py new file mode 100644 index 00000000..7cfad537 --- /dev/null +++ b/suggestions/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SuggestionsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'suggestions' diff --git a/suggestions/forms.py b/suggestions/forms.py new file mode 100644 index 00000000..a798d4d9 --- /dev/null +++ b/suggestions/forms.py @@ -0,0 +1,25 @@ +from django import forms +from .models import CofkSuggestions +from core.helper import form_serv + +# Form to getting suggestion text +class SuggestionForm(forms.Form): + suggestion_text = forms.CharField(max_length = 4096, widget=forms.Textarea(attrs={'rows':30})) + + class Meta: + model = CofkSuggestions + fields = ['suggestion_text'] + +# Form for filtering displayed suggestions of the given user +class SuggestionFilterForm(forms.Form): + person = forms.BooleanField(required=True, initial=True, label="Person") + location = forms.BooleanField(required=True, initial=True, label="Location") + publication = forms.BooleanField(required=True, initial=True, label="Institution") + institution = forms.BooleanField(required=True, initial=True, label="Publication") + showNew = forms.BooleanField(required=True, initial=True, label="Show New") + showExisting = forms.BooleanField(required=True, initial=True, label="Show Existing") + + class Meta: + fields = [ 'person', 'location', 'publication', 'institution', + 'showNew', 'showExisting'] + \ No newline at end of file diff --git a/suggestions/migrations/0001_initial.py b/suggestions/migrations/0001_initial.py new file mode 100644 index 00000000..f8d9fd3c --- /dev/null +++ b/suggestions/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 4.0.6 on 2025-02-27 10:37 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CofkSuggestions', + fields=[ + ('suggestion_id', models.AutoField(primary_key=True, serialize=False)), + ('suggestion_new', models.BooleanField(default=True)), + ('suggestion_type', models.CharField(max_length=200)), + ('suggestion_suggestion', models.TextField()), + ('suggestion_status', models.CharField(default='New', max_length=256)), + ('suggestion_created_at', models.DateTimeField(auto_now_add=True)), + ('suggestion_updated_at', models.DateTimeField(auto_now=True)), + ('suggestion_resolved_at', models.DateTimeField(blank=True, null=True)), + ('suggestion_related_record_int', models.IntegerField(blank=True, null=True)), + ('suggestion_author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='suggestions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'cofk_union_suggestions', + }, + ), + ] diff --git a/suggestions/migrations/__init__.py b/suggestions/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/suggestions/models.py b/suggestions/models.py new file mode 100644 index 00000000..20915596 --- /dev/null +++ b/suggestions/models.py @@ -0,0 +1,76 @@ +from django.db import models +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +import datetime +from suggestions import utils as sug_utils +from person import person_suggestion_fields +from location import location_suggestion_fields +from institution import institution_suggestion_fields +from publication import publication_suggestion_fields + +class CofkSuggestions(models.Model): + suggestion_id = models.AutoField(primary_key=True) + suggestion_new = models.BooleanField(default=True) + suggestion_type = models.CharField(max_length=200) + suggestion_suggestion = models.TextField() + suggestion_status = models.CharField(max_length=256, default="New") + + # Automatic fields + suggestion_created_at = models.DateTimeField(auto_now_add=True) + suggestion_updated_at = models.DateTimeField(auto_now=True) + suggestion_resolved_at = models.DateTimeField(null=True, blank=True) + suggestion_author = models.ForeignKey('login.CofkUser', on_delete=models.SET_NULL, + related_name='suggestions', null=True, blank=True + ) + + # Default = -1 means there is no related record for this suggestion + suggestion_related_record_int = models.IntegerField(null=True, blank=True) + + class Meta: + db_table = 'cofk_union_suggestions' + + @property + def is_updated(self): + # Updated date-time is always a few microseconds ahead of created date-time + created = self.suggestion_created_at + datetime.timedelta(seconds=1) + updated = self.suggestion_updated_at + return updated > created + + def fields(self): + match self.suggestion_type: + case "Person": + return sug_utils.suggestion_fields(person_suggestion_fields.suggestion_fields_map()) + case "Location": + return sug_utils.suggestion_fields(location_suggestion_fields.suggestion_fields_map()) + case "Institution": + return sug_utils.suggestion_fields(institution_suggestion_fields.suggestion_fields_map()) + case "Publication": + return sug_utils.suggestion_fields(publication_suggestion_fields.suggestion_fields_map()) + + def new_suggestion_text(self): + return ":\n\n".join(self.fields()) + ":" + + @property + def parsed_suggestion(self): + keys = self.fields() + key1 = "" + key2 = "" + text_key = "" + suggestion_hash = {} + for line in self.suggestion_suggestion.split("\n"): + keyword = False + for key in keys: + if line.startswith(key): + if key1 == "": + key1 = key + else: + key1 = key2 + key2 = key + suggestion_hash[key1] = text_key.strip() + text_key = line.split(key)[1].strip().lstrip(":") + keyword = True + break + if not keyword: + text_key = text_key + " " + line.strip() + suggestion_hash[key2] = text_key.strip() + return suggestion_hash diff --git a/suggestions/templates/component/record_info_body.html b/suggestions/templates/component/record_info_body.html new file mode 100644 index 00000000..7efb2532 --- /dev/null +++ b/suggestions/templates/component/record_info_body.html @@ -0,0 +1,30 @@ +| + {% include "component/record_info_header.html" %} + | +
|---|
+ Suggestion ID: {{ record.suggestion_id}}+ {% include "component/suggestion_buttons.html" with record=record %} + |
+
|---|