diff --git a/docs/md/contact-messages.md b/docs/md/contact-messages.md new file mode 100644 index 0000000000..299d339634 --- /dev/null +++ b/docs/md/contact-messages.md @@ -0,0 +1,19 @@ +# Contact messages + +Janeway provides a public contact form for journal and press sites. Contact people can be set for each. + +## Data model + +Contact messages are stored via `LogEntry` objects with `types="Contact Message"`. The `target`, which would otherwise be something like an article for workflow log entries, is the journal or press site that the contact message was submitted to. + +## Recipient access to contact messages + +The primary way users get contact messages is directly as emails to their inbox outside of Janeway. + +However, they can also access **Contact Messages** via the manager. This view only shows the user messages sent to them, including at the staff level. This may be counter-intuitive in comparison with similar views, because most of the time, an object belonging to journal (like an article, or a task) is viewable by all editors. However, this behavior is needed for privacy, because many users will expect their message to be private to the person they select on the form. At the staff level, staff members cannot see contact messages sent to journal editors, for similar reasons. + +## What happens on deletion + +When a `ContactPerson` is removed, the contact messages sent to that person should still be viewable, because they are recorded as `LogEntry` objects, where the recipient’s email is saved independently of the `ContactPerson` or `Account`. The message will still appear on **Contact Messages**. + +When an `Account` is removed, the `ContactPerson` is also deleted, but again, the contact messages should still exist. diff --git a/docs/md/sequence-fields.md b/docs/md/sequence-fields.md new file mode 100644 index 0000000000..9c8818b40f --- /dev/null +++ b/docs/md/sequence-fields.md @@ -0,0 +1,11 @@ +# Sequence fields + +We use several patterns across Janeway to let users set the sequence of things, most commonly with a `PositiveIntegerField`. + +Because we sometimes expose this number to end users in form inputs, it is worth thinking about the usability of the default value. The number 1 is low enough to be easy for end users to manipulate. Zero is sometimes most convenient from a programming perspective, but avoid it if possible, since it can be counter-intuitive for non-programmers. + +```py +sequence = models.PositiveIntegerField(default=1) +``` + +Of course, it is best if end users do not have to deal with this number at all. User interfaces should use accessible buttons that move things up or down in the sequence. This allows us to write an algorithm to check that multiple things have not been given the same sequence, and it keeps the user from having to recall off-screen information about the order of other items whilst performing an action. diff --git a/docs/md/task-and-email-logs.md b/docs/md/task-and-email-logs.md new file mode 100644 index 0000000000..bf91ced80a --- /dev/null +++ b/docs/md/task-and-email-logs.md @@ -0,0 +1,26 @@ +# Task and email logs + +Janeway creates a number of logs (`utils.LogEntry`) for actions that happen during the workflow. Many these actions trigger an email to be sent. The logging is thus managed by the email sending process. A log is created created that records the type of action taken in Janeway as well as details of the email. + +## The notification system + +The notification system, where emails and logs are created, lives in `src/utils`, and uses hooks to provide plugin functionality: + +``` +src/utils/notify.py +src/utils/notify_helpers.py +src/utils/notify_plugins/notify_email.py +src/utils/notify_plugins/email_log.py +src/utils/notify_plugins/notify_slack.py +``` + + + +## Non-workflow log entries + +There are email messages stored as log entries that are unrelated to the workflow. Contact messages are the first case of this kind of non-workflow log entry. diff --git a/docs/md/testing-views.md b/docs/md/testing-views.md index d1b625d01d..b87715ff72 100644 --- a/docs/md/testing-views.md +++ b/docs/md/testing-views.md @@ -83,3 +83,13 @@ from utils.shared import clear_cache clear_cache() ``` + +## Testing views with captchas + +Captchas are inserted depending on the `CAPTCHA_TYPE` Django setting, and forms that require them will come up invalid in tests. Disable the captcha during a test run by overriding the setting with an empty string: + +``` + @override_settings(CAPTCHA_TYPE="") + def test_posting_view_with_captcha(self): + ... +``` diff --git a/requirements.txt b/requirements.txt index f1e07b439b..27afe219ec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -65,3 +65,4 @@ ua-parser==0.16.1 # See #3736 urllib3<2 user-agents==2.2.0 +docutils==0.21.2 diff --git a/src/core/admin.py b/src/core/admin.py index 740e7ae5ea..c25296e36f 100755 --- a/src/core/admin.py +++ b/src/core/admin.py @@ -131,6 +131,7 @@ class AccountAdmin(UserAdmin): admin_utils.RepositoryRoleInline, admin_utils.EditorialGroupMemberInline, admin_utils.StaffGroupMemberInline, + admin_utils.ContactPersonInline, admin_utils.PasswordResetInline, ] @@ -558,36 +559,26 @@ def _journal(self, obj): return obj.group.journal if obj else "" -class ContactsAdmin(admin.ModelAdmin): - list_display = ("name", "email", "role", "object", "sequence") +class ContactPersonAdmin(admin.ModelAdmin): + list_display = ("_name", "_email", "role", "object", "sequence") list_filter = ( admin_utils.GenericRelationJournalFilter, admin_utils.GenericRelationPressFilter, ) - search_fields = ("name", "email", "role") - - -class ContactAdmin(admin.ModelAdmin): - list_display = ( - "subject", - "sender", - "recipient", - "client_ip", - "date_sent", - "object", - ) - list_filter = ( - admin_utils.GenericRelationJournalFilter, - admin_utils.GenericRelationPressFilter, - "date_sent", - "recipient", - ) search_fields = ( - "subject", - "sender", - "recipient", + "account__first_name", + "account__middle_name", + "account__last_name", + "account__email", + "role", ) - date_hierarchy = "date_sent" + raw_id_fields = ("account",) + + def _name(self, obj): + return obj.account.full_name() if obj and obj.account else "" + + def _email(self, obj): + return obj.account.email if obj and obj.account else "" class DomainAliasAdmin(admin.ModelAdmin): @@ -766,8 +757,7 @@ def _person(self, obj): (models.Workflow, WorkflowAdmin), (models.WorkflowLog, WorkflowLogAdmin), (models.LoginAttempt, LoginAttemptAdmin), - (models.Contacts, ContactsAdmin), - (models.Contact, ContactAdmin), + (models.ContactPerson, ContactPersonAdmin), (models.AccessRequest, AccessRequestAdmin), (models.Organization, OrganizationAdmin), (models.OrganizationName, OrganizationNameAdmin), diff --git a/src/core/forms/__init__.py b/src/core/forms/__init__.py index c7bf9a113e..a9843dbc42 100644 --- a/src/core/forms/__init__.py +++ b/src/core/forms/__init__.py @@ -20,7 +20,8 @@ GetResetTokenForm, JournalArticleForm, JournalAttributeForm, - JournalContactForm, + ContactMessageForm, + ContactPersonForm, JournalImageForm, JournalStylingForm, JournalSubmissionForm, diff --git a/src/core/forms/forms.py b/src/core/forms/forms.py index 2063fba894..fae8172cd2 100755 --- a/src/core/forms/forms.py +++ b/src/core/forms/forms.py @@ -6,6 +6,7 @@ import uuid import json import os +import warnings from django import forms from django.db.models import Q @@ -33,6 +34,7 @@ YesNoRadio, ) from utils.logger import get_logger +from utils.models import ACTOR_EMAIL_MAX_LENGTH from submission import models as submission_models logger = get_logger(__name__) @@ -85,18 +87,16 @@ def clean(self): return cleaned_data -class JournalContactForm(JanewayTranslationModelForm): +class ContactPersonForm(JanewayTranslationModelForm): def __init__(self, *args, **kwargs): next_sequence = kwargs.pop("next_sequence", None) - super(JournalContactForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if next_sequence: self.fields["sequence"].initial = next_sequence class Meta: - model = models.Contacts + model = models.ContactPerson fields = ( - "name", - "email", "role", "sequence", ) @@ -106,6 +106,37 @@ class Meta: ) +class ContactMessageForm(CaptchaForm): + contact_person = forms.TypedChoiceField( + label=_("Who would you like to contact?"), + ) + sender = forms.EmailField( + max_length=ACTOR_EMAIL_MAX_LENGTH, + label=_("Your contact email address"), + ) + subject = forms.CharField(max_length=300, label=_("Subject")) + body = JanewayBleachFormField(label=_("Your message")) + + def __init__(self, *args, **kwargs): + subject = kwargs.pop("subject", "") + contact_person = kwargs.pop("contact_person", None) + contact_people = kwargs.pop("contact_people", []) + super().__init__(*args, **kwargs) + self.fields["contact_person"].choices = [ + (person.pk, person.account.full_name()) for person in contact_people + ] + self.fields["subject"].initial = subject + + if contact_person: + self.fields["contact_person"].initial = contact_person.pk + + +class JournalContactForm(ContactPersonForm): + def __init__(self, *args, **kwargs): + warnings.warn("Use ContactPersonForm instead.") + super().__init__(*args, **kwargs) + + class EditorialGroupForm(JanewayTranslationModelForm): def __init__(self, *args, **kwargs): next_sequence = kwargs.pop("next_sequence", None) diff --git a/src/core/include_urls.py b/src/core/include_urls.py index 165c320da9..2b32571f34 100644 --- a/src/core/include_urls.py +++ b/src/core/include_urls.py @@ -12,7 +12,7 @@ from journal import urls as journal_urls from core import views as core_views, plugin_loader -from utils import notify +from utils import notify, views as utils_views from press import views as press_views from cms import views as cms_views from submission import views as submission_views @@ -120,6 +120,16 @@ press_views.IdentifierManager.as_view(), name="press_identifier_manager", ), + re_path( + r"^press/contact/$", + press_views.contact, + name="press_contact", + ), + re_path( + "press/contact/recipient/(?P\d+)/?", + press_views.contact, + name="press_contact_with_recipient", + ), # Notes re_path( r"^article/(?P\d+)/note/(?P\d+)/delete/$", @@ -256,22 +266,52 @@ core_views.article_image_edit, name="core_article_image_edit", ), - # Journal Contacts - re_path(r"^manager/contacts/$", core_views.contacts, name="core_journal_contacts"), + # Contact People re_path( - r"^manager/contacts/add/$", - core_views.edit_contacts, - name="core_new_journal_contact", + r"^manager/contacts/$", + core_views.contact_people, + name="core_contact_people", ), re_path( - r"^manager/contacts/(?P\d+)/$", - core_views.edit_contacts, - name="core_journal_contact", + r"^manager/contacts/order/$", + core_views.contact_people_reorder, + name="core_contact_people_reorder", ), re_path( - r"^manager/contacts/order/$", - core_views.contacts_order, - name="core_journal_contacts_order", + r"^manager/contacts/search/$", + core_views.PotentialContactListView.as_view(), + name="core_contact_person_search", + ), + re_path( + r"^manager/contacts/add/(?P\d+)/$", + core_views.contact_person_create, + name="core_contact_person_create", + ), + re_path( + r"^manager/contacts/(?P\d+)/$", + core_views.contact_person_update, + name="core_contact_person_update", + ), + re_path( + r"^manager/contacts/(?P\d+)/delete/$", + core_views.contact_person_delete, + name="core_contact_person_delete", + ), + # Contact messages + re_path( + r"^manager/contact-messages/$", + utils_views.ContactMessageListView.as_view(), + name="core_contact_messages", + ), + re_path( + r"^manager/contact-messages/(?P\d+)/$", + utils_views.contact_message, + name="core_contact_message", + ), + re_path( + r"^manager/contact-messages/(?P\d+)/delete/$", + utils_views.contact_message_delete, + name="core_contact_message_delete", ), # Editorial Team re_path( diff --git a/src/core/janeway_global_settings.py b/src/core/janeway_global_settings.py index 0343c2f73f..f0989ef1ac 100755 --- a/src/core/janeway_global_settings.py +++ b/src/core/janeway_global_settings.py @@ -52,6 +52,7 @@ INSTALLED_APPS = [ "modeltranslation", + "django.contrib.admindocs", "apps.JanewayAdminConfig", "django.contrib.auth", "django.contrib.sessions", diff --git a/src/core/logic.py b/src/core/logic.py index 85479724e1..4c52e471a1 100755 --- a/src/core/logic.py +++ b/src/core/logic.py @@ -16,11 +16,12 @@ from django.conf import settings from django.contrib.auth import logout from django.contrib import messages +from django.contrib.contenttypes.models import ContentType from django.template.loader import get_template from django.db.models import Q from django.http import JsonResponse, QueryDict from django.forms.models import model_to_dict -from django.shortcuts import reverse +from django.shortcuts import reverse, get_object_or_404 from django.utils import timezone from django.utils.translation import get_language, gettext_lazy as _ @@ -1257,3 +1258,72 @@ def create_organization_name(request): % {"organization": organization_name}, ) return organization_name + + +def get_contact_form(request, contact_person_id): + if contact_person_id: + contact_person = get_object_or_404( + models.ContactPerson, + pk=contact_person_id, + content_type=request.model_content_type, + object_id=request.site_type.pk, + ) + else: + contact_person = None + + subject = request.GET.get("subject", "") + contact_people = request.site_type.contact_people + + if request.method == "POST": + contact_form = forms.ContactMessageForm( + request.POST, + contact_people=contact_people, + ) + else: + contact_form = forms.ContactMessageForm( + subject=subject, + contact_people=contact_people, + contact_person=contact_person, + ) + return contact_form, contact_people + + +def send_contact_message(contact_form, request): + sender_email = contact_form.cleaned_data["sender"] + contact_person = get_object_or_404( + models.ContactPerson, + pk=contact_form.cleaned_data["contact_person"], + content_type=request.model_content_type, + object_id=request.site_type.pk, + ) + recipient_email = contact_person.account.email + + log_dict = { + "level": "Info", + "action_text": f"Contact Message sent from {sender_email} to {recipient_email}", + "types": "Contact Message", + "target": request.site_type, + "actor_email": contact_form.cleaned_data["sender"], + } + + notify_helpers.send_email_with_body_from_setting_template( + request=request, + template="contact_message", + subject=contact_form.cleaned_data["subject"], + to=recipient_email, + context={ + "site": request.journal or request.press, + "from": sender_email, + "to": recipient_email, + "subject": contact_form.cleaned_data["subject"], + "body": contact_form.cleaned_data["body"], + "custom_reply_to": contact_form.cleaned_data["sender"], + }, + log_dict=log_dict, + ) + messages.add_message( + request, + messages.SUCCESS, + _("Your message has been sent to %(recipient)s.") + % {"recipient": contact_person.account.full_name()}, + ) diff --git a/src/core/middleware.py b/src/core/middleware.py index bcc68567b0..fde5b9be51 100755 --- a/src/core/middleware.py +++ b/src/core/middleware.py @@ -221,9 +221,11 @@ def process_request(request): "cms_page", "cms_nav", "website_index", - "core_journal_contacts", - "core_journal_contact", - "core_journal_contacts_order", + "core_contact_people", + "core_contact_person_search", + "core_contact_person_create", + "core_contact_person_update", + "core_contact_people_reorder", "contact", "core_edit_settings_group", ] diff --git a/src/core/migrations/0110_alter_account_managers.py b/src/core/migrations/0110_alter_account_managers.py new file mode 100644 index 0000000000..022c012c5f --- /dev/null +++ b/src/core/migrations/0110_alter_account_managers.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.22 on 2025-12-12 13:49 + +import core.models +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0109_salutation_name_20250707_1420"), + ] + + operations = [ + migrations.AlterModelManagers( + name="account", + managers=[ + ("objects", core.models.AccountManager()), + ], + ), + ] diff --git a/src/core/migrations/0111_rename_contacts_contactperson_and_more.py b/src/core/migrations/0111_rename_contacts_contactperson_and_more.py new file mode 100644 index 0000000000..ca94e1628b --- /dev/null +++ b/src/core/migrations/0111_rename_contacts_contactperson_and_more.py @@ -0,0 +1,292 @@ +# Generated by Django 4.2.21 on 2025-07-07 16:18 + +from tqdm import tqdm + +from django.conf import settings +from django.db import migrations, models, IntegrityError +import django.db.models.deletion +from utils.logger import get_logger + +logger = get_logger(__name__) + +ACCOUNT_CACHE = {} +TARGET_CACHE = {} + + +def get_account(apps, contact_email, contact_name): + if contact_email in ACCOUNT_CACHE: + return ACCOUNT_CACHE[contact_email] + + Account = apps.get_model("core", "Account") + + # Do the same preparation as Account.clean() + email = Account.objects.normalize_email(contact_email) + username = contact_email.lower() + + matching_accounts = Account.objects.filter( + models.Q(email__iexact=email) | models.Q(username__iexact=username), + ) + if matching_accounts.exists(): + account = matching_accounts.first() + else: + try: + account = Account.objects.create( + email=email, + username=username, + first_name=" ".join(contact_name.split()[:-1]) if contact_name else "", + last_name=contact_name.split()[-1] if contact_name else "", + ) + except IntegrityError: + account = None + + if contact_email not in ACCOUNT_CACHE: + ACCOUNT_CACHE[contact_email] = account + + return account + + +def connect_contact_person_to_account(apps, schema_editor): + ContactPerson = apps.get_model("core", "ContactPerson") + Journal = apps.get_model("journal", "Journal") + Role = apps.get_model("core", "Role") + AccountRole = apps.get_model("core", "AccountRole") + for contact_person in ContactPerson.objects.all(): + account = get_account(apps, contact_person.email, contact_person.name) + if contact_person.content_type.model == "journal": + journal = Journal.objects.get(pk=contact_person.object_id) + role = Role.objects.get(slug="author") + AccountRole.objects.get_or_create(role=role, user=account, journal=journal) + if account: + contact_person.account = account + contact_person.email = "" + contact_person.save() + + +def infer_contact_message_target(apps, account): + if account.pk in TARGET_CACHE: + return TARGET_CACHE[account.pk] + + Journal = apps.get_model("journal", "Journal") + Press = apps.get_model("press", "Press") + ContactPerson = apps.get_model("core", "ContactPerson") + ContentType = apps.get_model("contenttypes", "ContentType") + + contact_person_records = ContactPerson.objects.filter(account__pk=account.pk) + is_staff = account.is_staff + journals_where_editor = Journal.objects.filter( + accountrole__user__pk=account.pk, + accountrole__role__slug="editor", + ) + + if contact_person_records.count() == 1: + # This person must have been contacted on the same site as their ContactPerson record. + contact_person = contact_person_records.first() + + target_pieces = (contact_person.content_type, contact_person.object_id) + elif is_staff and journals_where_editor.count() == 0: + # This person must have been contacted in their press staff capacity. + press = Press.objects.first() + target_pieces = (ContentType.objects.get_for_model(press), press.pk) + elif not is_staff and journals_where_editor.count() == 1: + # This person must have been contacted in their journal editor capacity. + journal = journals_where_editor[0] + target_pieces = (ContentType.objects.get_for_model(journal), journal.pk) + else: + # This person has multiple roles, so we cannot infer the site where the + # contact form was submitted. + target_pieces = (None, None) + if account.pk not in TARGET_CACHE: + TARGET_CACHE[account.pk] = target_pieces + + return target_pieces + + +def move_contact_message_to_log_entry(apps, schema_editor): + Contact = apps.get_model("core", "Contact") + LogEntry = apps.get_model("utils", "LogEntry") + Addressee = apps.get_model("utils", "Addressee") + log_entries_to_create = [] + addressees_to_link_up = [] + + for contact_message in Contact.objects.all(): + account = get_account(apps, contact_message.recipient, "") + + new_entry = LogEntry() + new_entry.types = "Contact Message" + new_entry.date = contact_message.date_sent + new_entry.subject = contact_message.subject + new_entry.description = contact_message.body + new_entry.level = "Info" + new_entry.actor = None + new_entry.actor_email = contact_message.sender + new_entry.request = None + + if contact_message.content_type and contact_message.object_id: + # We set LogEntry.target explicitly, so it works with bulk_create. + new_entry.content_type = contact_message.content_type + new_entry.object_id = contact_message.object_id + elif account: + # The Contact was supposed store the journal or press in a field called `object`, + # but due to a bug, it was not typically populated before this migration. + # So we anticipate that in most cases, LogEntry.target will need to be inferred. + content_type, object_id = infer_contact_message_target(apps, account) + if content_type and object_id: + new_entry.content_type = content_type + new_entry.object_id = object_id + + new_entry.is_email = True + new_entry.email_subject = contact_message.subject + log_entries_to_create.append(new_entry) + + if account: + addressee = Addressee() + addressee.email = account.email + addressee.field = "to" + addressees_to_link_up.append(addressee) + + batch_size = 500 + log_entries = LogEntry.objects.bulk_create(log_entries_to_create, batch_size) + + if addressees_to_link_up: + # Link up the addressees with their log entries + addressees_to_create = [] + for i, addressee in enumerate(addressees_to_link_up): + addressee.log_entry = log_entries[i] + addressees_to_create.append(addressee) + + Addressee.objects.bulk_create(addressees_to_create, batch_size) + + Contact.objects.all().delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("core", "0110_alter_account_managers"), + ("utils", "0043_logentry_actor_email_alter_logentry_ip_address"), + ] + + operations = [ + migrations.RenameModel( + old_name="Contacts", + new_name="ContactPerson", + ), + migrations.AddField( + model_name="contactperson", + name="account", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="contactperson", + name="email", + field=models.EmailField( + blank=True, + help_text="The 'email' field is deprecated. Use 'account.email'.", + max_length=254, + ), + ), + migrations.AlterField( + model_name="contactperson", + name="name", + field=models.CharField( + blank=True, + help_text="The 'name' field is deprecated. Use 'account.full_name'.", + max_length=300, + ), + ), + migrations.AlterField( + model_name="contactperson", + name="name_cy", + field=models.CharField( + blank=True, + help_text="The 'name' field is deprecated. Use 'account.full_name'.", + max_length=300, + null=True, + ), + ), + migrations.AlterField( + model_name="contactperson", + name="name_de", + field=models.CharField( + blank=True, + help_text="The 'name' field is deprecated. Use 'account.full_name'.", + max_length=300, + null=True, + ), + ), + migrations.AlterField( + model_name="contactperson", + name="name_en", + field=models.CharField( + blank=True, + help_text="The 'name' field is deprecated. Use 'account.full_name'.", + max_length=300, + null=True, + ), + ), + migrations.AlterField( + model_name="contactperson", + name="name_en_us", + field=models.CharField( + blank=True, + help_text="The 'name' field is deprecated. Use 'account.full_name'.", + max_length=300, + null=True, + ), + ), + migrations.AlterField( + model_name="contactperson", + name="name_es", + field=models.CharField( + blank=True, + help_text="The 'name' field is deprecated. Use 'account.full_name'.", + max_length=300, + null=True, + ), + ), + migrations.AlterField( + model_name="contactperson", + name="name_fr", + field=models.CharField( + blank=True, + help_text="The 'name' field is deprecated. Use 'account.full_name'.", + max_length=300, + null=True, + ), + ), + migrations.AlterField( + model_name="contactperson", + name="name_nl", + field=models.CharField( + blank=True, + help_text="The 'name' field is deprecated. Use 'account.full_name'.", + max_length=300, + null=True, + ), + ), + migrations.AlterField( + model_name="contactperson", + name="sequence", + field=models.PositiveIntegerField(default=1), + ), + migrations.AlterModelOptions( + name="contactperson", + options={ + "ordering": ("sequence",), + "verbose_name_plural": "contact people", + }, + ), + migrations.RunPython( + connect_contact_person_to_account, + reverse_code=migrations.RunPython.noop, + ), + migrations.RunPython( + move_contact_message_to_log_entry, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/src/core/model_utils.py b/src/core/model_utils.py index 9376ef3bdb..009836c1c0 100644 --- a/src/core/model_utils.py +++ b/src/core/model_utils.py @@ -17,6 +17,7 @@ from django import forms from django.apps import apps from django.contrib import admin +from django.contrib.auth.models import ContentType from django.core.paginator import EmptyPage, Paginator from django.contrib.postgres.lookups import SearchLookup as PGSearchLookup from django.contrib.postgres.search import ( @@ -136,6 +137,24 @@ def auth_success_url(self, next_url=""): """ return next_url or reverse(self.AUTH_SUCCESS_URL) + @property + def contact_people(self): + """ + For use with journal and press sites. + """ + ContactPerson = apps.get_model("core", "ContactPerson") + return ContactPerson.objects.filter( + content_type=ContentType.objects.get_for_model(self), + object_id=self.pk, + ) + + def next_contact_order(self): + """ + For use with journal and press sites. + """ + orderings = [cp.sequence for cp in self.contact_people] + return max(orderings) + 1 if orderings else 0 + class PGCaseInsensitivedMixin: """Activates the citext postgres extension for the given field""" diff --git a/src/core/models.py b/src/core/models.py index e02a2ab8b3..efcf8256ae 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -33,6 +33,7 @@ transaction, ) from django.utils import timezone +from django.utils.html import mark_safe from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.search import SearchVector, SearchVectorField @@ -392,6 +393,8 @@ def create(self, **kwargs): class AccountManager(BaseUserManager): + use_in_migrations = True + def create_user(self, username=None, password=None, email=None, **kwargs): """Creates a user from the given username or email In Janeway, users rely on email addresses to log in. For compatibility @@ -612,6 +615,10 @@ def real_email(self): else: return "" + @property + def pretty_wrapping_email(self): + return mark_safe(self.email.replace("@", "@").replace(".", ".")) + def get_full_name(self): """Deprecated in 1.5.2""" return self.full_name() @@ -1960,7 +1967,7 @@ def __str__(self): return f"{self.user} in {self.group}" -class Contacts(models.Model): +class ContactPerson(models.Model): content_type = models.ForeignKey( ContentType, on_delete=models.CASCADE, @@ -1970,23 +1977,61 @@ class Contacts(models.Model): object_id = models.PositiveIntegerField(blank=True, null=True) object = GenericForeignKey("content_type", "object_id") - name = models.CharField(max_length=300) - email = models.EmailField() + account = models.ForeignKey( + Account, + on_delete=models.CASCADE, + blank=True, + null=True, + ) role = models.CharField(max_length=200) - sequence = models.PositiveIntegerField(default=999) + sequence = models.PositiveIntegerField(default=1) + + name = models.CharField( + max_length=300, + blank=True, + help_text="The 'name' field is deprecated. Use 'account.full_name'.", + ) + email = models.EmailField( + blank=True, + help_text="The 'email' field is deprecated. Use 'account.email'.", + ) class Meta: - # This verbose name will hopefully more clearly - # distinguish this model from the below model `Contact` - # in the admin area. - verbose_name_plural = "contacts" - ordering = ("sequence", "name") + ordering = ("sequence",) + verbose_name_plural = "contact people" def __str__(self): - return "{0}, {1} - {2}".format(self.name, self.object, self.role) + return f"{self.display_name}, {self.object} - {self.role}" + + def __getattribute__(self, name): + if name == "name": + warnings.warn( + "The 'name' field is deprecated. Use 'account.full_name'.", + DeprecationWarning, + stacklevel=2, + ) + elif name == "email": + warnings.warn( + "The 'email' field is deprecated. Use 'account.email'.", + DeprecationWarning, + stacklevel=2, + ) + return super().__getattribute__(name) + + @property + def display_name(self): + return self.account.full_name() if self.account else "" + + @property + def display_email(self): + return self.account.pretty_wrapping_email if self.account else "" class Contact(models.Model): + """ + Deprecated. Use LogEntry instead. + """ + recipient = models.EmailField( max_length=200, verbose_name=_("Who would you like to contact?") ) @@ -2010,6 +2055,14 @@ class Meta: # in the admin area. verbose_name_plural = "contact messages" + def __init__(self, *args, **kwargs): + warnings.warn("Contact is deprecated. Use LogEntry instead.") + super().__init__(*args, **kwargs) + + +# Aliases for backward compatibility +Contacts = ContactPerson + class DomainAlias(AbstractSiteModel): redirect = models.BooleanField( diff --git a/src/core/tests/test_views.py b/src/core/tests/test_views.py index 3747d32fa5..6876b1fb72 100644 --- a/src/core/tests/test_views.py +++ b/src/core/tests/test_views.py @@ -5,12 +5,13 @@ from mock import patch from uuid import uuid4 +from django.contrib.contenttypes.models import ContentType +from django.http import QueryDict from django.urls.base import clear_script_prefix from django.shortcuts import reverse from django.test import Client, TestCase, override_settings from core import models as core_models -from core import views as core_views from utils import orcid from utils.testing import helpers @@ -857,3 +858,268 @@ def test_affiliation_update_from_orcid_confirmed(self, get_orcid_record): self.assertEqual( self.user.primary_affiliation(as_object=False), "California Digital Library" ) + + +class ContactSystemTests(CoreViewTestsWithData): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.editor_one = helpers.create_editor( + cls.journal_one, + email="editor_awydh5q7z0q0hpfallko@example.org", + first_name="Editor", + last_name="One", + ) + cls.editor_two = helpers.create_editor( + cls.journal_one, + email="editor_f0nexsowxw3tcz27td5q@example.org", + first_name="Editor", + last_name="Two", + ) + cls.tech_person = helpers.create_user( + "tech_person_npavexim0doaqr9w9cqz@example.org", + roles=["author"], + journal=cls.journal_one, + is_staff=True, + is_active=True, + first_name="Tech", + last_name="Person", + ) + cls.press_manager = helpers.create_user( + "press_manager_lpnp50waqhk5wwlcbyie@example.org", + roles=["author"], + journal=cls.journal_one, + is_staff=True, + is_active=True, + first_name="Press", + last_name="Manager", + ) + cls.contact_one = helpers.create_contact_person(cls.editor_one, cls.journal_one) + cls.contact_two = helpers.create_contact_person( + cls.tech_person, + cls.journal_one, + ) + cls.contact_three = helpers.create_contact_person( + cls.press_manager, + cls.press, + ) + cls.press_content_type = ContentType.objects.get_for_model(cls.press) + cls.journal_content_type = ContentType.objects.get_for_model(cls.journal_one) + + # Create some log entries containing contact messages + cls.contact_message_one = helpers.send_contact_message( + cls.journal_one, + cls.contact_one, + ) + cls.contact_message_two = helpers.send_contact_message( + cls.press, + cls.contact_three, + ) + + @override_settings(URL_CONFIG="domain") + def test_contact_people_GET(self): + self.client.force_login(self.editor_one) + url = reverse("core_contact_people") + response = self.client.get(url, SERVER_NAME=self.journal_one.domain) + self.assertIn(self.contact_one, response.context["contacts"]) + self.assertNotIn(self.contact_three, response.context["contacts"]) + self.assertTemplateUsed( + "core/manager/contacts/index.html", + ) + + @override_settings(URL_CONFIG="domain") + def test_contact_people_delete_POST(self): + self.client.force_login(self.editor_one) + url = reverse("core_contact_people") + post_data = { + "delete": self.contact_two.pk, + } + self.client.post(url, post_data, SERVER_NAME=self.journal_one.domain) + self.assertFalse( + self.journal_one.contact_people.filter( + account=self.tech_person, + ).exists(), + ) + + @override_settings(URL_CONFIG="domain") + def test_contact_person_create_GET(self): + self.client.force_login(self.editor_one) + url = reverse( + "core_contact_person_create", + kwargs={"account_id": self.editor_two.pk}, + ) + response = self.client.get(url, SERVER_NAME=self.journal_one.domain) + self.assertEqual( + response.context["account"], + self.editor_two, + ) + self.assertTemplateUsed( + "core/manager/contacts/contact_person_form.html", + ) + + @override_settings(URL_CONFIG="domain") + def test_contact_person_create_POST(self): + self.client.force_login(self.editor_one) + url = reverse( + "core_contact_person_create", + kwargs={"account_id": self.editor_two.pk}, + ) + post_data = { + "role": "Managing editor", + "sequence": "2", + } + self.client.post(url, post_data, SERVER_NAME=self.journal_one.domain) + self.assertTrue( + self.journal_one.contact_people.filter( + account=self.editor_two, + ).exists(), + ) + + @override_settings(URL_CONFIG="domain") + def test_contact_person_update_GET(self): + self.client.force_login(self.editor_one) + url = reverse( + "core_contact_person_update", + kwargs={"contact_person_id": self.contact_one.pk}, + ) + response = self.client.get(url, SERVER_NAME=self.journal_one.domain) + self.assertEqual( + response.context["contact_person"], + self.contact_one, + ) + self.assertListEqual( + [cp.pk for cp in response.context["contact_people"]], + [cp.pk for cp in self.journal_one.contact_people], + ) + self.assertTemplateUsed( + "core/manager/contacts/contact_person_form.html", + ) + + @override_settings(URL_CONFIG="domain") + @override_settings(LANGUAGE_CODE="en") + def test_contact_person_update_POST(self): + self.client.force_login(self.editor_one) + url = reverse( + "core_contact_person_update", + kwargs={"contact_person_id": self.contact_one.pk}, + ) + post_data = { + "role": "Keeper of the issue numbers", + "sequence": self.contact_one.sequence, + } + self.client.post(url, post_data, SERVER_NAME=self.journal_one.domain) + self.contact_one.refresh_from_db() + self.assertEqual( + self.contact_one.role, + "Keeper of the issue numbers", + ) + + @override_settings(URL_CONFIG="domain") + def test_contact_person_reorder(self): + self.client.force_login(self.editor_one) + url = reverse("core_contact_people_reorder") + post_data = { + "contact[]": [self.contact_two.pk, self.contact_one.pk], + } + self.client.post(url, post_data, SERVER_NAME=self.journal_one.domain) + self.contact_one.refresh_from_db() + self.contact_two.refresh_from_db() + self.assertEqual(self.contact_two.sequence, 1) + self.assertEqual(self.contact_one.sequence, 2) + + @override_settings(URL_CONFIG="domain") + def test_potential_contact_list_view_GET(self): + self.client.force_login(self.editor_one) + url = reverse("core_contact_person_search") + response = self.client.get(url, SERVER_NAME=self.journal_one.domain) + self.assertListEqual( + [cp.pk for cp in response.context["contact_people"]], + [cp.pk for cp in self.journal_one.contact_people], + ) + self.assertTemplateUsed( + "core/manager/contacts/search_potential.html", + ) + + @override_settings(URL_CONFIG="domain") + def test_potential_contact_list_view_GET_with_q(self): + self.client.force_login(self.editor_one) + url = reverse("core_contact_person_search") + get_data = { + "q": self.editor_two.last_name, + } + response = self.client.get(url, get_data, SERVER_NAME=self.journal_one.domain) + self.assertIn( + self.editor_two, + response.context["account_list"], + ) + + @override_settings(URL_CONFIG="domain") + def test_potential_contact_list_view_GET_marks_existing_contacts(self): + self.client.force_login(self.editor_one) + url = reverse("core_contact_person_search") + get_data = { + "q": self.editor_one.first_name, + } + response = self.client.get(url, get_data, SERVER_NAME=self.journal_one.domain) + self.assertTrue(response.context["account_list"][0].is_contact_person) + + @override_settings(URL_CONFIG="domain") + def test_core_contact_messages_journal_GET(self): + self.client.force_login(self.editor_one) + url = reverse("core_contact_messages") + response = self.client.get(url, SERVER_NAME=self.journal_one.domain) + self.assertIn( + self.contact_message_one, + response.context["logentry_list"], + ) + self.assertNotIn( + self.contact_message_two, + response.context["logentry_list"], + ) + self.assertTemplateUsed( + "core/manager/contacts/message_list.html", + ) + + @override_settings(URL_CONFIG="domain") + def test_core_contact_messages_press_GET(self): + self.client.force_login(self.press_manager) + url = reverse("core_contact_messages") + response = self.client.get(url, SERVER_NAME=self.press.domain) + self.assertIn( + self.contact_message_two, + response.context["logentry_list"], + ) + self.assertNotIn( + self.contact_message_one, + response.context["logentry_list"], + ) + self.assertTemplateUsed( + "core/manager/contacts/message_list.html", + ) + + @override_settings(URL_CONFIG="domain") + def test_contact_message_GET(self): + self.client.force_login(self.editor_one) + url = reverse( + "core_contact_message", + kwargs={"log_entry_id": self.contact_message_one.pk}, + ) + response = self.client.get(url, SERVER_NAME=self.journal_one.domain) + self.assertEqual( + self.contact_message_one, + response.context["log_entry"], + ) + + @override_settings(URL_CONFIG="domain") + def test_contact_message_delete_GET(self): + self.client.force_login(self.editor_one) + url = reverse( + "core_contact_message_delete", + kwargs={"log_entry_id": self.contact_message_one.pk}, + ) + response = self.client.get(url, SERVER_NAME=self.journal_one.domain) + self.assertEqual( + self.contact_message_one, + response.context["log_entry"], + ) diff --git a/src/core/translation.py b/src/core/translation.py index 6fa46f8f7f..11b83b1425 100644 --- a/src/core/translation.py +++ b/src/core/translation.py @@ -16,7 +16,7 @@ class EditorialGroupTranslationOptions(TranslationOptions): ) -@register(models.Contacts) +@register(models.ContactPerson) class ContactTranslationOptions(TranslationOptions): fields = ( "name", diff --git a/src/core/urls.py b/src/core/urls.py index 297e5984bc..f79d18e29c 100755 --- a/src/core/urls.py +++ b/src/core/urls.py @@ -20,6 +20,7 @@ urlpatterns = [ path("", press_views.index, name="website_index"), + path("admin/doc/", include("django.contrib.admindocs.urls")), path("admin/", admin.site.urls), path("summernote/", include("django_summernote.urls")), path("", include("core.include_urls")), diff --git a/src/core/views.py b/src/core/views.py index 174c6a3712..99a6f69a1a 100755 --- a/src/core/views.py +++ b/src/core/views.py @@ -34,7 +34,7 @@ from django.utils.translation import gettext_lazy as _ from django.utils.html import mark_safe from django.utils import translation -from django.db.models import Q, OuterRef, Subquery, Count, Avg +from django.db.models import Q, OuterRef, Subquery, Count, Avg, Exists from django.views import generic from core import models, forms, logic, workflow, files, models as core_models @@ -1477,7 +1477,7 @@ def add_user(request): """ form = forms.EditAccountForm() registration_form = forms.AdminUserForm(active="add", request=request) - return_url = request.GET.get("return", None) + next_url = request.GET.get("return", None) or request.GET.get("next", None) role = request.GET.get("role", None) if request.POST: @@ -1497,13 +1497,21 @@ def add_user(request): form = forms.EditAccountForm(request.POST, request.FILES, instance=new_user) if form.is_valid(): - form.save() - messages.add_message(request, messages.SUCCESS, "User created.") - - if return_url: - return redirect(return_url) + account = form.save() + messages.add_message( + request, + messages.SUCCESS, + _("User account created for %(name)s (%(email)s)") + % { + "name": account.full_name(), + "email": account.email, + }, + ) - return redirect(reverse("core_manager_users")) + if next_url: + return redirect(next_url) + else: + return redirect(reverse("core_manager_users")) else: # If the registration form is not valid, @@ -1870,105 +1878,174 @@ def article_image_edit(request, article_pk): @editor_user_required -def contacts(request): +def contact_people(request): """ - Allows for adding and deleting of JournalContact objects. + See the list of ContactPerson objects, + and delete individual ContactPerson records. :param request: HttpRequest object :return: HttpResponse object """ - form = forms.JournalContactForm() - contacts = models.Contacts.objects.filter( - content_type=request.model_content_type, - object_id=request.site_type.pk, - ) + contact_people = request.site_type.contact_people if "delete" in request.POST: contact_id = request.POST.get("delete") - contact = get_object_or_404( - models.Contacts, + contact_person = get_object_or_404( + models.ContactPerson, pk=contact_id, content_type=request.model_content_type, object_id=request.site_type.pk, ) - contact.delete() - return redirect(reverse("core_journal_contacts")) - - if request.POST: - form = forms.JournalContactForm(request.POST) - - if form.is_valid(): - contact = form.save(commit=False) - contact.content_type = request.model_content_type - contact.object_id = request.site_type.pk - contact.sequence = request.site_type.next_contact_order() - contact.save() - return redirect(reverse("core_journal_contacts")) + contact_person.delete() + return redirect(reverse("core_contact_people")) template = "core/manager/contacts/index.html" context = { - "form": form, - "contacts": contacts, - "action": "new", + "contacts": contact_people, } - return render(request, template, context) @editor_user_required @GET_language_override -def edit_contacts(request, contact_id=None): +def contact_person_create(request, account_id): """ - Allows for editing of existing Contact objects + Create a new ContactPerson with the selected account. :param request: HttpRequest object :param contact_id: Contact object PK :return: HttpResponse object """ + next_url = request.GET.get("next", "") + account = get_object_or_404(models.Account, pk=account_id) + contact_people = request.site_type.contact_people with translation.override(request.override_language): - if contact_id: - contact = get_object_or_404( - models.Contacts, - pk=contact_id, - content_type=request.model_content_type, - object_id=request.site_type.pk, - ) - form = forms.JournalContactForm(instance=contact) - else: - contact = None - form = forms.JournalContactForm( - next_sequence=request.site_type.next_contact_order(), - ) + form = forms.ContactPersonForm( + next_sequence=request.site_type.next_contact_order(), + ) if request.POST: - form = forms.JournalContactForm(request.POST, instance=contact) - + form = forms.ContactPersonForm(request.POST) if form.is_valid(): - if contact: - contact = form.save() + contact_person = form.save(commit=False) + contact_person.account = account + contact_person.content_type = request.model_content_type + contact_person.object_id = request.site_type.pk + contact_person.save() + messages.add_message( + request, + messages.SUCCESS, + _("Contact person added: %(contact_person)s") + % {"contact_person": contact_person}, + ) + + if next_url: + return redirect(next_url) else: - contact = form.save(commit=False) - contact.content_type = request.model_content_type - contact.object_id = request.site_type.pk - contact.save() + return redirect(reverse("core_contact_people")) - return language_override_redirect( + template = "core/manager/contacts/contact_person_form.html" + context = { + "account": account, + "contact_people": contact_people, + "form": form, + } + return render(request, template, context) + + +@editor_user_required +@GET_language_override +def contact_person_update(request, contact_person_id): + """ + Allows for editing of existing Contact objects + :param request: HttpRequest object + :param contact_id: Contact object PK + :return: HttpResponse object + """ + next_url = request.GET.get("next", "") + contact_person = get_object_or_404( + models.ContactPerson, + pk=contact_person_id, + content_type=request.model_content_type, + object_id=request.site_type.pk, + ) + contact_people = request.site_type.contact_people + with translation.override(request.override_language): + form = forms.ContactPersonForm(instance=contact_person) + if request.POST: + form = forms.ContactPersonForm( + request.POST, + instance=contact_person, + ) + if form.is_valid(): + contact_person = form.save() + messages.add_message( request, - "core_journal_contact", - {"contact_id": contact.pk}, + messages.SUCCESS, + _("Contact person updated: %(contact_person)s") + % {"contact_person": contact_person}, ) + if next_url: + return redirect(next_url) + else: + return redirect(reverse("core_contact_people")) - template = "core/manager/contacts/manage.html" + template = "core/manager/contacts/contact_person_form.html" context = { "form": form, - "contact": contact, + "contact_person": contact_person, + "contact_people": contact_people, + "account": contact_person.account, } return render(request, template, context) +@login_required +def contact_person_delete(request, contact_person_id): + """ + Allows a staff member or editor to remove a contact person. + """ + + next_url = request.GET.get("next", "") + contact_person = get_object_or_404( + models.ContactPerson, + pk=contact_person_id, + content_type=request.model_content_type, + object_id=request.site_type.pk, + ) + contact_people = request.site_type.contact_people + form = forms.ConfirmDeleteForm() + + if request.method == "POST": + form = forms.ConfirmDeleteForm(request.POST) + if form.is_valid(): + contact_person.delete() + messages.add_message( + request, + messages.SUCCESS, + _("Contact person removed: %(contact_person)s") + % {"contact_person": contact_person}, + ) + if next_url: + return redirect(next_url) + else: + return redirect(reverse("core_contact_people")) + + template = "admin/core/manager/contacts/confirm_remove.html" + context = { + "account": contact_person.account, + "form": form, + "thing_to_delete": contact_person, + "contact_person": contact_person, + "contact_people": contact_people, + } + return render(request, template, context) + + @editor_user_required -def contacts_order(request): +@require_POST +def contact_people_reorder(request): """ - Reorders the Contact list, posted via AJAX. + Reorders the ContactPerson list, posted via AJAX. :param request: HttpRequest object :return: HttpResponse object """ @@ -1976,11 +2053,9 @@ def contacts_order(request): ids = request.POST.getlist("contact[]") ids = [int(_id) for _id in ids] - for jc in models.Contacts.objects.filter( - content_type=request.model_content_type, object_id=request.site_type.pk - ): - jc.sequence = ids.index(jc.pk) - jc.save() + for contact_person in request.site_type.contact_people: + contact_person.sequence = ids.index(contact_person.pk) + 1 + contact_person.save() return HttpResponse("Thanks") @@ -3373,3 +3448,51 @@ def affiliation_delete(request, affiliation_id): "thing_to_delete": affiliation.organization.name, } return render(request, template, context) + + +@method_decorator(editor_user_required, name="dispatch") +class PotentialContactListView(GenericFacetedListView): + """ + Allows an editor or press manager to search for someone in order to + make them a contact person for the journal or press. + """ + + model = core_models.Account + template_name = "admin/core/manager/contacts/search_potential.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["contact_people"] = self.request.site_type.contact_people + return context + + def get_queryset(self, *args, **kwargs): + queryset = super().get_queryset(*args, **kwargs) + return queryset.annotate( + is_contact_person=Exists( + self.request.site_type.contact_people.filter( + account=OuterRef("pk"), + ) + ) + ) + + def get_journal_filter_query(self): + if self.request.journal: + journal_roles = ["editor", "section-editor", "press-manager"] + return ( + Q( + accountrole__journal=self.request.journal, + accountrole__role__slug__in=journal_roles, + ) + | Q(is_staff=True) + | Q(is_superuser=True) + ) + else: + return Q(is_staff=True) | Q(is_superuser=True) + + def get_facets(self): + return { + "q": { + "type": "search", + "field_label": "Search", + }, + } diff --git a/src/journal/forms.py b/src/journal/forms.py index 101d08232e..34da209242 100755 --- a/src/journal/forms.py +++ b/src/journal/forms.py @@ -12,8 +12,8 @@ from tinymce.widgets import TinyMCE from core import models as core_models -from core.forms import FullSettingEmailForm -from journal import models as journal_models, logic +from core.forms import FullSettingEmailForm, ContactMessageForm +from journal import models as journal_models from utils.forms import CaptchaForm SEARCH_SORT_OPTIONS = [ @@ -36,29 +36,12 @@ class Meta: } -class ContactForm(forms.ModelForm, CaptchaForm): +class ContactForm(ContactMessageForm): def __init__(self, *args, **kwargs): - subject = kwargs.pop("subject", None) - contacts = kwargs.pop("contacts", None) - super(ContactForm, self).__init__(*args, **kwargs) - - if subject: - self.fields["subject"].initial = subject - - if contacts: - contact_choices = [] - for contact in contacts: - contact_choices.append( - [ - contact.email, - "{name}, {role}".format(name=contact.name, role=contact.role), - ] - ) - self.fields["recipient"].widget = forms.Select(choices=contact_choices) - - class Meta: - model = core_models.Contact - fields = ("recipient", "sender", "subject", "body") + return DeprecationWarning("Use ContactMessageForm instead.") + if "contact_people" not in kwargs: + kwargs["contact_people"] = kwargs.pop("contacts", None) + super().__init__(*args, **kwargs) class ResendEmailForm(forms.Form): diff --git a/src/journal/logic.py b/src/journal/logic.py index 5828a257ca..2689fbd61f 100755 --- a/src/journal/logic.py +++ b/src/journal/logic.py @@ -397,6 +397,10 @@ def set_article_image(request, article): def send_contact_message(new_contact, request): + warnings.warn( + "`journal.logic.send_contact_message` is deprecated. " + "Use `core.logic.send_contact_message` instead." + ) body = new_contact.body.replace("\n", "
") message = """

