diff --git a/peachjam/adapters/adapters.py b/peachjam/adapters/adapters.py index 85af368ab..7375c3882 100644 --- a/peachjam/adapters/adapters.py +++ b/peachjam/adapters/adapters.py @@ -111,6 +111,10 @@ def get_doc_list(self): while url: res = self.client_get(url).json() + # ignore bills + # TODO: later, make this configurable + res["results"] = [r for r in res["results"] if r["nature"] != "bill"] + # Filter by actor, if setting is present actor = self.settings.get("actor", None) if actor: diff --git a/peachjam/admin.py b/peachjam/admin.py index 1d3cd099e..dd60a116e 100644 --- a/peachjam/admin.py +++ b/peachjam/admin.py @@ -731,14 +731,14 @@ class JudgmentAdmin(ImportExportMixin, DocumentAdmin): CaseNumberAdmin, JudgmentRelationshipStackedInline, ] + DocumentAdmin.inlines - filter_horizontal = ("judges", "attorneys") + filter_horizontal = ("judges", "attorneys", "order_outcomes") list_filter = (*DocumentAdmin.list_filter, "court") fieldsets = copy.deepcopy(DocumentAdmin.fieldsets) fieldsets[0][1]["fields"].insert(3, "court") fieldsets[0][1]["fields"].insert(4, "registry") fieldsets[0][1]["fields"].insert(5, "case_name") - fieldsets[0][1]["fields"].insert(6, "order_outcome") + fieldsets[0][1]["fields"].insert(6, "order_outcomes") fieldsets[0][1]["fields"].insert(7, "mnc") fieldsets[0][1]["fields"].insert(8, "serial_number_override") fieldsets[0][1]["fields"].insert(9, "serial_number") diff --git a/peachjam/forms.py b/peachjam/forms.py index 139d02e7b..1768ece27 100644 --- a/peachjam/forms.py +++ b/peachjam/forms.py @@ -158,7 +158,9 @@ def filter_queryset(self, queryset, exclude=None): queryset = queryset.filter(attorneys__name__in=attorneys) if order_outcomes and exclude != "order_outcomes": - queryset = queryset.filter(order_outcome__name__in=order_outcomes) + queryset = queryset.filter( + order_outcomes__name__in=order_outcomes + ).distinct() return queryset diff --git a/peachjam/models/judgment.py b/peachjam/models/judgment.py index d7f0349ad..c4e999d74 100644 --- a/peachjam/models/judgment.py +++ b/peachjam/models/judgment.py @@ -202,11 +202,8 @@ class Judgment(CoreDocument): attorneys = models.ManyToManyField( Attorney, blank=True, verbose_name=_("attorneys") ) - order_outcome = models.ForeignKey( + order_outcomes = models.ManyToManyField( OrderOutcome, - on_delete=models.PROTECT, - null=True, - related_name="judgments", blank=True, ) case_summary = models.TextField(_("case summary"), null=True, blank=True) diff --git a/peachjam/resolver.py b/peachjam/resolver.py new file mode 100644 index 000000000..c92900812 --- /dev/null +++ b/peachjam/resolver.py @@ -0,0 +1,114 @@ +from django.conf import settings + + +class RedirectResolver: + RESOLVER_MAPPINGS = { + "africanlii": { + "country_code": "aa", + "domain": "africanlii.org", + }, + "eswatinilii": { + "country_code": "sz", + "domain": "eswatinilii.org", + }, + "ghalii": { + "country_code": "gh", + "domain": "ghalii.org", + }, + "lawlibrary": { + "country_code": "za", + "domain": "lawlibrary.org.za", + }, + "leslii": { + "country_code": "ls", + "domain": "lesotholii.org", + }, + "malawilii": { + "country_code": "mw", + "domain": "malawilii.org", + }, + "mauritiuslii": { + "country_code": "mu", + "domain": "mauritiuslii.org", + }, + "namiblii": { + "country_code": "na", + "domain": "namiblii.org", + }, + "nigerialii": { + "country_code": "ng", + "domain": "nigerialii.org", + }, + "open by-laws": { + "place_code": [], + "domain": "openbylaws.org.za", + }, + "rwandalii": { + "country_code": "rw", + "domain": "rwandalii.org", + }, + "seylii": { + "country_code": "sc", + "domain": "seylii.org", + }, + "sierralii": { + "country_code": "sl", + "domain": "sierralii.org", + }, + "tanzlii": { + "country_code": "tz", + "domain": "tanzlii.org", + }, + "tcilii": { + "country_code": "tc", + "domain": "tcilii.org", + }, + "ulii": { + "country_code": "ug", + "domain": "ulii.org", + }, + "zambialii": { + "country_code": "zm", + "domain": "zambialii.org", + }, + "zanzibarlii": { + "place_code": "tz-znz", + "domain": "zanzibarlii.org", + }, + "zimlii": { + "country_code": "zw", + "domain": "zimlii.org", + }, + } + + def __init__(self, app_name): + self.current_authority = self.RESOLVER_MAPPINGS.get(app_name.lower()) + + def get_domain_for_frbr_uri(self, parsed_frbr_uri): + best_domain = self.get_best_domain(parsed_frbr_uri) + if self.current_authority and best_domain != self.current_authority["domain"]: + return best_domain + return None + + def get_url_for_frbr_uri(self, parsed_frbr_uri, raw_frbr_uri): + domain = self.get_domain_for_frbr_uri(parsed_frbr_uri) + if domain: + return f"https://{domain}{raw_frbr_uri}" + + def get_best_domain(self, parsed_uri): + country_code = parsed_uri.country + place_code = parsed_uri.place + + if country_code != place_code: + for key, mapping in self.RESOLVER_MAPPINGS.items(): + if mapping.get("place_code") == place_code: + return mapping.get("domain") + + # if no domain matching with place code is found use country code + for key, mapping in self.RESOLVER_MAPPINGS.items(): + if mapping.get("country_code") == country_code: + return mapping.get("domain") + return None + + +resolver = RedirectResolver(settings.PEACHJAM["APP_NAME"]) diff --git a/peachjam/settings.py b/peachjam/settings.py index 5e8ebaafe..56608962c 100644 --- a/peachjam/settings.py +++ b/peachjam/settings.py @@ -77,6 +77,7 @@ "drf_spectacular", "django_advanced_password_validation", "martor", + "corsheaders", ] MIDDLEWARE = [ @@ -87,6 +88,7 @@ "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.locale.LocaleMiddleware", + "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", @@ -607,3 +609,7 @@ def before_send(event, hint): } # disable the normal martor theme which pulls in another bootstrap version MARTOR_ALTERNATIVE_CSS_FILE_THEME = "martor/css/peachjam.css" + +# CORS +# disable regex matches, we do matching using signals +CORS_URLS_REGEX = r"^$" diff --git a/peachjam/templates/peachjam/document_popup.html b/peachjam/templates/peachjam/document_popup.html index 14f0d179a..f8a7b8f9c 100644 --- a/peachjam/templates/peachjam/document_popup.html +++ b/peachjam/templates/peachjam/document_popup.html @@ -7,7 +7,7 @@ {% endblock %} {% else %} -
+
{% block title %} {{ document.title }} @@ -19,13 +19,21 @@ {% endblock %}
{% block citation %} - {% if document.citation %} + {% if document.citation and document.citation != document.title %}
{{ document.citation }}
{% endif %} {% endblock %} + {% block date %}
{{ document.date }}
{% endblock %}
- {% block date %}
{{ document.date }}
{% endblock %} + {% block offsite_request %} + {% if offsite_request %} +
+ {{ APP_NAME }} +
+ {% endif %} + {% endblock %} {% endif %}
diff --git a/peachjam/templates/peachjam/judgment_detail.html b/peachjam/templates/peachjam/judgment_detail.html index a43fe7ae4..6e26aafe9 100644 --- a/peachjam/templates/peachjam/judgment_detail.html +++ b/peachjam/templates/peachjam/judgment_detail.html @@ -42,14 +42,19 @@ {{ document.registry.name }} {% endif %} - {% if document.order_outcome %} -
- {% trans 'Order' %} -
-
- {{ document.order_outcome.name }} -
- {% endif %} + {% with document.order_outcomes.all as order_outcomes %} + {% if order_outcomes %} +
+ {% trans 'Order' %} +
+
+ {% for order_outcome in order_outcomes %} + {{ order_outcome.name }} + {% if not forloop.last %},{% endif %} + {% endfor %} +
+ {% endif %} + {% endwith %} {% with document.case_numbers.all as case_numbers %} {% if case_numbers %}
diff --git a/peachjam/tests/test_resolver.py b/peachjam/tests/test_resolver.py index d170ed195..4796b4880 100644 --- a/peachjam/tests/test_resolver.py +++ b/peachjam/tests/test_resolver.py @@ -1,7 +1,7 @@ from cobalt import FrbrUri from django.test import TestCase -from peachjam.views.documents import RedirectResolver +from peachjam.resolver import RedirectResolver urls = [ "/akn/zm/judgment/zmsc/2021/7/eng@2021-01-19", diff --git a/peachjam/views/courts.py b/peachjam/views/courts.py index 05753c879..546dafb99 100644 --- a/peachjam/views/courts.py +++ b/peachjam/views/courts.py @@ -97,7 +97,7 @@ def populate_facets(self, context): self.get_base_queryset(), exclude="order_outcomes" ) .order_by() - .values_list("order_outcome__name", flat=True) + .values_list("order_outcomes__name", flat=True) .distinct() if order_outcome ) diff --git a/peachjam/views/documents.py b/peachjam/views/documents.py index 535a038a6..64a3a4560 100644 --- a/peachjam/views/documents.py +++ b/peachjam/views/documents.py @@ -1,5 +1,4 @@ from cobalt import FrbrUri -from django.conf import settings from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, redirect, reverse from django.utils.decorators import method_decorator @@ -9,111 +8,7 @@ from peachjam.helpers import add_slash, add_slash_to_frbr_uri from peachjam.models import CoreDocument from peachjam.registry import registry - - -class RedirectResolver: - RESOLVER_MAPPINGS = { - "africanlii": { - "country_code": "aa", - "domain": "africanlii.org", - }, - "eswatinilii": { - "country_code": "sz", - "domain": "eswatinilii.org", - }, - "ghalii": { - "country_code": "gh", - "domain": "ghalii.org", - }, - "lawlibrary": { - "country_code": "za", - "domain": "lawlibrary.org.za", - }, - "leslii": { - "country_code": "ls", - "domain": "lesotholii.org", - }, - "malawilii": { - "country_code": "mw", - "domain": "malawilii.org", - }, - "mauritiuslii": { - "country_code": "mu", - "domain": "mauritiuslii.org", - }, - "namiblii": { - "country_code": "na", - "domain": "namiblii.org", - }, - "nigerialii": { - "country_code": "ng", - "domain": "nigerialii.org", - }, - "open by-laws": { - "place_code": [], - "domain": "openbylaws.org.za", - }, - "rwandalii": { - "country_code": "rw", - "domain": "rwandalii.org", - }, - "seylii": { - "country_code": "sc", - "domain": "seylii.org", - }, - "sierralii": { - "country_code": "sl", - "domain": "sierralii.org", - }, - "tanzlii": { - "country_code": "tz", - "domain": "tanzlii.org", - }, - "tcilii": { - "country_code": "tc", - "domain": "tcilii.org", - }, - "ulii": { - "country_code": "ug", - "domain": "ulii.org", - }, - "zambialii": { - "country_code": "zm", - "domain": "zambialii.org", - }, - "zanzibarlii": { - "place_code": "tz-znz", - "domain": "zanzibarlii.org", - }, - "zimlii": { - "country_code": "zw", - "domain": "zimlii.org", - }, - } - - def __init__(self, app_name): - self.current_authority = self.RESOLVER_MAPPINGS[app_name.lower()] - - def get_domain_for_frbr_uri(self, parsed_uri): - best_domain = self.get_best_domain(parsed_uri) - if best_domain != self.current_authority["domain"]: - return best_domain - return None - - def get_best_domain(self, parsed_uri): - country_code = parsed_uri.country - place_code = parsed_uri.place - - if country_code != place_code: - for key, mapping in self.RESOLVER_MAPPINGS.items(): - if mapping.get("place_code") == place_code: - return mapping.get("domain") - - # if no domain matching with place code is found use country code - for key, mapping in self.RESOLVER_MAPPINGS.items(): - if mapping.get("country_code") == country_code: - return mapping.get("domain") - return None +from peachjam.resolver import resolver class DocumentDetailViewResolver(View): @@ -145,10 +40,8 @@ def dispatch(self, request, *args, **kwargs): ) if not obj: - resolver = RedirectResolver(settings.PEACHJAM["APP_NAME"]) - domain = resolver.get_domain_for_frbr_uri(parsed_frbr_uri) - if domain: - url = f"https://{domain}{frbr_uri}" + url = resolver.get_url_for_frbr_uri(parsed_frbr_uri, frbr_uri) + if url: return redirect(url) raise Http404() diff --git a/peachjam/views/widgets.py b/peachjam/views/widgets.py index c7f2d4cd1..1187a4f47 100644 --- a/peachjam/views/widgets.py +++ b/peachjam/views/widgets.py @@ -1,32 +1,87 @@ +import re +from urllib.parse import urlparse + import lxml.html from cobalt.uri import FrbrUri +from corsheaders.signals import check_request_enabled +from django.conf import settings from django.http import Http404 +from django.shortcuts import redirect from django.utils.translation import get_language from django.views.generic import DetailView from peachjam.helpers import add_slash, parse_utf8_html from peachjam.models import CoreDocument +from peachjam.resolver import RedirectResolver, resolver class DocumentPopupView(DetailView): - """Shows a popup with basic details for a document.""" + """Shows a popup with basic details for a document. + + An affiliate site may use this by redirecting a local popup to a popup on (this) LII website. + So we allow CORS requests, provided the origin matches the partner website. + + For example: + + 1. The user hovers over a link to /akn/xx/act/2009/1 on africanlii.org + 2. The browser asks africanlii.org for the popup, but it doesn't exist on africanlii.org + 3. So africanlii.org uses peachjam's resolver logic to identify that xxlii.org is responsible for /akn/xx/... + and redirects the user's browser to xxlii.org/p/africanlii.org/e/popup/akn/xx/act/2009/1 + 4. This view loads on xxlii.org and shows the popup, because the request came from africanlii.org + which matches the partner code in the URL + """ model = CoreDocument context_object_name = "document" template_name = "peachjam/document_popup.html" + partner_domains = [x["domain"] for x in RedirectResolver.RESOLVER_MAPPINGS.values()] + localhost = ["localhost", "127.0.0.1"] + frbr_uri = None + + def get(self, request, partner, frbr_uri, *args, **kwargs): + # check partner matches requesting host + if not self.valid_partner(request, partner): + raise Http404() + + try: + self.object = self.get_object() + except Http404: + if self.frbr_uri: + # use the resolver to send a redirect if it's probably off-site somewhere + domain = resolver.get_domain_for_frbr_uri(self.frbr_uri) + if domain: + return redirect(f"https://{domain}{self.request.path}") + raise + + context = self.get_context_data(object=self.object) + return self.render_to_response(context) + + def valid_partner(self, request, partner): + # only allow this page to be embedded from valid partners + # first, the partner must match the referer (or origin, for CORS requests) + referrer = request.META.get("HTTP_REFERER") or request.META.get("HTTP_ORIGIN") + if referrer and not settings.DEBUG: + try: + parsed = urlparse(referrer) + if parsed.hostname != partner and parsed.hostname not in self.localhost: + return False + except ValueError: + return False + # second, the partner must be in the list of valid partners + return partner in self.partner_domains or partner in self.localhost def get_object(self, *args, **kwargs): try: - frbr_uri = FrbrUri.parse(add_slash(self.kwargs["frbr_uri"])) + self.frbr_uri = FrbrUri.parse(add_slash(self.kwargs["frbr_uri"])) except ValueError: raise Http404() - self.portion = frbr_uri.portion - frbr_uri.portion = None - if frbr_uri.expression_date: - uri = frbr_uri.expression_uri() + self.portion = self.frbr_uri.portion + self.frbr_uri.portion = None + if self.frbr_uri.expression_date: + uri = self.frbr_uri.expression_uri() else: - uri = frbr_uri.work_uri() + uri = self.frbr_uri.work_uri() obj = self.model.objects.best_for_frbr_uri(uri, get_language())[0] if not obj: @@ -51,4 +106,27 @@ def get_context_data(self, **kwargs): except ValueError: raise Http404() + # is this a CORS request from off-site? (the partner host is not the same as the local host) + context["offsite_request"] = self.request.get_host() != self.kwargs["partner"] + return context + + +url_re = re.compile("^/p/([^/]+)/e/.*") + + +def check_cors_and_partner(sender, request, **kwargs): + """Check if we should mark this request as CORS-enabled. We do so if it's popup URL and + the origin matches the partner domain.""" + match = url_re.match(request.path_info) + if match: + # allow a CORS request if the partner portion of the URL matches the origin + origin = request.META.get("HTTP_ORIGIN") + if origin: + try: + return urlparse(origin).hostname == match.group(1) + except ValueError: + return False + + +check_request_enabled.connect(check_cors_and_partner) diff --git a/peachjam_search/documents.py b/peachjam_search/documents.py index f46e7f684..ae4013854 100644 --- a/peachjam_search/documents.py +++ b/peachjam_search/documents.py @@ -25,6 +25,11 @@ log = logging.getLogger(__name__) +# the languages that translated fields support, that will be indexed into ES +# TODO: where should this language list be configured? they are languages that the interface is translated into +TRANSLATED_FIELD_LANGS = ["en", "fr", "pt", "sw"] + + class RankField(fields.DEDField, RankFeature): pass @@ -76,7 +81,7 @@ class SearchableDocument(Document): registry_fr = fields.KeywordField() registry_pt = fields.KeywordField() - order_outcome = fields.KeywordField(attr="order_outcome.name") + order_outcome = fields.KeywordField() order_outcome_en = fields.KeywordField() order_outcome_sw = fields.KeywordField() order_outcome_fr = fields.KeywordField() @@ -107,7 +112,6 @@ class SearchableDocument(Document): translated_fields = [ ("court", "name"), ("registry", "name"), - ("order_outcome", "name"), ("nature", "name"), ] @@ -235,8 +239,22 @@ def prepare_nature(self, instance): return instance.nature.name def prepare_order_outcome(self, instance): - if hasattr(instance, "order_outcome") and instance.order_outcome: - return instance.order_outcome.name + if hasattr(instance, "order_outcomes") and instance.order_outcomes: + return [ + order_outcome.name for order_outcome in instance.order_outcomes.all() + ] + + def prepare_order_outcome_en(self, instance): + return get_translated_m2m_name(instance, "order_outcomes", "en") + + def prepare_order_outcome_fr(self, instance): + return get_translated_m2m_name(instance, "order_outcomes", "fr") + + def prepare_order_outcome_pt(self, instance): + return get_translated_m2m_name(instance, "order_outcomes", "pt") + + def prepare_order_outcome_sw(self, instance): + return get_translated_m2m_name(instance, "order_outcomes", "sw") def prepare_pages(self, instance): """Text content of pages extracted from PDF.""" @@ -290,6 +308,15 @@ def get_queryset(self): return super().get_queryset().order_by("-pk") +def get_translated_m2m_name(instance, field, lang): + """Get the translated name of a many-to-many field.""" + if hasattr(instance, field) and getattr(instance, field): + return [ + getattr(v, f"name_{lang}", None) or v.name + for v in getattr(instance, field).all() + ] + + def prepare_translated_field(self, instance, field, attr, lang): if getattr(instance, field, None): fld = getattr(instance, field) @@ -304,8 +331,7 @@ def make_prepare(field, attr, lang): # add preparation methods for translated fields to avoid lots of copy-and-paste for field, attr in SearchableDocument.translated_fields: - # TODO: where should this language list be configured? they are languages that the interface is translated into - for lang in ["en", "fr", "pt", "sw"]: + for lang in TRANSLATED_FIELD_LANGS: # we must call make_prepare so that the variables are evaluated now, not when the function is called setattr( SearchableDocument, diff --git a/peachjam_search/serializers.py b/peachjam_search/serializers.py index 2fa0744cd..7ae2ba0d6 100644 --- a/peachjam_search/serializers.py +++ b/peachjam_search/serializers.py @@ -73,7 +73,10 @@ def get_nature(self, obj): return obj["nature" + self.language_suffix] def get_order_outcome(self, obj): - return obj["order_outcome" + self.language_suffix] + val = obj["order_outcome" + self.language_suffix] + if val is not None: + val = list(val) + return val def get_registry(self, obj): return obj["registry" + self.language_suffix] diff --git a/pyproject.toml b/pyproject.toml index f039ec1a0..1f5738797 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "django-background-tasks>=1.2.5", "django-ckeditor>=6.4.2", "django-compressor>=3.1", + "django-cors-headers>=4.3.1", "django-countries-plus>=1.3.2", "django-debug-toolbar>=3.2.4,<4.2.0", "django-elasticsearch-debug-toolbar>=3.0.2",