__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" from django.contrib import messages from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import UserPassesTestMixin from django.core.exceptions import PermissionDenied from django.template.response import TemplateResponse from django.urls import reverse_lazy from django.db import transaction from django.db.models import Q from django.shortcuts import get_object_or_404, render, redirect from django.urls import reverse from django.utils import timezone from django.utils.html import format_html from django.views.generic.detail import DetailView from django.views.generic.edit import CreateView, UpdateView, DeleteView from django.views.generic.list import ListView from dal import autocomplete from guardian.decorators import permission_required from .constants import ( ORGTYPE_PRIVATE_BENEFACTOR, ORGANIZATION_EVENT_COMMENT, ORGANIZATION_EVENT_EMAIL_SENT, ) from .forms import ( SelectOrganizationForm, OrganizationForm, OrganizationEventForm, ContactPersonForm, NewContactForm, ContactActivationForm, ContactRoleForm, ) from .models import Organization, OrganizationEvent, ContactPerson, Contact, ContactRole from funders.models import Funder from mails.utils import DirectMailUtil from mails.views import MailEditorSubview from organizations.decorators import has_contact from organizations.models import Organization from organizations.utils import RORAPIHandler from scipost.mixins import PermissionsMixin, PaginationMixin from scipost.permissions import permission_required_htmx ###################### # Autocomplete views # ###################### class OrganizationAutocompleteView(autocomplete.Select2QuerySetView): """ View to feed the Select2 widget. Flags of the organizations are displayed in the selection list; the stylesheet flags/sprite-hq.css from app django-countries must be accessible on the page for the flag to be displayed properly; we include it centrally in static and put this in the page head: .. code-block:: html <link rel="stylesheet" href="{% static 'flags/sprite-hq.css' %}"> The data-html attribute has to be set to True on all widgets, e.g. .. code-block:: python organization = forms.ModelChoiceField( queryset=Organization.objects.all(), widget=autocomplete.ModelSelect2( url='/organizations/organization-autocomplete', attrs={'data-html': True} ) ) """ def get_queryset(self): qs = Organization.objects.all() if self.q: qs = qs.filter( Q(name__icontains=self.q) | Q(name_original__icontains=self.q) | Q(acronym__icontains=self.q) | Q(ror_json__names__contains=[{"value": self.q}]) # Search ROR ) return qs def get_result_label(self, item): return format_html( '<span><i class="{}" data-bs-toggle="tooltip" title="{}"></i> {}</span>', item.country.flag_css, item.country.name, item.name, ) class OrganizationCreateView(PermissionsMixin, CreateView): """ Create a new Organization. """ permission_required = "scipost.can_manage_organizations" form_class = OrganizationForm template_name = "organizations/organization_create.html" success_url = reverse_lazy("organizations:organizations") class OrganizationUpdateView(PermissionsMixin, UpdateView): """ Update an Organization. """ permission_required = "scipost.can_manage_organizations" model = Organization form_class = OrganizationForm template_name = "organizations/organization_update.html" success_url = reverse_lazy("organizations:organizations") class OrganizationDeleteView(PermissionsMixin, DeleteView): """ Delete an Organization. """ permission_required = "scipost.can_manage_organizations" model = Organization success_url = reverse_lazy("organizations:organizations") class OrganizationListView(PaginationMixin, ListView): model = Organization paginate_by = 50 def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) if self.request.user.has_perm("scipost.can_manage_organizations"): context["nr_funders_wo_organization"] = Funder.objects.filter( organization=None ).count() context["pubyears"] = range(int(timezone.now().strftime("%Y")), 2015, -1) context["countrycodes"] = [ code["country"] for code in list( Organization.objects.all().distinct("country").values("country") ) ] context["select_organization_form"] = SelectOrganizationForm() return context def get_queryset(self): qs = super().get_queryset().exclude(orgtype=ORGTYPE_PRIVATE_BENEFACTOR) country = self.request.GET.get("country") order_by = self.request.GET.get("order_by") ordering = self.request.GET.get("ordering") if country: qs = qs.filter(country=country) if order_by == "country": qs = qs.order_by("country") elif order_by == "name": qs = qs.order_by("name") elif order_by == "nap": qs = qs.exclude(cf_nr_associated_publications__isnull=True).order_by( "cf_nr_associated_publications" ) if ordering == "desc": qs = qs.reverse() return qs.prefetch_related("logos") def get_organization_detail(request): org_id = request.GET.get("organization", None) if org_id: return redirect( reverse("organizations:organization_detail", kwargs={"pk": org_id}) ) return redirect(reverse("organizations:organizations")) class OrganizationDetailView(DetailView): model = Organization def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) context["pubyears"] = range(int(timezone.now().strftime("%Y")), 2015, -1) # context["balance"] = self.object.get_balance_info() context["balance"] = self.object.cf_balance_info return context def get_queryset(self): """ Restrict view to permitted people if Organization details not publicly viewable. """ queryset = super().get_queryset() if not self.request.user.has_perm("scipost.can_manage_organizations"): queryset = queryset.exclude(orgtype=ORGTYPE_PRIVATE_BENEFACTOR) # return queryset return queryset.select_related("parent").prefetch_related( "children", "subsidy_set", "contactperson_set", "contactrole_set", "funder_set", "organizationevent_set", "pubfractions", ) class OrganizationEventCreateView(PermissionsMixin, CreateView): permission_required = "scipost.can_manage_organizations" model = OrganizationEvent form_class = OrganizationEventForm template_name = "organizations/organizationevent_form.html" def get_initial(self): organization = get_object_or_404(Organization, pk=self.kwargs.get("pk")) return { "organization": organization, "noted_on": timezone.now, "noted_by": self.request.user, } def get_success_url(self): return reverse_lazy( "organizations:organization_detail", kwargs={"pk": self.object.organization.id}, ) class OrganizationEventListView(PermissionsMixin, PaginationMixin, ListView): permission_required = "scipost.can_manage_organizations" model = OrganizationEvent paginate_by = 10 class ContactPersonCreateView(PermissionsMixin, CreateView): permission_required = "scipost.can_add_contactperson" model = ContactPerson form_class = ContactPersonForm template_name = "organizations/contactperson_form.html" def get_initial(self): try: organization = Organization.objects.get( pk=self.kwargs.get("organization_id") ) return {"organization": organization} except Organization.DoesNotExist: pass def form_valid(self, form): event = OrganizationEvent( organization=form.cleaned_data["organization"], event=ORGANIZATION_EVENT_COMMENT, comments=( "Added ContactPerson: %s %s" % (form.cleaned_data["first_name"], form.cleaned_data["last_name"]) ), noted_on=timezone.now(), noted_by=self.request.user, ) event.save() return super().form_valid(form) def get_success_url(self): return reverse_lazy( "organizations:organization_detail", kwargs={"pk": self.object.organization.id}, ) class ContactPersonUpdateView(PermissionsMixin, UpdateView): permission_required = "scipost.can_add_contactperson" model = ContactPerson form_class = ContactPersonForm template_name = "organizations/contactperson_form.html" def form_valid(self, form): event = OrganizationEvent( organization=form.cleaned_data["organization"], event=ORGANIZATION_EVENT_COMMENT, comments=( "Updated ContactPerson: %s %s" % (form.cleaned_data["first_name"], form.cleaned_data["last_name"]) ), noted_on=timezone.now(), noted_by=self.request.user, ) event.save() return super().form_valid(form) def get_success_url(self): return reverse_lazy( "organizations:organization_detail", kwargs={"pk": self.object.organization.id}, ) class ContactPersonDeleteView(UserPassesTestMixin, DeleteView): model = ContactPerson def test_func(self): """ Allow ContactPerson delete to OrgAdmins and all Contacts for this Organization. """ if self.request.user.has_perm("scipost.can_manage_organizations"): return True contactperson = get_object_or_404(ContactPerson, pk=self.kwargs.get("pk")) return self.request.user.has_perm( "can_view_org_contacts", contactperson.organization ) def get_success_url(self): return reverse_lazy( "organizations:organization_detail", kwargs={"pk": self.object.organization.id}, ) class ContactPersonListView(PermissionsMixin, ListView): permission_required = "scipost.can_add_contactperson" model = ContactPerson @permission_required("scipost.can_manage_organizations", return_403=True) @transaction.atomic def email_contactperson(request, contactperson_id, mail=None): contactperson = get_object_or_404(ContactPerson, pk=contactperson_id) suffix = "" if mail == "followup": mail_code = "org_contacts/contactperson_followup_mail" suffix = " (followup)" else: mail_code = "org_contacts/contactperson_initial_mail" suffix = " (initial)" mail_request = MailEditorSubview(request, mail_code, contactperson=contactperson) if mail_request.is_valid(): comments = "Email{suffix} sent to ContactPerson {name}.".format( suffix=suffix, name=contactperson ) event = OrganizationEvent( organization=contactperson.organization, event=ORGANIZATION_EVENT_EMAIL_SENT, comments=comments, noted_on=timezone.now(), noted_by=request.user, ) event.save() messages.success(request, "Email successfully sent.") mail_request.send_mail() return redirect(contactperson.organization.get_absolute_url()) else: return mail_request.interrupt() @permission_required("scipost.can_manage_organizations", return_403=True) def organization_add_contact(request, organization_id, contactperson_id=None): organization = get_object_or_404(Organization, id=organization_id) if contactperson_id: contactperson = get_object_or_404(ContactPerson, id=contactperson_id) initial = { "title": contactperson.title, "first_name": contactperson.first_name, "last_name": contactperson.last_name, "email": contactperson.email, } else: contactperson = None initial = {} form = NewContactForm( request.POST or None, initial=initial, organization=organization, contactperson=contactperson, ) if form.is_valid(): contact = form.save(current_user=request.user) mail_sender = DirectMailUtil( "org_contacts/email_contact_for_activation", contact=contact ) mail_sender.send_mail() for role in contact.roles.all(): event = OrganizationEvent( organization=role.organization, event=ORGANIZATION_EVENT_COMMENT, comments=("Contact for %s created; activation pending" % str(contact)), noted_on=timezone.now(), noted_by=request.user, ) event.save() messages.success( request, "<h3>Created contact: %s</h3>Email has been sent." % str(contact) ) return redirect( reverse("organizations:organization_detail", kwargs={"pk": organization.id}) ) context = {"organization": organization, "form": form} return render(request, "organizations/organization_add_contact.html", context) def activate_account(request, activation_key): contact = get_object_or_404( Contact, user__is_active=False, activation_key=activation_key, user__email__icontains=request.GET.get("email", None), ) # TODO: Key Expires fallback form = ContactActivationForm(request.POST or None, instance=contact.user) if form.is_valid(): form.activate_user() for role in contact.roles.all(): event = OrganizationEvent( organization=role.organization, event=ORGANIZATION_EVENT_COMMENT, comments=("Contact %s activated their account" % str(contact)), noted_on=timezone.now(), noted_by=contact.user, ) event.save() messages.success(request, "<h3>Thank you for activating your account</h3>") return redirect(reverse("organizations:dashboard")) context = {"contact": contact, "form": form} return render(request, "organizations/activate_account.html", context) @login_required def dashboard(request): """ Administration page for Organization Contacts. This page is meant as a personal page for Contacts, where they will for example be able to read their personal data and agreements. """ if not ( request.user.has_perm("scipost.can_manage_organizations") or has_contact(request.user) ): raise PermissionDenied context = {"contacts": Contact.objects.all()} if has_contact(request.user): context["own_roles"] = request.user.org_contact.roles.all() return render(request, "organizations/dashboard.html", context) class ContactDetailView(PermissionsMixin, DetailView): """ View details of a Contact. Accessible to Admin. """ permission_required = "scipost.can_manage_organizations" model = Contact class ContactRoleUpdateView(UserPassesTestMixin, UpdateView): """ Update a ContactRole. """ model = ContactRole form_class = ContactRoleForm template_name = "organizations/contactrole_form.html" def test_func(self): """ Allow ContactRole update to OrgAdmins and all Contacts for this Organization. """ if self.request.user.has_perm("scipost.can_manage_organizations"): return True contactrole = get_object_or_404(ContactRole, pk=self.kwargs.get("pk")) return self.request.user.has_perm( "can_view_org_contacts", contactrole.organization ) def get_success_url(self): return reverse_lazy( "organizations:organization_detail", kwargs={"pk": self.object.organization.id}, ) class ContactRoleDeleteView(PermissionsMixin, DeleteView): """ Delete a ContactRole. """ permission_required = "scipost.can_manage_organizations" model = ContactRole def get_success_url(self): return reverse_lazy( "organizations:organization_detail", kwargs={"pk": self.object.organization.id}, ) @permission_required("scipost.can_manage_organizations", return_403=True) @transaction.atomic def email_contactrole(request, contactrole_id, mail=None): contactrole = get_object_or_404(ContactRole, pk=contactrole_id) suffix = "" if mail == "renewal": mail_code = "org_contacts/contactrole_subsidy_renewal_mail" suffix = " (subsidy renewal query)" else: mail_code = "org_contacts/contactrole_generic_mail" suffix = " (generic)" mail_request = MailEditorSubview(request, mail_code, contactrole=contactrole) if mail_request.is_valid(): comments = "Email{suffix} sent to Contact {name}.".format( suffix=suffix, name=contactrole.contact ) event = OrganizationEvent( organization=contactrole.organization, event=ORGANIZATION_EVENT_EMAIL_SENT, comments=comments, noted_on=timezone.now(), noted_by=request.user, ) event.save() messages.success(request, "Email successfully sent.") mail_request.send_mail() return redirect(contactrole.organization.get_absolute_url()) else: return mail_request.interrupt() ### ROR ### @permission_required_htmx("scipost.can_manage_organizations") def _hx_ror_search_form(request, pk): """ Helper function to generate the form for ROR search. """ from organizations.forms import RORSearchForm organization = get_object_or_404(Organization, id=pk) form = RORSearchForm(query=organization.name) return TemplateResponse( request, "organizations/_hx_ror_search_form.html", {"organization": organization, "form": form}, ) @permission_required_htmx("scipost.can_manage_organizations") # @for_htmx(use_block_from_params=True) def _hx_ror_search_results(request, pk): """ Perform ROR search and return results. """ ror_api_handler = RORAPIHandler() results = ror_api_handler.query(request.POST["query"]) return TemplateResponse( request, "organizations/_hx_ror_search_results.html", {"pk": pk, "results": results}, ) @permission_required_htmx("scipost.can_manage_organizations") # @for_htmx(use_block_from_params=True) def _hx_add_ror(request, pk, ror_id): """ Get ROR data and add it to the organization. """ organization = get_object_or_404(Organization, id=pk) ror_api_handler = RORAPIHandler() if ror_id == "None": organization.ror_json = {} else: ror_data = ror_api_handler.from_id(ror_id) organization.ror_json = ror_data organization.save() return TemplateResponse( request, "organizations/_hx_organization_info.html", {"org": organization}, )