This message is from {0}'s contact form.

diff --git a/src/journal/models.py b/src/journal/models.py index 731dd6d7f5..71e1d97d27 100644 --- a/src/journal/models.py +++ b/src/journal/models.py @@ -566,13 +566,6 @@ def next_featured_article_order(self): ] return max(orderings) + 1 if orderings else 0 - def next_contact_order(self): - contacts = core_models.Contacts.objects.filter( - content_type__model="journal", object_id=self.pk - ) - orderings = [contact.sequence for contact in contacts] - return max(orderings) + 1 if orderings else 0 - def next_group_order(self): orderings = [group.sequence for group in self.editorialgroup_set.all()] return max(orderings) + 1 if orderings else 0 diff --git a/src/journal/tests/test_views.py b/src/journal/tests/test_views.py index 9d34995257..c56c2a3b26 100644 --- a/src/journal/tests/test_views.py +++ b/src/journal/tests/test_views.py @@ -3,17 +3,43 @@ __license__ = "AGPL v3" __maintainer__ = "Open Library of Humanities" -from django.test import Client, TestCase +from django.contrib.contenttypes.models import ContentType +from django.test import Client, TestCase, override_settings +from django.urls import reverse from django.utils import timezone +from core import models as core_models +from core.logic import reverse_with_query +from utils import models as utils_models from utils.testing import helpers -class PublishedArticlesListViewTests(TestCase): +class JournalViewTestsWithData(TestCase): @classmethod def setUpTestData(cls): cls.press = helpers.create_press() cls.journal_one, cls.journal_two = helpers.create_journals() + cls.editor_one = helpers.create_editor( + cls.journal_one, + email="editor_jiqjgaysqge1pahnj4xn@example.org", + first_name="Editor", + last_name="One", + ) + cls.editor_two = helpers.create_editor( + cls.journal_one, + email="editor_iw9pm21rrrxhm9kp5rfa@example.org", + first_name="Editor", + last_name="Two", + ) + cls.contact_person_one = helpers.create_contact_person( + cls.editor_one, + cls.journal_one, + ) + cls.contact_person_two = helpers.create_contact_person( + cls.editor_two, + cls.journal_one, + ) + cls.journal_content_type = ContentType.objects.get_for_model(cls.journal_one) cls.sections = [] cls.articles = [] thirty_days_ago = timezone.now() - timezone.timedelta(days=30) @@ -34,6 +60,8 @@ def setUpTestData(cls): ) ) + +class PublishedArticlesListViewTests(JournalViewTestsWithData): def setUp(self): self.client = Client() @@ -41,7 +69,7 @@ def test_count_no_filters(self): data = {} response = self.client.get("/articles/", data) self.assertIn( - f"60 results", + "60 results", response.content.decode(), ) @@ -49,7 +77,7 @@ def test_count_filtered_on_section(self): data = {"section__pk": self.sections[0].pk} response = self.client.get("/articles/", data) self.assertIn( - f"15 results", + "15 results", response.content.decode(), ) @@ -59,10 +87,64 @@ def test_counts_match_with_filters(self): } response = self.client.get("/articles/", data) self.assertIn( - f"15 results", + "15 results", response.content.decode(), ) self.assertIn( - f"Article (15)", + "Article (15)", response.content.decode(), ) + + +class JournalContactTests(JournalViewTestsWithData): + @override_settings(URL_CONFIG="domain") + def test_contact_GET(self): + response = self.client.get( + reverse("contact"), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual( + self.contact_person_one.account.email, + response.context["contacts"][0].account.email, + ) + self.assertEqual( + [ + (self.contact_person_one.pk, self.editor_one.full_name()), + (self.contact_person_two.pk, self.editor_two.full_name()), + ], + response.context["contact_form"].fields["contact_person"].choices, + ) + + @override_settings(URL_CONFIG="domain") + def test_journal_contact_with_recipient_GET(self): + url = reverse( + "journal_contact_with_recipient", + kwargs={"contact_person_id": self.contact_person_two.pk}, + ) + response = self.client.get(url, SERVER_NAME=self.journal_one.domain) + self.assertEqual( + self.contact_person_two.pk, + response.context["contact_form"].fields["contact_person"].initial, + ) + + @override_settings(URL_CONFIG="domain") + @override_settings(CAPTCHA_TYPE="") + def test_contact_POST(self): + post_data = { + "contact_person": self.contact_person_one.pk, + "sender": "santa@example.org", + "subject": "Merry Christmas", + "body": "Tis the season\nTo be jolly", + } + self.client.post( + reverse("contact"), + post_data, + SERVER_NAME=self.journal_one.domain, + ) + self.assertTrue( + utils_models.LogEntry.objects.filter( + actor_email="santa@example.org", + content_type=self.journal_content_type, + object_id=self.journal_one.pk, + ).exists() + ) diff --git a/src/journal/urls.py b/src/journal/urls.py index 5f9cf56ba1..331a1b777b 100755 --- a/src/journal/urls.py +++ b/src/journal/urls.py @@ -3,7 +3,7 @@ __license__ = "AGPL v3" __maintainer__ = "Birkbeck Centre for Technology and Publishing" -from django.urls import re_path +from django.urls import path, re_path from journal import views from identifiers.models import NON_DOI_IDENTIFIER_TYPES, DOI_REGEX_PATTERN @@ -287,6 +287,11 @@ re_path(r"^reviewer/$", views.become_reviewer, name="become_reviewer"), # Contact re_path(r"^contact/$", views.contact, name="contact"), + re_path( + "contact/recipient/(?P\d+)/$", + views.contact, + name="journal_contact_with_recipient", + ), # Accessibility re_path(r"^accessibility/$", views.accessibility, name="accessibility"), # Editorial team diff --git a/src/journal/views.py b/src/journal/views.py index e9491ad805..ab84bcb7fa 100755 --- a/src/journal/views.py +++ b/src/journal/views.py @@ -2079,46 +2079,33 @@ def become_reviewer(request): return render(request, template, context) -def contact(request): +def contact(request, contact_person_id=None): """ Displays a form that allows a user to contact admins or editors. :param request: HttpRequest object + :param contact_person_id: pk for the ContactPerson that should be pre-selected :return: HttpResponse or HttpRedirect if POST """ - subject = request.GET.get("subject", "") - contacts = core_models.Contacts.objects.filter( - content_type=request.model_content_type, object_id=request.site_type.pk - ) - - contact_form = forms.ContactForm(subject=subject, contacts=contacts) - - if request.POST: - contact_form = forms.ContactForm(request.POST, contacts=contacts) - if contact_form.is_valid(): - new_contact = contact_form.save(commit=False) - new_contact.client_ip = shared.get_ip_address(request) - new_contact.content_type = request.model_content_type - new_contact.object_ic = request.site_type.pk - new_contact.save() + # Backwards compatibility + if not request.journal: + return redirect(reverse("press_contact")) - logic.send_contact_message(new_contact, request) - messages.add_message( - request, - messages.SUCCESS, - _("Your message has been sent."), - ) - return redirect(reverse("contact")) + contact_form, contact_people = core_logic.get_contact_form( + request, + contact_person_id, + ) + if request.POST and contact_form.is_valid(): + core_logic.send_contact_message(contact_form, request) + return redirect(reverse("contact")) - if request.journal and request.journal.disable_front_end: + if request.journal.disable_front_end: template = "admin/journal/contact.html" - elif request.journal: - template = "journal/contact.html" else: - template = "press/journal/contact.html" + template = "journal/contact.html" context = { "contact_form": contact_form, - "contacts": contacts, + "contacts": contact_people, } return render(request, template, context) diff --git a/src/press/models.py b/src/press/models.py index c8246c7918..53a67e1dc9 100755 --- a/src/press/models.py +++ b/src/press/models.py @@ -363,13 +363,6 @@ def next_journal_order(self): else: return max_number + 1 - def next_contact_order(self): - contacts = core_models.Contacts.objects.filter( - content_type__model="press", object_id=self.pk - ) - orderings = [contact.sequence for contact in contacts] - return max(orderings) + 1 if orderings else 0 - @property def active_carousel(self): """Renders a carousel for the press homepage. diff --git a/src/press/tests/test_views.py b/src/press/tests/test_views.py index 6f46d9ab62..b2a076a1d6 100644 --- a/src/press/tests/test_views.py +++ b/src/press/tests/test_views.py @@ -3,9 +3,12 @@ __license__ = "AGPL v3" __maintainer__ = "Open Library of Humanities" -from django.test import Client, TestCase, override_settings +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse +from django.test import TestCase, override_settings -from press import views as press_views +from core import models as core_models +from utils import models as utils_models from utils.testing import helpers @@ -20,6 +23,11 @@ def setUpTestData(cls): cls.press_manager.is_active = True cls.press_manager.is_staff = True cls.press_manager.save() + cls.contact_person = helpers.create_contact_person( + cls.press_manager, + cls.press, + ) + cls.press_content_type = ContentType.objects.get_for_model(cls.press) class URLWithReturnTests(PressViewTestsWithData): @@ -36,3 +44,56 @@ def test_press_nav_account_links_do_not_have_return(self): content = response.content.decode() self.assertNotIn("/login/?next=", content) self.assertNotIn("/register/step/1/?next=", content) + + +class PressContactTests(PressViewTestsWithData): + @override_settings(URL_CONFIG="domain") + def test_press_contact_GET(self): + response = self.client.get( + reverse("press_contact"), + SERVER_NAME=self.press.domain, + ) + self.assertEqual( + self.contact_person.account.email, + response.context["contacts"][0].account.email, + ) + self.assertEqual( + [(self.contact_person.pk, self.contact_person.account.full_name())], + response.context["contact_form"].fields["contact_person"].choices, + ) + self.assertTemplateUsed("press/journal/contact.html") + + @override_settings(URL_CONFIG="domain") + def test_journal_contact_view_redirects_to_press_contact_when_no_journal(self): + """ + The tested behavior is for backwards compatibility, + since the press used to use the journal 'contact' view. + """ + response = self.client.get( + reverse("contact"), + SERVER_NAME=self.press.domain, + follow=True, + ) + self.assertIn((reverse("press_contact"), 302), response.redirect_chain) + + @override_settings(URL_CONFIG="domain") + @override_settings(CAPTCHA_TYPE="") + def test_press_contact_POST(self): + post_data = { + "contact_person": self.contact_person.pk, + "sender": "santa@example.org", + "subject": "Merry Christmas", + "body": "Tis the season\nTo be jolly", + } + self.client.post( + reverse("press_contact"), + post_data, + SERVER_NAME=self.press.domain, + ) + self.assertTrue( + utils_models.LogEntry.objects.filter( + actor_email="santa@example.org", + content_type=self.press_content_type, + object_id=self.press.pk, + ).exists() + ) diff --git a/src/press/views.py b/src/press/views.py index 0cef8e4556..7b9c45890a 100755 --- a/src/press/views.py +++ b/src/press/views.py @@ -21,6 +21,7 @@ logic as core_logic, views as core_views, ) +from core.forms import ContactMessageForm from core.views import BaseUserList from journal import ( models as journal_models, @@ -439,3 +440,27 @@ def edit_press_journal_description(request, journal_id): @method_decorator(staff_member_required, name="dispatch") class AllUsers(BaseUserList): pass + + +def contact(request, contact_person_id=None): + """ + Displays a form that allows a user to contact press representatives. + :param request: HttpRequest object + :param contact_person_id: pk for the ContactPerson that should be pre-selected + :return: HttpResponse or HttpRedirect if POST + """ + contact_form, contact_people = core_logic.get_contact_form( + request, + contact_person_id, + ) + if request.POST and contact_form.is_valid(): + core_logic.send_contact_message(contact_form, request) + return redirect(reverse("press_contact")) + + template = "press/journal/contact.html" + context = { + "contact_form": contact_form, + "contacts": contact_people, + } + + return render(request, template, context) diff --git a/src/security/decorators.py b/src/security/decorators.py index a45b181948..3a7bca5b1e 100755 --- a/src/security/decorators.py +++ b/src/security/decorators.py @@ -25,7 +25,7 @@ can_view_file_history, is_data_figure_file, ) -from utils import setting_handler +from utils import setting_handler, models as utils_models from utils.logger import get_logger from repository import models as preprint_models @@ -1571,3 +1571,23 @@ def inner(request, *args, **kwargs): return inner return decorator + + +def user_can_view_contact_message(func): + """This checks permissions for a user to view a specific contact message. + + :param func: the function to callback from the decorator + :return: either the function call or raises an Http404 + """ + + def wrapper(request, *args, **kwargs): + log_entry_id = kwargs["log_entry_id"] + + log_entry = utils_models.LogEntry.objects.get(pk=log_entry_id) + + if log_entry.viewable_as_contact_message_by(request.user): + return func(request, *args, **kwargs) + else: + deny_access(request) + + return wrapper diff --git a/src/security/test_security.py b/src/security/test_security.py index 5614998256..8f33ed478c 100644 --- a/src/security/test_security.py +++ b/src/security/test_security.py @@ -4685,6 +4685,26 @@ def test_repository_setting_enabled_enabled_setting(self): ) self.assertEqual(response.status_code, 200) + def test_user_can_view_contact_message_permission_granted(self): + func = Mock() + decorated_func = decorators.user_can_view_contact_message(func) + kwargs = {"log_entry_id": self.contact_message_one.pk} + + request = self.prepare_request_with_user(self.editor) + + decorated_func(request, **kwargs) + self.assertTrue(func.called) + + def test_user_can_view_contact_message_permission_denied(self): + func = Mock() + decorated_func = decorators.user_can_view_contact_message(func) + kwargs = {"log_entry_id": self.contact_message_one.pk} + + request = self.prepare_request_with_user(self.regular_user) + + with self.assertRaises(PermissionDenied): + decorated_func(request, **kwargs) + # General helper functions @staticmethod @@ -5344,6 +5364,15 @@ def setUpTestData(self): subject=self.repository_subject, ) + self.contact_one = helpers.create_contact_person( + self.editor, + self.journal_one, + ) + self.contact_message_one = helpers.send_contact_message( + self.journal_one, + self.contact_one, + ) + call_command("load_default_settings") call_command("load_permissions") diff --git a/src/static/admin/css/admin.css b/src/static/admin/css/admin.css index cc49bab8a5..2b80a43d3b 100644 --- a/src/static/admin/css/admin.css +++ b/src/static/admin/css/admin.css @@ -897,6 +897,11 @@ ul.menu { @media (width > 50rem) { grid-template-columns: 20rem minmax(auto, 60rem); } + &.wide-table { + @media (width > 50rem) { + grid-template-columns: 20rem auto; + } + } h3 { font-size: 1.3rem; } @@ -1086,6 +1091,6 @@ ul.menu { grid-template-columns: 100%; } @media (width > 50rem) { - grid-template-columns: 20rem minmax(auto, 60rem); + grid-template-columns: 24rem minmax(auto, 60rem); } } diff --git a/src/static/common/css/utilities.css b/src/static/common/css/utilities.css index 2af077bd2e..a00e38fdc5 100644 --- a/src/static/common/css/utilities.css +++ b/src/static/common/css/utilities.css @@ -133,6 +133,7 @@ .max-w-80 { max-width: 80rem; } .width-min-content { width: min-content; } .width-max-content { width: max-content; } +.width-full { width: 100%; } /* Padding */ .padding-inline-0 { padding-inline: 0rem; } @@ -155,6 +156,16 @@ .padding-block-2-5 { padding-block: 2.5rem; } .padding-block-3 { padding-block: 3rem; } .padding-block-4 { padding-block: 4rem; } +.padding-block-start-0 { padding-block-start: 0rem; } +.padding-block-start-0-25 { padding-block-start: 0.25rem; } +.padding-block-start-0-5 { padding-block-start: 0.5rem; } +.padding-block-start-0-75 { padding-block-start: 0.75rem; } +.padding-block-start-1 { padding-block-start: 1rem; } +.padding-block-start-1-5 { padding-block-start: 1.5rem; } +.padding-block-start-2 { padding-block-start: 2rem; } +.padding-block-start-2-5 { padding-block-start: 2.5rem; } +.padding-block-start-3 { padding-block-start: 3rem; } +.padding-block-start-4 { padding-block-start: 4rem; } .padding-block-end-0 { padding-block-end: 0rem; } .padding-block-end-0-25 { padding-block-end: 0.25rem; } .padding-block-end-0-5 { padding-block-end: 0.5rem; } diff --git a/src/templates/admin/core/manager/contacts/confirm_remove.html b/src/templates/admin/core/manager/contacts/confirm_remove.html new file mode 100644 index 0000000000..7975360204 --- /dev/null +++ b/src/templates/admin/core/manager/contacts/confirm_remove.html @@ -0,0 +1,16 @@ +{% extends "admin/elements/confirm_remove.html" %} + +{% load i18n next_url %} + +{% block breadcrumbs %} + {% include "elements/breadcrumbs/contacts_base.html" with subpage="yes" %} +
  • + {% blocktrans %} + Remove "{{ thing_to_delete }}" + {% endblocktrans %} +
  • +{% endblock breadcrumbs %} + +{% block nitty %} + {% include "admin/core/manager/contacts/related_info.html" with contact_people=contact_people %} +{% endblock nitty %} diff --git a/src/templates/admin/core/manager/contacts/contact_person_form.html b/src/templates/admin/core/manager/contacts/contact_person_form.html new file mode 100644 index 0000000000..7f174dbd91 --- /dev/null +++ b/src/templates/admin/core/manager/contacts/contact_person_form.html @@ -0,0 +1,59 @@ +{% extends "admin/elements/nitty_gritty.html" %} + +{% load i18n static next_url %} + +{% block contextual_title %} + {% include "core/manager/contacts/contact_person_form_page_name.html" %} +{% endblock contextual_title %} + +{% block title-section %} + {% include "core/manager/contacts/contact_person_form_page_name.html" %} +{% endblock title-section %} + +{% block title-sub %} +
    + {% trans "Back to contact manager" as back_label %} + {% url "core_contact_people" as back_url %} + {% include "elements/a_back.html" with label=back_label href=back_url %} +
    +{% endblock title-sub %} + +{% block breadcrumbs %} + {% include "elements/breadcrumbs/contacts_base.html" with subpage=True %} +
  • + {% include "core/manager/contacts/contact_person_form_page_name.html" %} +
  • +{% endblock breadcrumbs %} + +{% block nitty %} + {% include "admin/core/manager/contacts/related_info.html" with contact_people=contact_people %} +{% endblock nitty %} + +{% block gritty_h2 %} + {% trans "Contact details" %} +{% endblock gritty_h2 %} + +{% block gritty_form %} + {% include "admin/elements/translations/form_tabs.html" with object=contact_person %} + {% if contact_person %} +

    Make your changes to the contact, and then select Save.

    + {% else %} +

    Enter contact details, and select Save to create the contact.

    + {% endif %} +
    + {% include "admin/elements/layout/key_value_above.html" with key="Name" value=account.full_name %} + {% include "admin/elements/layout/key_value_above.html" with key="Email" value=account.email %} +
    + {% if contact_person %} +

    To change the name or email, please edit the corresponding user account.

    + {% endif %} +
    + {% include "admin/elements/forms/field.html" with field=form.role %} + {% include "admin/elements/forms/field.html" with field=form.sequence %} +
    +
    + {% include "elements/button_save.html" %} + {% url "core_contact_people" as cancel_url %} + {% include "elements/a_cancel.html" with href=cancel_url %} +
    +{% endblock gritty_form %} diff --git a/src/templates/admin/core/manager/contacts/contact_person_form_page_name.html b/src/templates/admin/core/manager/contacts/contact_person_form_page_name.html new file mode 100644 index 0000000000..0a8ebf8c28 --- /dev/null +++ b/src/templates/admin/core/manager/contacts/contact_person_form_page_name.html @@ -0,0 +1,9 @@ +{% if contact_person %} + {% blocktrans with person_name=account.full_name %} + Update contact details for {{ person_name }} + {% endblocktrans %} +{% else %} + {% blocktrans with person_name=account.full_name %} + Make {{ person_name }} a contact person + {% endblocktrans %} +{% endif %} diff --git a/src/templates/admin/core/manager/contacts/index.html b/src/templates/admin/core/manager/contacts/index.html index 3ad40a8b85..a15a6ab39e 100644 --- a/src/templates/admin/core/manager/contacts/index.html +++ b/src/templates/admin/core/manager/contacts/index.html @@ -1,14 +1,14 @@ {% extends "admin/core/base.html" %} {% load foundation %} {% load static %} +{% load fqdn %} +{% load next_url %} -{% block title %}Contact Manager{% endblock title %} -{% block title-section %}Contact Manager{% endblock %} +{% block title %}Contact People{% endblock title %} +{% block title-section %}Contact People{% endblock %} {% block breadcrumbs %} - {{ block.super }} -
  • Manager
  • -
  • Contact Manager
  • + {% include "elements/breadcrumbs/contacts_base.html" %} {% endblock %} {% block body %} @@ -16,48 +16,68 @@

    - Journal Contacts are listed below. You can alter their order by dragging and dropping them into the + Contact people are listed below. You can alter their order by dragging and dropping them into the order you require.

    +

    + The contact link takes users to the contact page with the designated contact person preselected. +

    {% csrf_token %} - - + + + - + {% for contact in contacts %} - - + + + {% empty %} {% endfor %} @@ -82,11 +102,11 @@

    Current Contacts

    $.ajax({ data: data, type: 'POST', - url: '{% url 'core_journal_contacts_order' %}' + url: '{% url 'core_contact_people_reorder' %}' }); } }); $("#sortable").disableSelection(); - {% include "admin/elements/post_check.html" %} + {% include "admin/elements/button_copy_js.html" %} {% endblock js %} diff --git a/src/templates/admin/core/manager/contacts/manage.html b/src/templates/admin/core/manager/contacts/manage.html index d8796c0731..02b7a56b53 100644 --- a/src/templates/admin/core/manager/contacts/manage.html +++ b/src/templates/admin/core/manager/contacts/manage.html @@ -2,13 +2,12 @@ {% load foundation %} {% load static %} -{% block title %}Contact Manager{% endblock title %} -{% block title-section %}Contact Manager{% endblock %} +{% block title %}Contact People{% endblock title %} +{% block title-section %}Contact People{% endblock %} {% block breadcrumbs %} {{ block.super }}
  • Manager
  • -
  • Contact Manager
  • {% if contact %}Edit Contact{% else %}Add New Contact{% endif %}
  • {% endblock %} @@ -17,7 +16,6 @@

    {% if contact %}Edit Contact{% else %}Add New Contact{% endif %}

    - < Back
    {% include "admin/elements/translations/form_tabs.html" with object=contact %} diff --git a/src/templates/admin/core/manager/contacts/message.html b/src/templates/admin/core/manager/contacts/message.html new file mode 100644 index 0000000000..8e5cd680a8 --- /dev/null +++ b/src/templates/admin/core/manager/contacts/message.html @@ -0,0 +1,40 @@ +{% extends "admin/elements/nitty_gritty.html" %} + +{% load next_url %} + +{% block contextual_title %} + {{ log_entry.email_subject|truncatewords:5 }} +{% endblock contextual_title %} + +{% block title-section %} + {{ log_entry.email_subject|truncatewords:5 }} +{% endblock title-section %} + +{% block title-sub %} +
    + {% url "core_contact_messages" as back_url %} + {% with label="Back to contact messages" href=back_url %} + {% include "elements/a_back.html" %} + {% endwith %} +
    +{% endblock title-sub %} + +{% block breadcrumbs %} + {% include "elements/breadcrumbs/contact_messages_base.html" with subpage=True %} +
  • {{ log_entry.email_subject|truncatewords:5 }}
  • +{% endblock breadcrumbs %} + +{% block nitty %} + {% include "elements/layout/key_value_above.html" with key="From" value=log_entry.from_email %} + {% include "elements/layout/key_value_above.html" with key="Subject" value=log_entry.email_subject %} + {% include "elements/layout/key_value_above.html" with key="Date" value=log_entry.date %} + {% include "elements/layout/key_value_above.html" with key="Website" value=log_entry.target.name %} +{% endblock nitty %} + +{% block gritty_h2 %} + {% trans "Contact message" %} +{% endblock gritty_h2 %} + +{% block gritty %} + {{ log_entry.description|safe }} +{% endblock gritty %} diff --git a/src/templates/admin/core/manager/contacts/message_confirm_delete.html b/src/templates/admin/core/manager/contacts/message_confirm_delete.html new file mode 100644 index 0000000000..08f3072678 --- /dev/null +++ b/src/templates/admin/core/manager/contacts/message_confirm_delete.html @@ -0,0 +1,19 @@ +{% extends "admin/elements/confirm_delete.html" %} + +{% load i18n next_url static %} + +{% block breadcrumbs %} + {{ block.super }} + {% include "elements/breadcrumbs/contact_messages_base.html" with subpage=True %} +
  • + {% blocktrans with thing_to_delete=thing_to_delete|truncatewords:5 %} + Delete "{{ thing_to_delete }}" + {% endblocktrans %} +
  • +{% endblock breadcrumbs %} + +{% block nitty %} + {% include "elements/layout/key_value_above.html" with key="From" value=log_entry.from_email %} + {% include "elements/layout/key_value_above.html" with key="Date" value=log_entry.date %} + {% include "elements/layout/key_value_above.html" with key="Website" value=log_entry.target.name %} +{% endblock nitty %} diff --git a/src/templates/admin/core/manager/contacts/message_list.html b/src/templates/admin/core/manager/contacts/message_list.html new file mode 100644 index 0000000000..052527dea8 --- /dev/null +++ b/src/templates/admin/core/manager/contacts/message_list.html @@ -0,0 +1,82 @@ +{% extends "admin/core/base.html" %} +{% load next_url %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Manager
  • +
  • Contact Messages
  • +{% endblock %} + +{% block title %}Contact Messages | {{ block.super }}{% endblock %} +{% block title-section %}Contact Messages{% endblock %} + +{% block body %} +
    +
    + {% include "admin/elements/list_filters.html" %} +
    +
    +

    Help

    +
    +
      + {% if contact_person %} +
    • This page lists all contact messages sent to you as a + contact person for {{ request.site_type.name }}.
    • + {% else %} +
    • You are not currently a contact person for + {{ request.site_type.name }}.
    • + {% endif %} +
    • To change who can be contacted, go to Contact + People from the Manager.
    • + {% if request.journal %} +
    • If you are looking for an older contact message not listed here, + please contact your press manager.
    • + {% endif %} +
    +
    +
    +
    +
    +

    Contact Messages

    +
    +
    + {% include "common/elements/sorting.html" with form_id=facet_form.id %} +
    +
    Contact NameEmail AddressNameEmail RoleLink EditDeleteRemove
    {{ contact.name }}{{ contact.email }}{{ contact.display_name }}{{ contact.display_email }} {{ contact.role }} -  Edit Contact + {% if contact.account %} + {% if request.journal %} + {% site_url "journal_contact_with_recipient" contact.pk as content %} + {% else %} + {% site_url "press_contact_with_recipient" contact.pk as content %} + {% endif %} + {% trans "Copy link" as label_copy %} + {% trans "Link copied" as label_copied %} + {% include "admin/elements/button_copy.html" with content=content label_copy=label_copy label_copied=label_copied size="small" %} + {% endif %} + +
    + {% url 'core_contact_person_update' contact.pk as href %} + {% include "admin/elements/a_edit.html" with href=href %} +
    - +
    + {% url_with_return "core_contact_person_delete" contact.pk as href %} + {% include "admin/elements/a_remove.html" with href=href size="small" %} +
    - This journal has no contacts. Add a new contact. + This site has no contacts.
    + + + + + + + + + + {% for entry in logentry_list %} + + + + + + + + + + {% endfor %} +
    Log IDFromSubjectMessageDateViewDelete
    {{ entry.pk }}{{ entry.actor_email }}{{ entry.email_subject }}{{ entry.description|safe|truncatewords:10 }}{{ entry.date }} + + View + + + + Delete + +
    + {% include "common/elements/pagination.html" with form_id=facet_form.id %} +
    +
    +{% endblock body %} diff --git a/src/templates/admin/core/manager/contacts/related_info.html b/src/templates/admin/core/manager/contacts/related_info.html new file mode 100644 index 0000000000..53ee235075 --- /dev/null +++ b/src/templates/admin/core/manager/contacts/related_info.html @@ -0,0 +1,25 @@ +
    + {% if request.journal %} + {% include "admin/elements/layout/key_value_above.html" with key="Journal" value=request.journal.name %} + {% else %} + {% include "admin/elements/layout/key_value_above.html" with key="Press" value=request.press.name %} + {% endif %} + {% if contact_people %} +
    +
    + Current contacts +
    +
    +
      + {% for person in contact_people %} +
    • +
      {{ person.account.full_name }}
      +
      {{ person.role }}
      +
      {{ person.account.pretty_wrapping_email }}
      +
    • + {% endfor %} +
    +
    +
    + {% endif %} +
    diff --git a/src/templates/admin/core/manager/contacts/search_potential.html b/src/templates/admin/core/manager/contacts/search_potential.html new file mode 100644 index 0000000000..17a3f2d387 --- /dev/null +++ b/src/templates/admin/core/manager/contacts/search_potential.html @@ -0,0 +1,74 @@ +{% extends "admin/elements/nitty_gritty.html" %} + +{% load i18n static next_url %} + +{% block contextual_title %} + {% trans "Add Contact Person" %} +{% endblock contextual_title %} + +{% block title-section %} + {% trans "Add Contact Person" %} +{% endblock title-section %} + +{% block breadcrumbs %} + {% include "elements/breadcrumbs/contacts_base.html" with subpage=True %} +
  • {% trans "Add Contact Person" %}
  • +{% endblock breadcrumbs %} + +{% block nitty %} + {% include "admin/core/manager/contacts/related_info.html" with contact_people=contact_people %} +{% endblock nitty %} + +{% block gritty_h2 %} + {% trans "Search by name or email" %} +{% endblock gritty_h2 %} + +{% block gritty %} + {% include "admin/elements/forms/messages_in_callout.html" with form=form %} + {% include "admin/elements/list_search.html" with placeholder='e.g. Kathryn Janeway' %} + {% if request.journal %} +

    Only accounts with the role of editor, section editor, or press manager are searchable here. Press staff can also be journal contacts.

    + {% else %} +

    Only staff members are searchable here.

    + {% endif %} + {% if request.GET.q %} + {% for account in account_list %} +
    +
    +
    +

    {{ account.full_name }}

    +
    + {% include "admin/elements/layout/key_value_above.html" with key="Email" value=account.email %} + {% if account.primary_affiliation %} + {% include "admin/elements/layout/key_value_above.html" with key="Affiliation" value=account.primary_affiliation %} + {% endif %} +
    +
    + {% if account.is_contact_person %} +
    +
    + + Already a contact person +
    +
    + {% else %} +
    + {% url_with_next 'core_contact_person_create' account.pk as create_url %} + {% trans "Make contact person" as create_label %} + {% include "elements/a_create.html" with href=create_url label=create_label %} +
    + {% endif %} +
    +
    + {% empty %} +

    {% trans 'No people to display.' %}

    + {% endfor %} +
    +

    {% trans "User account not found?" %}

    + {% url_with_return 'core_add_user' as create_url %} + {% trans "Create user account" as create_label %} + {% include "elements/a_create.html" with href=create_url label=create_label %} +
    + {% include "common/elements/pagination.html" with form_id=facet_form.id %} + {% endif %} +{% endblock gritty %} diff --git a/src/templates/admin/core/manager/index.html b/src/templates/admin/core/manager/index.html index f3e94a187c..2d7afac067 100644 --- a/src/templates/admin/core/manager/index.html +++ b/src/templates/admin/core/manager/index.html @@ -72,7 +72,8 @@

    Content

    Content Manager Media Files Submission Page Items - Journal Contacts + Contact People + Contact Messages Editorial Team diff --git a/src/templates/admin/core/manager/users/edit.html b/src/templates/admin/core/manager/users/edit.html index 65f73c991f..fcd9287f2d 100644 --- a/src/templates/admin/core/manager/users/edit.html +++ b/src/templates/admin/core/manager/users/edit.html @@ -51,11 +51,13 @@

    {% trans "Email and account access" %}

    {% include "elements/forms/field.html" with field=registration_form.is_staff %} {% include "elements/forms/field.html" with field=registration_form.is_admin %} {% include "elements/forms/field.html" with field=registration_form.is_superuser %} - {% if active == 'add' %} + + {% if active == 'add' %} +
    {% include "elements/forms/field.html" with field=registration_form.password_1 %} {% include "elements/forms/field.html" with field=registration_form.password_2 %} - {% endif %} -
    + + {% endif %}

    {% trans "Profile details" %}

    diff --git a/src/templates/admin/elements/breadcrumbs/contact_messages_base.html b/src/templates/admin/elements/breadcrumbs/contact_messages_base.html new file mode 100644 index 0000000000..df8e344629 --- /dev/null +++ b/src/templates/admin/elements/breadcrumbs/contact_messages_base.html @@ -0,0 +1,2 @@ +
  • Manager
  • +
  • Contact Messages
  • diff --git a/src/templates/admin/elements/breadcrumbs/contacts_base.html b/src/templates/admin/elements/breadcrumbs/contacts_base.html new file mode 100644 index 0000000000..3c87a11b49 --- /dev/null +++ b/src/templates/admin/elements/breadcrumbs/contacts_base.html @@ -0,0 +1,14 @@ +
  • + + {% trans "Manager" %} + +
  • +
  • + {% if subpage %} + + {% trans "Contact People" %} + + {% else %} + {% trans "Contact People" %} + {% endif %} +
  • diff --git a/src/templates/admin/elements/button_copy.html b/src/templates/admin/elements/button_copy.html index c0f3084a6e..f2f947f714 100644 --- a/src/templates/admin/elements/button_copy.html +++ b/src/templates/admin/elements/button_copy.html @@ -8,10 +8,11 @@ {% endcomment %}
    -
    +
    +
    +
    + diff --git a/src/templates/admin/install/next.html b/src/templates/admin/install/next.html index ab4f1a7737..1a9844867d 100644 --- a/src/templates/admin/install/next.html +++ b/src/templates/admin/install/next.html @@ -13,7 +13,7 @@

    Next Steps

    -{% endblock body %} \ No newline at end of file +{% endblock body %} diff --git a/src/templates/admin/press/press_manager_index.html b/src/templates/admin/press/press_manager_index.html index 60600b84ac..d898cbd9b4 100644 --- a/src/templates/admin/press/press_manager_index.html +++ b/src/templates/admin/press/press_manager_index.html @@ -23,7 +23,8 @@

    Settings

    Media Manager News Manager All Users (New) - Contact Manager + Contact People + Contact Messages Edit Press Details Edit journal default settings Homepage Elements diff --git a/src/templates/admin/repository/log.html b/src/templates/admin/repository/log.html index 4e7b473e89..9113495245 100644 --- a/src/templates/admin/repository/log.html +++ b/src/templates/admin/repository/log.html @@ -28,7 +28,7 @@

    Log Entries

    Entry Type - Addressees + Addressees Information Date Actor diff --git a/src/templates/common/site_map_index.xml b/src/templates/common/site_map_index.xml index 5ea60c5a9c..6aa225fba9 100644 --- a/src/templates/common/site_map_index.xml +++ b/src/templates/common/site_map_index.xml @@ -30,7 +30,7 @@ {% if journal_settings.general.enable_editorial_display %} {% include "common/sitemap_url.xml" with obj=press url_name='editorial_team' label="Editorial team" %} {% endif %} - {% include "common/sitemap_url.xml" with obj=press url_name='contact' label="Contact" %} + {% include "common/sitemap_url.xml" with obj=press url_name='press_contact' label="Contact" %} {% include "common/sitemap_url.xml" with obj=press url_name='core_login' label="Log in" %} {% include "common/sitemap_url.xml" with obj=press url_name='core_register' label="Register" %} {% for news_item in press.active_news_items %} diff --git a/src/themes/OLH/templates/journal/contact.html b/src/themes/OLH/templates/journal/contact.html index e07bb2cb06..798737047b 100644 --- a/src/themes/OLH/templates/journal/contact.html +++ b/src/themes/OLH/templates/journal/contact.html @@ -15,7 +15,7 @@

    {% trans "Journal Representatives" %}

    {% trans "Press Representatives" %}

    {% endif %} {% for contact in contacts %} -

    {{ contact.name }}

    +

    {{ contact.display_name }}

    {{ contact.role }}

    {% endfor %} {% if journal_settings.general.contact_info %} @@ -28,12 +28,8 @@

    {% trans "Contact" %}

    {% include "elements/forms/errors.html" with form=contact_form %} {% csrf_token %} - - {% include "admin/elements/forms/required_field_info.html" %} + {{ contact_form.contact_person|foundation }} {{ contact_form.sender|foundation }} {{ contact_form.subject|foundation }} {{ contact_form.body|foundation }} diff --git a/src/themes/OLH/templates/press/elements/press_footer.html b/src/themes/OLH/templates/press/elements/press_footer.html index 0b056ab3f4..b573ac0836 100644 --- a/src/themes/OLH/templates/press/elements/press_footer.html +++ b/src/themes/OLH/templates/press/elements/press_footer.html @@ -14,6 +14,7 @@ {% endif %}
  • {% trans "Sitemap" %}
  • {% trans "Contact" %}
  • +
  • {% trans "Contact" %}
  • {% trans "Accessibility" %}
  • {% if not request.user.is_authenticated %}
  • diff --git a/src/themes/OLH/templates/press/journal/contact.html b/src/themes/OLH/templates/press/journal/contact.html index 1170d61caa..8af140c10f 100644 --- a/src/themes/OLH/templates/press/journal/contact.html +++ b/src/themes/OLH/templates/press/journal/contact.html @@ -11,7 +11,7 @@

    {% trans 'Contact us' %}

    {% trans "Press Representatives" %}

    {% for contact in contacts %} -

    {{ contact.name }}

    +

    {{ contact.display_name }}

    {{ contact.role }}

    {% endfor %}
  • @@ -20,11 +20,8 @@

    {% trans "Contact" %}

    {% include "elements/forms/errors.html" with form=contact_form %} {% csrf_token %} - - {% include "admin/elements/forms/required_field_info.html" %} + {{ contact_form.contact_person|foundation }} {{ contact_form.sender|foundation }} {{ contact_form.subject|foundation }} {{ contact_form.body|foundation }} diff --git a/src/themes/OLH/templates/press/nav.html b/src/themes/OLH/templates/press/nav.html index d02fe55276..96d73315d9 100644 --- a/src/themes/OLH/templates/press/nav.html +++ b/src/themes/OLH/templates/press/nav.html @@ -38,7 +38,11 @@ {% endif %} {% if journal_settings.general.enable_editorial_display %}
  • {% trans 'Editorial Team' %}
  • {% endif %} -
  • {% trans 'Contact' %}
  • +
  • + + {% trans 'Contact' %} + +
  • {% hook 'nav_block' %}
  • {% trans 'Account' %} @@ -62,4 +66,4 @@
  • - \ No newline at end of file + diff --git a/src/themes/clean/templates/elements/press_footer.html b/src/themes/clean/templates/elements/press_footer.html index bad8362925..5d82643b4d 100644 --- a/src/themes/clean/templates/elements/press_footer.html +++ b/src/themes/clean/templates/elements/press_footer.html @@ -16,7 +16,11 @@ href="{% url 'cms_page' 'privacy' %}">{% trans "Privacy Policy" %} {% endif %}
  • {% trans "Sitemap" %}
  • -
  • {% trans "Contact" %}
  • +
  • + + {% trans "Contact" %} + +
  • {% trans "Accessibility" %}
  • {% if not request.user.is_authenticated %}
  • diff --git a/src/themes/clean/templates/journal/contact.html b/src/themes/clean/templates/journal/contact.html index 9dd27efef8..09b1e6b211 100644 --- a/src/themes/clean/templates/journal/contact.html +++ b/src/themes/clean/templates/journal/contact.html @@ -11,7 +11,7 @@

    {% trans 'Contact us' %}

    {% for contact in contacts %} -

    {{ contact.name }}

    +

    {{ contact.display_name }}

    {{ contact.role }}

    {% endfor %} {% if journal_settings.general.contact_info %} @@ -24,14 +24,7 @@

    {% trans "Contact" %}

    {% include "elements/forms/errors.html" with form=contact_form %} {% csrf_token %} -
    - - -
    + {% bootstrap_field contact_form.contact_person %} {% bootstrap_field contact_form.sender %} {% bootstrap_field contact_form.subject %} {% bootstrap_field contact_form.body %} diff --git a/src/themes/clean/templates/press/nav.html b/src/themes/clean/templates/press/nav.html index e4b3404ce9..2458b7658d 100644 --- a/src/themes/clean/templates/press/nav.html +++ b/src/themes/clean/templates/press/nav.html @@ -51,7 +51,11 @@
  • {% endif %} - + {% hook 'nav_block' %}