From 4cb004fe9fe82c34cd6061efc3908ff2bb5140b2 Mon Sep 17 00:00:00 2001 From: William Palin Date: Thu, 17 Oct 2024 15:30:11 -0400 Subject: [PATCH 01/37] feat(new_ui): NEW HTML and CSS and JS --- cl/assets/static-global/css/override.css | 706 +++++++++++++++++- cl/assets/static-global/js/base.js | 271 ++++++- .../includes/add_download_button.html | 46 ++ .../templates/includes/add_note_button.html | 2 +- .../templates/includes/opinion_tabs.html | 337 +++++++++ cl/opinion_page/templates/opinion.html | 2 +- cl/opinion_page/templates/opinions.html | 346 +++++++++ cl/search/models.py | 32 + 8 files changed, 1735 insertions(+), 7 deletions(-) create mode 100644 cl/opinion_page/templates/includes/add_download_button.html create mode 100644 cl/opinion_page/templates/includes/opinion_tabs.html create mode 100644 cl/opinion_page/templates/opinions.html diff --git a/cl/assets/static-global/css/override.css b/cl/assets/static-global/css/override.css index 7a27e9f08f..32c21672a1 100644 --- a/cl/assets/static-global/css/override.css +++ b/cl/assets/static-global/css/override.css @@ -155,7 +155,30 @@ header { /* Standard target color. */ *:target { - background-color: lightyellow; + -webkit-animation: target-fade 3s; + -moz-animation: target-fade 3s; + -o-animation: target-fade 3s; + animation: target-fade 3s; +} + +@-webkit-keyframes target-fade { + from { background-color: lightyellow; } + to { background-color: transparent; } +} + +@-moz-keyframes target-fade { + from { background-color: lightyellow; } + to { background-color: transparent; } +} + +@-o-keyframes target-fade { + from { background-color: lightyellow; } + to { background-color: transparent; } +} + +@keyframes target-fade { + from { background-color: lightyellow; } + to { background-color: transparent; } } .alt { @@ -1603,7 +1626,7 @@ textarea { /* Prevent images inside opinion from overflowing */ -#opinion-content img { +div.subopinion-content img { max-width: 100%; height: auto; } @@ -1723,3 +1746,682 @@ rect.series-segment { opacity 150ms 150ms ease-in; transform: translate3d(0, 0, 0); } + + + +/*Wrap all our changes around an opinion-body class we load up + in the opinion template*/ + +.opinion-body { + + #headmatter { + font-family: Merriweather, "Times New Roman", Times, serif; + font-size: 15px; + letter-spacing: 0.2px; + text-align: justify; + padding:0px; + margin: 0px; + background-color: white; + border: none; + + } + #headmatter > parties { + text-align: center; + font-style: initial; + font-size: 2em; + display: block; + } + #headmatter > div.footnotes > .footnote > p { + line-height: 1em; + } + + #headmatter > * { + text-indent: 2em; + } + + #headmatter docketnumber, + #headmatter court, + #headmatter parties, + #headmatter attorneys, + #headmatter syllabus, + #headmatter decisiondate { + display: block; + } + + #headmatter > div.footnotes { + border-top: None; + padding-top: 1em; + } + + .jump-links > a{ + position: relative; + margin: -8px 20px 0 0; + width: 140px; + line-height: 18px; + font-size: 14px; + cursor: pointer; + white-space: nowrap; + text-overflow: ellipsis; + opacity: 1; + } + + .hr-opinion { + border-top: 2px solid black; + } + + /*Clean up the Case Caption section to look large and clean*/ + .case-caption { + font-size: 3em; + font-weight: 500; + text-align: left; + line-height: 1.1em; + margin-top: 50px; + } + + + .case-court { + font-size: 25px; + text-align: left; + } + +/*Update sidebar jump links to look nice*/ +.jump-links { + font-size: 12px; + padding-top: 5px; +} + + li.jump-links.active { + color: #B53C2C; + font-weight: bold; + } + + li.jump-links { + list-style-type: none; + padding-left: 0; + } + + li.jump-links::before { + content: ""; + border-left: 3px solid lightgrey; + height: 1em; + padding-right: 8px; + display: inline-block; + margin-right: 5px; + } + + li.jump-links.active::before { + content: ""; + border-left: 2px solid #B53C2C; + padding-right: 8px; + display: inline-block; + margin-right: 5px; + } + + + .jump-links { + font-size: 12px; + padding-top: 5px; +} + +li.jump-links { + height:2.5em; + list-style-type: none; + padding-left: 0; + position: relative; +} + +li.jump-links::before { + content: ""; + border-left: 2px solid lightgrey; + height: 100%; + position: absolute; + left: 0; + top: 0; + padding-right: 8px; + display: inline-block; +} + +/* Active link styles */ +li.jump-links > a.active { + font-weight: 500; + color: black; +} + +li.jump-links > a { + padding-left:10px; + color: black; +} + + +div.footnote:first-of-type { + border-top: 1px solid black; + width: 100%; + display: block; + } + + /*Columbia specific Fix*/ + /*Columbia/HTML Law box special footnotes data almost awlays starts with fn1*/ + footnote_body sup#fn1 { + padding-top: 10px; + border-top: 1px solid black; + width: 100%; + display: block; + } + + /*HTML law box page numbers*/ + strong[data-ref] { + font-size: 0.8em; + fon: italic; + } + + strong[data-ref]::before { + content: attr(data-ref); + display: inline; + position: relative; + float: right; + left: -.5em; + font-size: 0.8em; + color: dimgray; + width: 0; + } + + + div.footnote { + padding-top: 10px; + display: block; + line-height: 1em; + } + + div.footnote > p { + display: inline; + } + + div.footnote::before { + content: attr(label) " "; + font-weight: bold; + color: #000; + margin-right: 5px; + padding-top: 2em; + } + + div.footnote { + padding-top: 10px; + font-size: 12px; + } + + div.footnote > * { + padding-top: 10px; + font-size: 12px; + } + + + /*To help separate footnotes from opinion document*/ + footnote:first-of-type { + border-top: 1px solid black; + width: 100%; + display: block; + } + + footnote { + padding-top: 10px; + display: block; + line-height: 1.5em; + /*margin-left: 1em;*/ + padding-left: 40px; + } + + footnote > p { + display: inline; + } + + footnote::before { + content: attr(label); + font-weight: bold; + color: #000; + margin-right: 26px; + padding-top: 2em; + margin-left: -35px; + } + + /*Handle CSS in Columbia opinions*/ + footnotemark { + font-weight: bold; + font-size: 0.8em; + vertical-align: super; + line-height: 0; + } + + + #cited-by { + z-index: 1; + } + + footnotemark { + cursor: pointer; + color: blue; + text-decoration: underline; + } + + footnote { + padding-top: 10px; + font-size: 12px; + } + + + .jumpback { + color: blue; + cursor: pointer; + font-weight: bold; + margin-left: 5px; + } + + + footnote > * { + font-size: 12px; + } + + author > page-number { + display: block; + font-size: 15px; + } + + author { + display: inline; + margin: 0; /* Remove any default margin */ + text-indent: 2em; /* Indents the first line by 2em */ + } + + /*Important for indenting harvard opinions correctly*/ + opinion > p[id^="b"] { + text-indent: 2em; + } + + + opinion > [id^="p-"] { + padding-left: 2em; + text-indent: 2em; + } +} + +[id^="A"] { + text-indent: 2em; + display: inline; + +} + +.opinion-body { + /*I think i did this but i dont know why so im leaving it for now*/ + /*.tab-pane {*/ + /* display: none; */ + /*}*/ + + .tab-pane.active { + display: block; + } + + @media (min-width: 767px) { + + #sidebar { + display: flex; + flex-direction: column; + height: 100vh; + justify-content: space-between; /* Push content apart */ + padding: 20px; + padding-top: 3px; + overflow-y: auto; + position: -webkit-sticky; /* For Safari */ + position: sticky; + top: 0; /* Stick to the top of the viewport */ + + } + } + + @media (min-width: 100px) { + #sidebar { + height: auto; + } + } + + .sidebar-bottom { + margin-top: auto; + } + + .support-flp, .sponsored-by { + margin-bottom: 20px; + text-align: center; + } + + #opinion > article > * > p { + text-indent: 2em; + } + + .active > a { + border-bottom-color: #B53C2C; + } + + #opinion p { + text-indent: 2em; + } + + + .nav-pills > li > a { + padding: 1px 15px; + } + + blockquote > * { + text-indent: 0em; + } + + sup { + font-size: .9em; + } + + .main-document { + padding-bottom: 5em; + } + + /*Case Caption CSS*/ + #caption-square { + background-color: #F6F2EE; + margin-left: -15px; + margin-right: -15px; + margin-top: -20px; + } + + #caption-square > ul > li { + background-color: #fcfaf9; + border-top-right-radius: 5px 5px; /* Rounds the corners */ + border-top-left-radius: 5px 5px; /* Rounds the corners */ + margin-left: 4px; + } + + #caption-square > ul > li.active { + background-color: #ffffff; + border-bottom: 1px solid lightgrey; + } + + #caption-square > ul > li.active { + background-color: #ffffff; + border-bottom: 1px solid white; + } + + #caption-square > ul > li.active > a { + border: 1px solid white; + } + + /*Opinion Date File*/ + .case-date-new { + border: 1px solid #B53C2C; + border-radius: 20px; /* Rounds the corners */ + padding: 5px; + padding-left: 8px; + padding-right: 8px; + padding-top: 8px; + color: #B53C2C; + + } + + /*Buttons on Top of Page*/ + .add-a-note { + margin-left: 5px; + border: 1px solid black; + border-radius: 10px; + padding-left: 8px; + padding-right: 8px; + } + + .add-citation-alert { + border: 1px solid black; + border-radius: 10px; + padding-left: 8px; + padding-right: 8px; + } + + cross_reference { + font-style: italic; + } + + #opinion-caption { + margin-top: 20px; + font-family: Merriweather, "Times New Roman", Times, serif; + font-size: 15px; + letter-spacing: 0.2px; + line-height: 2.3em; + margin-bottom: 20px; + padding-left: 20px; + padding-top: 10px; + padding-right: 10px; + } + + .case-details { + font-size: 16px; + } + + .case-details li { + line-height: 1.5em; + } + + span.citation.no-link { + font-style: italic; + } + + .opinion-button-row { + padding-top: 40px; + } + + #download-original { + color: black; + border-color: black; + background-color: white; + vertical-align: top; + float:right; + display:block; + } + + #btn-group-download-original { + float:right; + margin-top: 0px; + margin-left:10px; + padding-right: 10px; + } + + #add-note-button { + color: black; + border-color: black; + background-color: white; + vertical-align: top; + float: right; + } + + #get-citation-btn-group { + float:right; + } + + #get-citation-btn-group > a { + + color: black; + border-color: black; + background-color: white; + vertical-align: top; + } + + + p > span.star-pagination::after { + display: inline; + position: relative; + content: attr(label);; + float: left; + left: -4.5em; + font-size: 1em; + color: dimgray; + width: 0; + } + + div > span.star-pagination::after { + display: inline; + position: relative; + content: attr(label);; + float: left; + left: -2.5em; + font-size: 1em; + color: dimgray; + width: 0; + } + + div.subopinion-content > .harvard { + font-family: Merriweather, "Times New Roman", Times, serif; + font-size: 15px; + letter-spacing: 0.2px; + line-height: 2.3em; + text-align: justify; + } + + #columbia-text { + font-family: Merriweather, "Times New Roman", Times, serif; + font-size: 15px; + letter-spacing: 0.2px; + line-height: 2.3em; + text-align: justify; + } + + #columbia-text > div.subopinion-content > div > p > span.star-pagination { + color: #555555; + } + + #columbia-text > div.subopinion-content > div > p > span.star-pagination::after { + display: inline; + position: relative; + content: attr(label);; + float: left; + left: -4.5em; + font-size: 1em; + color: dimgray; + width: 0; + } + + + page-number::after { + display: inline; + position: relative; + content: attr(label); + float: right; + font-size: 1em; + color: dimgray; + width: 0; + } + + page-number { + font-style: italic; + font-size: 0.8em; + margin-right: 4px; + margin-left: 2px; + } + + a.page-label { + font-style: italic; + font-size: 0.8em; + margin-right: 4px; + margin-left: 2px; + color: #555555; + } + + + a.page-label::after { + display: inline; + position: relative; + content: attr(data-label); + float: right; + font-size: 1em; + color: dimgray; + width: 0; + } + + footnote > blockquote > a.page-label::after { + right: -2.5em; + } + + blockquote[id^="A"] > a.page-label::after { + right: -2.5em; + } + + blockquote[id^="b"] > a.page-label::after { + right: -4.0em; + } + + opinion > a.page-label::after { + right: -2.5em; + } + + /* Adjust to move the entire blockquote to the right */ + blockquote { + margin-left: 3em; + } + + a.page-label::after { + display: inline; + position: relative; + attr(label); + float: right; + font-size: 1em; + color: dimgray; + width: 0; + } + + footnote > p > a.page-label::after { + display: none; + } + + footnote > blockquote > a.page-label::after { + display: none; + } + + /*Remove the header on the opinion page so its flush*/ + header { + margin-bottom: 0px; + } + + .harvard > opinion > author { + line-height: inherit; + font-size: inherit; + display: inline-block; + } + + .container > .content { + margin-bottom: 0em; + } + + .meta-data-header { + font-size:15px; + } + + .case-details { + font-family: Merriweather, "Times New Roman", Times, serif; + letter-spacing: 0.2px; + line-height:2.3em; + } + + .opinion-section-title { + margin-top: 50px; + font-family: Merriweather, "Times New Roman", Times, serif; + } + + /*Add style to align roman numerals */ + .center-header { + text-align: center; + font-size: 2em; + } + + /*If XS screen - remove the side page labels*/ + @media (max-width: 768px) { + a.page-label::after { + display: none; + } + a.page-number::after { + display: none; + } + } +} + +html { + scroll-behavior: smooth; +} diff --git a/cl/assets/static-global/js/base.js b/cl/assets/static-global/js/base.js index 99355aa207..31713c0df5 100644 --- a/cl/assets/static-global/js/base.js +++ b/cl/assets/static-global/js/base.js @@ -307,11 +307,8 @@ $(document).ready(function () { if (modal_exist) { $('#open-modal-on-load').modal(); } - }); - - // Debounce - rate limit a function // https://davidwalsh.name/javascript-debounce-function function debounce(func, wait, immediate) { @@ -369,3 +366,271 @@ if (form && button) { button.disabled = true; }); } + +////////////////// +// SCOTUS STYLE // +////////////////// + +document.querySelectorAll('p').forEach(function (element) { + // Bold and Center likely Roman Numerals this improves SCOTUS opinions + if (element.textContent.trim().length < 5) { + element.classList.add('center-header'); + } +}); + + +//////////////// +// Pagination // +//////////////// + +$('.star-pagination').each(function (index, element) { + $(this).attr('label', this.textContent.trim().replace('*Page ', '')); +}); + +// Systematize page numbers +$('page-number').each(function (index, element) { + // Get the label and citation index from the current element + const label = $(this).attr('label'); + const citationIndex = $(this).attr('citation-index'); + + // Clean up the label (remove '*') and use it for the new href and id + const cleanLabel = label.replace('*', '').trim(); + + // Create the new element + const $newAnchor = $('') + .addClass('page-label') + .attr('data-citation-index', citationIndex) + .attr('data-label', cleanLabel) + .attr('href', '#' + cleanLabel) + .attr('id', cleanLabel) + .text('*' + cleanLabel); + + // Replace the element with the new element + $(this).replaceWith($newAnchor); +}); + +// Systematize page numbers +$('span.star-pagination').each(function (index, element) { + // Get the label and citation index from the current element + const label = $(this).attr('label'); + const citationIndex = $(this).attr('citation-index'); + + // Clean up the label (remove '*') and use it for the new href and id + const cleanLabel = label.replace('*', '').trim(); + + // Create the new element + const $newAnchor = $('') + .addClass('page-label') + .attr('data-citation-index', citationIndex) + .attr('data-label', cleanLabel) + .attr('href', '#' + cleanLabel) + .attr('id', cleanLabel) + .text('*' + cleanLabel); + + // Replace the element with the new element + $(this).replaceWith($newAnchor); +}); +// Fix weird data-ref bug +document.querySelectorAll('strong').forEach((el) => { + if (/\[\d+\]/.test(el.textContent)) { + // Check if the text matches the pattern [XXX] + const match = el.textContent.match(/\[\d+\]/)[0]; // Get the matched pattern + el.setAttribute('data-ref', match); // Set a data-ref attribute + } +}); + +/////////////// +// Footnotes // +/////////////// + +// We formatted the harvard footnotes oddly when they appeared inside the pre-opinion content. +// this removes the excess a tags and allows us to standardize footnotes across our contents +// footnote cleanup in harvard +// Update and modify footnotes to enable linking +$('div.footnote > a').remove(); +const headfootnotemarks = $('a.footnote'); +const divfootnotes = $('div.footnote'); + +if (headfootnotemarks.length === divfootnotes.length) { + headfootnotemarks.each(function (index) { + const footnoteMark = $(this); + const footnote = divfootnotes.eq(index); + + const $newElement = $(''); + $.each(footnoteMark.attributes, function () { + if (footnoteMark.specified) { + $newElement.attr(footnoteMark.name, footnoteMark.value); + } + }); + $newElement.html(footnoteMark.html()); + footnoteMark.replaceWith($newElement); + + const $newFootnote = $(''); + $.each(footnote.attributes, function () { + if (footnote.specified) { + $newFootnote.attr(footnote.name, footnote.value); + } + }); + $newFootnote.attr('label', footnote.attr('label')); + $newFootnote.html(footnote.html()); + footnote.replaceWith($newFootnote); + }); +} + +// This fixes many of the harvard footnotes so that they can +// easily link back and forth - we have a second set +// of harvard footnotes inside headnotes that need to be parsed out now +// okay. + +const footnoteMarks = $('footnotemark'); +const footnotes = $('footnote').not('[orphan="true"]'); + +if (footnoteMarks.length === footnotes.length) { + // we can make this work + footnoteMarks.each(function (index) { + const footnoteMark = $(this); + console.log(index, footnoteMark); + const $newElement = $(''); + // Copy attributes from the old element + $.each(footnoteMark.attributes, function () { + if (footnoteMark.specified) { + $newElement.attr(footnoteMark.name, footnoteMark.value); + console.log(footnoteMark.name, footnoteMark.value); + } + }); + $newElement.html(footnoteMark.html()); + const $supElement = $('').append($newElement); + footnoteMark.replaceWith($supElement); + const footnote = footnotes.eq(index); + $newElement.attr('href', `#fn${index}`); + $newElement.attr('id', `fnref${index}`); + footnote.attr('id', `fn${index}`); + console.log(footnoteMark, footnote); + + const $jumpback = $(''); + $jumpback.attr('href', `#fnref${index}`); + + footnote.append($jumpback); + }); +} else { + // If the number of footnotes and footnotemarks are inconsistent use the method to scroll to the nearest one + // we dont use this by default because many older opinions will reuse * ^ and other icons repeatedly on every page + // and so label is no usable to identify the correct footnote. + + footnotes.each(function (index) { + console.log($(this)); + + const $jumpback = $(''); + $jumpback.attr('label', $(this).attr('label')); + $(this).append($jumpback); + }); + + // There is no silver bullet for footnotes + $('footnotemark').on('click', function () { + const markText = $(this).text().trim(); // Get the text of the clicked footnotemark + const currentScrollPosition = $(window).scrollTop(); // Get the current scroll position + + // Find the first matching footnote below the current scroll position + const targetFootnote = $('footnote') + .filter(function () { + return $(this).attr('label') === markText && $(this).offset().top > currentScrollPosition; + }) + .first(); + + // If a matching footnote is found, scroll to it + if (targetFootnote.length > 0) { + $('html, body').animate( + { + scrollTop: targetFootnote.offset().top, + }, + 500 + ); // Adjust the animation duration as needed + } else { + console.warn('No matching footnote found below the current position for:', markText); + } + }); + + + ////////////// + // Sidebar // + ///////////// + + $('.jumpback').on('click', function () { + const footnoteLabel = $(this).attr('label').trim(); // Get the label attribute of the clicked footnote + const currentScrollPosition = $(window).scrollTop(); // Get the current scroll position + + // Find the first matching footnotemark above the current scroll position + const targetFootnotemark = $('footnotemark') + .filter(function () { + return $(this).text().trim() === footnoteLabel && $(this).offset().top < currentScrollPosition; + }) + .last(); + + // If a matching footnotemark is found, scroll to it + if (targetFootnotemark.length > 0) { + $('html, body').animate( + { + scrollTop: targetFootnotemark.offset().top, + }, + 500 + ); // Adjust the animation duration as needed + } else { + console.warn('No matching footnotemark found above the current position for label:', footnoteLabel); + } + }); +} + +$(document).ready(function () { + function adjustSidebarHeight() { + if ($(window).width() > 767) { + // Only apply the height adjustment for screens wider than 767px + var scrollTop = $(window).scrollTop(); + if (scrollTop <= 175) { + $('.opinion-sidebar').css('height', 'calc(100vh - ' + (175 - scrollTop) + 'px)'); + // $('.main-document').css('height', 'calc(100vh + ' + (scrollTop) + 'px)'); + } else { + $('.opinion-sidebar').css('height', '100vh'); + } + } else { + $('.opinion-sidebar').css('height', 'auto'); // Reset height for mobile view + } + } + + // Adjust height on document ready and when window is scrolled or resized + adjustSidebarHeight(); + $(window).on('scroll resize', adjustSidebarHeight); +}); + +// Update sidebar to show where we are on the page +document.addEventListener('scroll', function () { + let sections = document.querySelectorAll('.jump-link'); + let links = document.querySelectorAll('.jump-links > a'); + let currentSection = ''; + + // Determine which section is currently in view + sections.forEach((section) => { + let sectionTop = section.offsetTop; + let sectionHeight = section.offsetHeight; + if (window.scrollY >= sectionTop - sectionHeight / 3) { + currentSection = section.getAttribute('id'); + } + }); + + // Remove the active class from all links and their parent elements + links.forEach((link) => { + link.classList.remove('active'); + if (link.parentElement) { + link.parentElement.classList.remove('active'); + } + }); + + // Add the active class to the link and its parent that corresponds to the current section + links.forEach((link) => { + if (link.getAttribute('href') === `#${currentSection}`) { + link.classList.add('active'); + if (link.parentElement) { + link.parentElement.classList.add('active'); + } + } + }); +}); diff --git a/cl/opinion_page/templates/includes/add_download_button.html b/cl/opinion_page/templates/includes/add_download_button.html new file mode 100644 index 0000000000..1d7a4d828e --- /dev/null +++ b/cl/opinion_page/templates/includes/add_download_button.html @@ -0,0 +1,46 @@ +
+ + +
diff --git a/cl/opinion_page/templates/includes/add_note_button.html b/cl/opinion_page/templates/includes/add_note_button.html index c5392897e8..fb5fdaac40 100644 --- a/cl/opinion_page/templates/includes/add_note_button.html +++ b/cl/opinion_page/templates/includes/add_note_button.html @@ -3,4 +3,4 @@ data-toggle="modal" data-target="#modal-save-note, #modal-logged-out" title="{% if form_instance_id %}Edit this note{% else %}Save this record as a note in your profile{% endif %}"> - {% if form_instance_id %}Edit Note{% else %}Add Note{% endif %} + diff --git a/cl/opinion_page/templates/includes/opinion_tabs.html b/cl/opinion_page/templates/includes/opinion_tabs.html new file mode 100644 index 0000000000..9a5334847d --- /dev/null +++ b/cl/opinion_page/templates/includes/opinion_tabs.html @@ -0,0 +1,337 @@ +{% load humanize %} +{% load text_filters %} + +{% if tab == "authorities" %} +{# Table of Authorities #} +
+ +
+
+ {% for authority in authorities_with_data %} +
+

+ + {{ authority.caption|safe|v_wrapper }} + +

+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+
+ {% endfor %} +
+
+{#{% elif tab == "details" %}#} +{# {% include "includes/tab_details.html" %}#} +{% elif tab == "summaries" %} + {# Summaries #} +
+ +
+
+
    + {% for group in parenthetical_groups %} + {% with representative=group.representative %} + {% with representative_cluster=representative.describing_opinion.cluster %} +
    +

    + + {{ representative_cluster|best_case_name|safe }} + +

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
  • + {{ representative.text|capfirst }} -- +
    + +
  • +
      + {% for summary in group.parentheticals.all %} + {% with describing_cluster=summary.describing_opinion.cluster %} + {% if summary != representative %} +
    • + {{ summary.text|capfirst }} +
      + {{ describing_cluster.date_filed }} + + + {{ describing_cluster|best_case_name|safe }} + + + {{ describing_cluster.docket.court }} +
    • + {% endif %} + {% endwith %} + {% endfor %} +
    + {% endwith %} + {% endwith %} + {% endfor %} +
+
+
+{% elif tab == "cited-by" %} + {# Cited By #} +
+ +
+ + {% if citing_cluster_count > 0 %} + {% for citing_cluster in citing_clusters %} + + {% endfor %} + {% else %} +

This case has not yet been cited in our system.

+ {% endif %} + +
+

+ View Citing Opinions +

+
+ +{% elif tab == "related-cases" %} + {# Related Cases #} + + +{% elif tab == "pdf" %} + {# PDF #} +
+
+ +
+
+
+ +
+
+
+

Oops! Your browser does not support embedded PDF viewing.

+
+ {% include "includes/rd_download_button.html" %} +
+
+
+
+
+
+{% else %} + + {# The section of the document I refer to as headmatter goes here #} +
+
+ {% with opinion_count=cluster.sub_opinions.all.count %} + {% if cluster.headnotes %} + +
+

{{ cluster.headnotes | safe}}

+ {% endif %} + + {% if cluster.headmatter %} + +
+
+ {{ cluster.headmatter|safe }} +
+ {% endif %} + + {% for sub_opinion in cluster.ordered_opinions %} + +
+ + {% if 'U' in cluster.source %} +
+ {% elif 'Z' in cluster.source %} +
+ {% elif 'L' in cluster.source %} +
+ {% elif 'R' in cluster.source %} +
+ {% else %} +
+ {% endif %} + +
+ {% if sub_opinion.xml_harvard and sub_opinion.html_with_citations %} +
{{ sub_opinion.html_with_citations|safe }}
+ {% elif sub_opinion.xml_harvard %} +
{{ sub_opinion.xml_harvard|safe }}
+ {% elif sub_opinion.html_with_citations %} + {% if cluster.source == "C" %} + {# It's a PDF with no HTML enrichment#} +
{{ sub_opinion.html_with_citations|safe|linebreaksbr }}
+ {% else %} +
{{ sub_opinion.html_with_citations|safe }}
+ {% endif %} + {% elif sub_opinion.html_columbia %} +
{{ sub_opinion.html_columbia|safe }}
+ {% elif sub_opinion.html_lawbox %} +
{{ sub_opinion.html_lawbox|safe }}
+ {% elif sub_opinion.html_anon_2020 %} +
{{ sub_opinion.html_anon_2020|safe }}
+ {% elif sub_opinion.html %} +
{{sub_opinion.html|safe}}
+ {% else %} +
{{sub_opinion.plain_text}}
+ {% endif %} +
+ + {% endfor %} + {% endwith %} +
+
+ +{% endif %} \ No newline at end of file diff --git a/cl/opinion_page/templates/opinion.html b/cl/opinion_page/templates/opinion.html index 16a33820fd..a0c4c797c7 100644 --- a/cl/opinion_page/templates/opinion.html +++ b/cl/opinion_page/templates/opinion.html @@ -100,7 +100,7 @@

Summaries ({{ summaries_count|intcomma }})

{% endfor %}

- View All Summaries diff --git a/cl/opinion_page/templates/opinions.html b/cl/opinion_page/templates/opinions.html new file mode 100644 index 0000000000..a32f1d0042 --- /dev/null +++ b/cl/opinion_page/templates/opinions.html @@ -0,0 +1,346 @@ +{% extends "base.html" %} +{% load extras %} +{% load humanize %} +{% load static %} +{% load text_filters %} + + +{% block canonical %}{% get_canonical_element %}{% endblock %} +{% block title %}{{ title }} – CourtListener.com{% endblock %} +{% block og_title %}{{ title }} – CourtListener.com{% endblock %} +{% block description %}{{ title }} — Brought to you by Free Law Project, a non-profit dedicated to creating high quality open legal information.{% endblock %} +{% block og_description %}{{ cluster|best_case_name }}{% if summaries_count > 0 %} — {{ top_parenthetical_groups.0.representative.text|capfirst }}{% else %} — Brought to you by Free Law Project, a non-profit dedicated to creating high quality open legal information.{% endif %} +{% endblock %} + +{% block head %} + +{% endblock %} + +{% block navbar-o %}active{% endblock %} + + +{% block sidebar %} + {% with sponsored_logo=STATIC_URL|add:'img/vlex-logo-150-75.png' %} + + + + {% endwith %} +{% endblock %} + +{% block body-classes %}opinion-body{% endblock %} + +{% block content %} + +
+
+ +
+ {{ cluster.date_filed }} + {% include "includes/add_note_button.html" with form_instance_id=note_form.instance.cluster_id %} + + {% if pdf_path %} + {% include "includes/add_download_button.html" %} + {% endif %} + + + + + +

{{ cluster.docket.court }}

+
+
+
    +
  • Citations: {{ cluster.citation_string|default:"None known" }}
  • + + {% if cluster.case_name_full != cluster.case_name and cluster.case_name_full != "" %} +
  • Full Case Name: + {{ cluster.case_name_full }} +
  • + {% endif %} + + {% if cluster.docket.court_id != "olc" %} +
  • Docket Number: {{ cluster.docket.docket_number|default:"Unknown" }}
  • + {% endif %} + + {% if cluster.get_precedential_status_display != "Precedential" %} +
  • Precedential Status: {{ cluster.get_precedential_status_display|default:"Unknown" }}
  • + {% endif %} + + {% if cluster.docket.court_id == 'scotus' and cluster.scbd %} +
  • Supreme Court DB ID: + + {{ cluster.scdb_id }} + +
  • + {% endif %} + + {% if cluster.panel.all.count > 0 %} +
  • Panel: + {% for p in cluster.panel.all %} + {{ p.name_full }}{% if not forloop.last %}, {% endif %} + {% endfor %} +
  • + {% endif %} + + {% if cluster.judges %} +
  • Judges: {{ cluster.judges }}
  • + {% endif %} + + {% if opinion.author %} +
  • Author: {{ opinion.author.name_full }}
  • + {% endif %} + + {% if opinion.joined_by.all.count > 0 %} +
  • Joined By: + {% for p in opinion.joined_by.all %} + {{ p.name_full }}{% if not forloop.last %}, {% endif %} + {% endfor %} +
  • + {% endif %} + + {% if cluster.nature_of_suit %} +
  • Nature of Suit: {{ cluster.nature_of_suit }}
  • + {% endif %} + + {% if cluster.nature_of_suit %} +
  • Posture: {{ cluster.posture }}
  • + {% endif %} + + {% if cluster.other_dates %} + {{ cluster.other_dates.items }} +
  • Other Dates: {{ cluster.other_dates }}
  • + {% endif %} + + {% if cluster.disposition %} +
  • Disposition: {{ cluster.disposition }}
  • + {% endif %} +
+
+
+ + +
+ {% include "includes/opinion_tabs.html" %} + {% include "includes/notes_modal.html" %} + +
+{% endblock %} + + +{% block footer-scripts %} + + + {% if request.user.is_staff %} + + {% if DEBUG %} + + {% else %} + + {% endif %} + {% endif %} +{% endblock %} diff --git a/cl/search/models.py b/cl/search/models.py index a0c808f3d3..3bacd929ab 100644 --- a/cl/search/models.py +++ b/cl/search/models.py @@ -2852,6 +2852,26 @@ def caption(self): caption += f" ({court} {year})" return caption + @property + def display_citation(self): + citation_list = [citation for citation in self.citations.all()] + citations = sorted(citation_list, key=sort_cites) + citation = "" + if not citations: + return "" + else: + if citations[0].type == Citation.NEUTRAL: + return citations[0] + elif ( + len(citations) >= 2 + and citations[0].type == Citation.WEST + and citations[1].type == Citation.LEXIS + ): + citation += f"{citations[0]}, {citations[1]}" + else: + citation += f"{citations[0]}" + return citation + @property def citation_string(self): """Make a citation string, joined by commas""" @@ -2991,6 +3011,18 @@ def __str__(self) -> str: def get_absolute_url(self) -> str: return reverse("view_case", args=[self.pk, self.slug]) + def ordered_opinions(self): + # Fetch all sub-opinions ordered by ordering_key + sub_opinions = self.sub_opinions.all().order_by("ordering_key") + + # Check if there is more than one sub-opinion + if sub_opinions.count() > 1: + # Return only sub-opinions with an ordering key + return sub_opinions.exclude(ordering_key__isnull=True) + + # If there's only one or no sub-opinions, return the main opinion + return sub_opinions + def save( self, update_fields=None, From 2ac21c6b3033d1ba97b3daa00d4c4c36bb514683 Mon Sep 17 00:00:00 2001 From: William Palin Date: Thu, 17 Oct 2024 15:31:12 -0400 Subject: [PATCH 02/37] feat(opinion.urls): Add/remove new endpoints Add multiple tab specific endpoints --- cl/opinion_page/urls.py | 48 ++++++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/cl/opinion_page/urls.py b/cl/opinion_page/urls.py index 5e7a9e1a54..be8c9214d8 100644 --- a/cl/opinion_page/urls.py +++ b/cl/opinion_page/urls.py @@ -12,14 +12,18 @@ download_docket_entries_csv, redirect_docket_recap, redirect_og_lookup, - view_authorities, view_docket, view_docket_feed, view_opinion, + view_opinion_authorities, + view_opinion_cited_by, + view_opinion_details, + view_opinion_pdf, + view_opinion_related_cases, + view_opinion_summaries, view_parties, view_recap_authorities, view_recap_document, - view_summaries, ) urlpatterns = [ @@ -31,16 +35,6 @@ name="court_publish_page", ), # Opinion pages - path( - "opinion///summaries/", - view_summaries, # type: ignore[arg-type] - name="view_summaries", - ), - path( - "opinion///authorities/", - view_authorities, # type: ignore[arg-type] - name="view_authorities", - ), path( "opinion///visualizations/", cluster_visualizations, # type: ignore[arg-type] @@ -52,6 +46,36 @@ name="docket_feed", ), path("opinion///", view_opinion, name="view_case"), # type: ignore[arg-type] + path( + "opinion///details/", + view_opinion_details, + name="view_case_details", + ), # with the tab + path( + "opinion///authorities/", + view_opinion_authorities, + name="view_case_authorities", + ), # with the tab + path( + "opinion///cited-by/", + view_opinion_cited_by, + name="view_case_cited_by", + ), # with the tab + path( + "opinion///summaries/", + view_opinion_summaries, + name="view_case_summaries", + ), # with the tab + path( + "opinion///related-cases/", + view_opinion_related_cases, + name="view_case_related_cases", + ), # with the tab + path( + "opinion///pdf/", + view_opinion_pdf, + name="view_case_pdf", + ), # with the tab path( "docket//download/", download_docket_entries_csv, # type: ignore[arg-type] From c260b60bd654a55e58c33b81d1c5044da2e6f4b2 Mon Sep 17 00:00:00 2001 From: William Palin Date: Thu, 17 Oct 2024 15:32:59 -0400 Subject: [PATCH 03/37] feat(opinion.views): Create new view methods Rewrite and waffle the new UI changes Added a number of methods to fetch and/or store related and cited by data quickly Implemented new view opinion with waffles --- cl/opinion_page/utils.py | 305 ++++++++++++++++++++++++++++++++++++- cl/opinion_page/views.py | 314 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 609 insertions(+), 10 deletions(-) diff --git a/cl/opinion_page/utils.py b/cl/opinion_page/utils.py index 160453bb1f..b8d5e581dc 100644 --- a/cl/opinion_page/utils.py +++ b/cl/opinion_page/utils.py @@ -157,8 +157,19 @@ async def build_cites_clusters_query( cluster_cites_query = cluster_search.query(cites_query) search_query = ( cluster_cites_query.sort({"citeCount": {"order": "desc"}}) - .source(includes=["absolute_url", "caseName", "dateFiled"]) - .extra(size=5, track_total_hits=True) + .source( + includes=[ + "absolute_url", + "caseName", + "cluster_id", + "docketNumber", + "citation", + "status", + "dateFiled", + ] + ) + .extra(size=20, track_total_hits=True) + .collapse(field="cluster_id") ) return search_query @@ -192,8 +203,18 @@ async def build_related_clusters_query( cluster_related_query = cluster_search.query(main_query) search_query = ( cluster_related_query.sort({"_score": {"order": "desc"}}) - .source(includes=["absolute_url", "caseName", "cluster_id"]) - .extra(size=5) + .source( + includes=[ + "absolute_url", + "caseName", + "cluster_id", + "docketNumber", + "citations", + "status", + "dateFiled", + ] + ) + .extra(size=20) .collapse(field="cluster_id") ) return search_query @@ -211,6 +232,202 @@ class RelatedCitingResults: timeout: bool = False +@dataclass +class RelatedClusterResults: + related_clusters: list[OpinionClusterDocument] = field( + default_factory=list + ) + sub_opinion_pks: list[int] = field(default_factory=list) + url_search_params: dict[str, str] = field(default_factory=dict) + timeout: bool = False + has_related_cases: bool = False + + +async def es_get_related_clusters_with_cache( + cluster: OpinionCluster, + request: HttpRequest, +) -> RelatedClusterResults: + """Elastic Related Clusters Search or Cache + + :param cluster:The cluster to use + :param request:The user request + :return:Related Cluster Data + """ + cache = caches["db_cache"] + mlt_cache_key = f"clusters-mlt-es:{cluster.pk}" + # By default, all statuses are included. Retrieve the PRECEDENTIAL_STATUS + # attributes (since they're indexed in ES) instead of the NAMES values. + search_params: CleanData = {} + url_search_params = { + f"stat_{v[0]}": "on" for v in PRECEDENTIAL_STATUS.NAMES + } + sub_opinion_pks = [ + str(pk) + async for pk in cluster.sub_opinions.values_list("pk", flat=True) + ] + if settings.RELATED_FILTER_BY_STATUS: + # Filter results by status (e.g., Precedential) + # Update URL parameters accordingly + search_params[ + f"stat_{PRECEDENTIAL_STATUS.get_status_value(settings.RELATED_FILTER_BY_STATUS)}" + ] = True + url_search_params = { + f"stat_{PRECEDENTIAL_STATUS.get_status_value(settings.RELATED_FILTER_BY_STATUS)}": "on" + } + + related_cluster_result = RelatedClusterResults( + url_search_params=url_search_params + ) + + if is_bot(request) or not sub_opinion_pks: + return related_cluster_result + + cached_related_clusters, timeout_related = ( + await cache.aget(mlt_cache_key) or (None, False) + if settings.RELATED_USE_CACHE + else (None, False) + ) + + # Prepare related cluster query if not cached results. + cluster_search = OpinionClusterDocument.search() + + if cached_related_clusters is not None: + related_cluster_result.related_clusters = cached_related_clusters + related_cluster_result.timeout = timeout_related + related_cluster_result.has_related_cases = ( + True if len(cached_related_clusters) > 0 else False + ) + return related_cluster_result + + # if cached_related_clusters is None: + related_query = await build_related_clusters_query( + cluster_search, sub_opinion_pks, search_params + ) + + related_query = related_query.params( + timeout=f"{settings.ELASTICSEARCH_FAST_QUERIES_TIMEOUT}s" + ) + related_query = related_query.extra( + size=settings.RELATED_COUNT, track_total_hits=False + ) + try: + # Execute the Related Query if needed + response = related_query.execute() + timeout_related = False + except (ConnectionError, RequestError, ApiError) as e: + logger.warning("Error getting cited and related clusters: %s", e) + if settings.DEBUG is True: + traceback.print_exc() + return related_cluster_result + except ConnectionTimeout as e: + logger.warning( + "ConnectionTimeout getting cited and related clusters: %s", e + ) + response = None + timeout_related = True + + related_cluster_result.related_clusters = ( + response if response is not None else cached_related_clusters or [] + ) + related_cluster_result.timeout = False + related_cluster_result.sub_opinion_pks = list(map(int, sub_opinion_pks)) + related_cluster_result.has_related_cases = True if response else False + + if timeout_related == False: + # print("SETTING", ( + # related_cluster_result.related_clusters, + # timeout_related, + # related_cluster_result.has_related_cases, + # )) + await cache.aset( + mlt_cache_key, + (results.related_clusters, timeout_related), + settings.RELATED_CACHE_TIMEOUT, + ) + + await cache.aset( + mlt_cache_key, + ( + related_cluster_result.related_clusters, + timeout_related, + related_cluster_result.has_related_cases, + ), + settings.RELATED_CACHE_TIMEOUT, + ) + return related_cluster_result + + +async def es_get_cited_clusters_with_cache( + cluster: OpinionCluster, + request: HttpRequest, +): + """Elastic cited by cluster search or cache + + :param cluster:The cluster to check + :param request:The user request + :return:The cited by data + """ + cache = caches["db_cache"] + cache_citing_key = f"clusters-cited-es:{cluster.pk}" + + sub_opinion_pks = [ + str(pk) + async for pk in cluster.sub_opinions.values_list("pk", flat=True) + ] + if is_bot(request) or not sub_opinion_pks: + return related_cluster_result + + cached_citing_results, cahced_citing_clusters_count, timeout_cited = ( + await cache.aget(cache_citing_key) or (None, False, False) + if settings.RELATED_USE_CACHE + else (None, False, False) + ) + + if cached_citing_results is not None: + return ( + cached_citing_results, + cahced_citing_clusters_count, + timeout_cited, + ) + + cluster_search = OpinionClusterDocument.search() + cited_query = await build_cites_clusters_query( + cluster_search, sub_opinion_pks + ) + try: + # Execute the Related Query if needed + response = cited_query.execute() + timeout_cited = False + except (ConnectionError, RequestError, ApiError) as e: + logger.warning("Error getting cited and related clusters: %s", e) + if settings.DEBUG is True: + traceback.print_exc() + return related_cluster_result + except ConnectionTimeout as e: + logger.warning( + "ConnectionTimeout getting cited and related clusters: %s", e + ) + response = None + timeout_cited = True + citing_clusters = list(response) + citing_clusters_count = ( + response.hits.total.value if response is not None else 0 + ) + timeout_cited = False if citing_clusters else timeout_cited + + if not timeout_cited: + await cache.aset( + cache_citing_key, + ( + citing_clusters, + citing_clusters_count, + timeout_cited, + ), + settings.RELATED_CACHE_TIMEOUT, + ) + return citing_clusters, citing_clusters_count, timeout_cited + + async def es_get_citing_and_related_clusters_with_cache( cluster: OpinionCluster, request: HttpRequest, @@ -251,9 +468,11 @@ async def es_get_citing_and_related_clusters_with_cache( if is_bot(request) or not sub_opinion_pks: return RelatedCitingResults(url_search_params=url_search_params) - cached_citing_results, cached_citing_cluster_count, timeout_cited = ( - await cache.aget(cache_citing_key) or (None, 0, False) - ) + ( + cached_citing_results, + cached_citing_cluster_count, + timeout_cited, + ) = await cache.aget(cache_citing_key) or (None, 0, False) cached_related_clusters, timeout_related = ( await cache.aget(mlt_cache_key) or (None, False) if settings.RELATED_USE_CACHE @@ -340,3 +559,75 @@ async def es_get_citing_and_related_clusters_with_cache( results.timeout = any([timeout_cited, timeout_related]) results.sub_opinion_pks = list(map(int, sub_opinion_pks)) return results + + +async def es_cited_case_count(cluster_id, sub_opinion_pks: [int]): + """Elastic quick cited by count query + + :param cluster_id: The cluster id to search with + :param sub_opinion_pks: The subopinion ids of the cluster + :return: + """ + cache = caches["db_cache"] + cache_cited_by_key = f"cited-by-count-es:{cluster_id}" + cached_cited_by_count = await cache.aget(cache_cited_by_key) or None + if cached_cited_by_count is not None: + return cached_cited_by_count + + cluster_search = OpinionClusterDocument.search() + cites_query = Q( + "bool", + filter=[ + Q("match", cluster_child="opinion"), + Q("terms", **{"cites": sub_opinion_pks}), + ], + ) + cluster_cites_query = cluster_search.query(cites_query) + cited_by_count = cluster_cites_query.count() + + await cache.aset( + cache_cited_by_key, + cited_by_count, + settings.RELATED_CACHE_TIMEOUT, + ) + + return cited_by_count + + +async def es_related_case_count(cluster_id, sub_opinion_pks: [int]): + """Elastic quick related cases count + + :param cluster_id: The cluster id of the object + :param sub_opinion_pks: The sub opinion ids of the cluster + :return: The count of related cases in elastic + """ + cache = caches["db_cache"] + cache_related_cases_key = f"related-cases-count-es:{cluster_id}" + cached_related_cases_count = ( + await cache.aget(cache_related_cases_key) or None + ) + if cached_related_cases_count is not None: + return cached_related_cases_count + + cluster_search = OpinionClusterDocument.search() + mlt_query = await build_more_like_this_query(sub_opinion_pks) + parent_filters = await sync_to_async(build_join_es_filters)( + {"type": SEARCH_TYPES.OPINION, "stat_published": True} + ) + default_parent_filter = [Q("match", cluster_child="opinion")] + parent_filters.extend(default_parent_filter) + main_query = Q( + "bool", + filter=default_parent_filter, + should=mlt_query, + minimum_should_match=1, + ) + cluster_related_query = cluster_search.query(main_query) + related_cases_count = cluster_related_query.count() + await cache.aset( + cache_related_cases_key, + related_cases_count, + settings.RELATED_CACHE_TIMEOUT, + ) + + return related_cases_count diff --git a/cl/opinion_page/views.py b/cl/opinion_page/views.py index c96cc3af85..e3f774945a 100644 --- a/cl/opinion_page/views.py +++ b/cl/opinion_page/views.py @@ -72,7 +72,11 @@ from cl.opinion_page.types import AuthoritiesContext from cl.opinion_page.utils import ( core_docket_data, + es_cited_case_count, + es_get_cited_clusters_with_cache, es_get_citing_and_related_clusters_with_cache, + es_get_related_clusters_with_cache, + es_related_case_count, generate_docket_entries_csv_data, get_case_title, ) @@ -352,7 +356,6 @@ async def fetch_docket_entries(docket): async def view_docket( request: HttpRequest, pk: int, slug: str ) -> HttpResponse: - sort_order_asc = True form = DocketEntryFilterForm(request.GET, request=request) docket, context = await core_docket_data(request, pk) @@ -770,7 +773,9 @@ async def view_recap_authorities( @never_cache -async def view_opinion(request: HttpRequest, pk: int, _: str) -> HttpResponse: +async def view_opinion_old( + request: HttpRequest, pk: int, _: str +) -> HttpResponse: """Using the cluster ID, return the cluster of opinions. We also test if the cluster ID has a user note, and send data @@ -855,7 +860,7 @@ async def view_opinion(request: HttpRequest, pk: int, _: str) -> HttpResponse: sponsored = True view_authorities_url = reverse( - "view_authorities", args=[cluster.pk, cluster.slug] + "view_case_authorities", args=[cluster.pk, cluster.slug] ) authorities_context: AuthoritiesContext = AuthoritiesContext( citation_record=cluster, @@ -896,6 +901,151 @@ async def view_opinion(request: HttpRequest, pk: int, _: str) -> HttpResponse: ) +async def setup_opinion_context( + cluster: OpinionCluster, request: HttpRequest, tab: str +): + """Generate the basic page information we need to load the page + + :param cluster: The opinon cluster + :param request: The HTTP request from the user + :param tab: The tab to load + :return: + """ + title = ", ".join( + [ + s + for s in [ + trunc(best_case_name(cluster), 100, ellipsis="..."), + await cluster.acitation_string(), + ] + if s.strip() + ] + ) + has_downloads = False + pdf_path = None + if cluster.filepath_pdf_harvard: + has_downloads = True + pdf_path = cluster.filepath_pdf_harvard + else: + async for sub_opinion in cluster.sub_opinions.all(): + if str(sub_opinion.local_path).endswith(".pdf"): + has_downloads = True + pdf_path = sub_opinion.local_path.url + break + elif sub_opinion.download_url: + has_downloads = True + pdf_path = sub_opinion.local_path.url + + get_string = make_get_string(request) + + sub_opinion_pks = [ + str(pk) + async for pk in cluster.sub_opinions.values_list("pk", flat=True) + ] + + es_has_cited_opinions = await es_cited_case_count( + cluster.id, sub_opinion_pks + ) + es_has_related_opinions = await es_related_case_count( + cluster.id, sub_opinion_pks + ) + + try: + note = await Note.objects.aget( + cluster_id=cluster.pk, + user=await request.auser(), # type: ignore[attr-defined] + # type: ignore[attr-defined] + ) + except (ObjectDoesNotExist, TypeError): + # Not note or anonymous user + note_form = NoteForm( + initial={ + "cluster_id": cluster.pk, + "name": trunc(best_case_name(cluster), 100, ellipsis="..."), + } + ) + else: + note_form = NoteForm(instance=note) + + # Identify opinions updated/added in partnership with v|lex for 3 years + sponsored = False + if ( + cluster.date_created.date() > datetime.datetime(2022, 6, 1).date() + and cluster.filepath_json_harvard + ): + sponsored = True + + context = { + "tab": tab, + "title": title, + "caption": await cluster.acaption(), + "cluster": cluster, + "has_downloads": has_downloads, + "pdf_path": pdf_path, + "note_form": note_form, + "get_string": get_string, + "private": cluster.blocked, + "sponsored": sponsored, + "summaries_count": await cluster.parentheticals.acount(), + "authorities_count": await cluster.aauthority_count(), + "related_cases_count": es_has_related_opinions, + "cited_by_count": es_has_cited_opinions, + } + + return context + + +async def render_opinion_view( + request: HttpRequest, pk: int, tab: str, additional_context: dict = None +) -> HttpResponse: + """Helper function to render opinion views with common context. + + :param request: The HttpRequest object + :param pk: The primary key for the OpinionCluster + :param tab: The tab name to display + :param additional_context: Any additional context to be passed to the template + :return: HttpResponse + """ + cluster: OpinionCluster = await aget_object_or_404(OpinionCluster, pk=pk) + + ui_flag_for_o = await sync_to_async(waffle.flag_is_active)( + request, "ui_flag_for_o" + ) + user_flag_active = await sync_to_async(waffle.flag_is_active)( + request.user, "ui_flag_for_o" + ) + if not any([ui_flag_for_o, user_flag_active]): + return await view_opinion_old(request, pk, "str") + + context = await setup_opinion_context(cluster, request, tab=tab) + + if additional_context: + context.update(additional_context) + + # Just redirect if people attempt to URL hack to pages without content + tab_count_mapping = { + "pdf": "has_downloads", + "authorities": "authorities_count", + "cited-by": "cited_by_count", + "related-by": "related_by_count", + "summaries": "summaries_count", + } + + # Check if the current tab needs a redirect based on the mapping + if context["tab"] in tab_count_mapping: + count_key = tab_count_mapping[context["tab"]] + if not context[count_key]: + return HttpResponseRedirect( + reverse("view_case", args=[cluster.pk, cluster.slug]) + ) + + return TemplateResponse( + request, + "opinions.html", + context, + ) + + async def view_summaries( request: HttpRequest, pk: int, slug: str ) -> HttpResponse: @@ -948,6 +1098,164 @@ async def view_authorities( ) +async def check_flag_exists(flag_name: str) -> bool: + return await sync_to_async( + waffle.get_waffle_flag_model().objects.filter(name=flag_name).exists + )() + + +@never_cache +async def view_opinion(request: HttpRequest, pk: int, _: str) -> HttpResponse: + """View for displaying opinions.""" + + flag_exists = await check_flag_exists("ui_flag_for_o") + if flag_exists: + ui_flag_for_o = await sync_to_async(waffle.flag_is_active)( + request, "ui_flag_for_o" + ) + user_flag_active = await sync_to_async(waffle.flag_is_active)( + request.user, "ui_flag_for_o" + ) + if ui_flag_for_o or user_flag_active: + return await render_opinion_view(request, pk, "opinions") + # else: + # print("~~~~1:", ui_flag_for_o, "~~~2:", user_flag_active, request.user) + return await view_opinion_old(request, pk, "str") + + +async def view_opinion_details( + request: HttpRequest, pk: int, _: str +) -> HttpResponse: + """View for displaying opinion case details.""" + + return await render_opinion_view(request, pk, "details") + + +async def view_opinion_pdf( + request: HttpRequest, pk: int, _: str +) -> HttpResponse: + """View for displaying opinion case details.""" + return await render_opinion_view(request, pk, "pdf") + + +async def view_opinion_authorities( + request: HttpRequest, pk: int, _: str +) -> HttpResponse: + """View for displaying opinion authorities.""" + cluster: OpinionCluster = await aget_object_or_404(OpinionCluster, pk=pk) + + authorities_context: AuthoritiesContext = AuthoritiesContext( + citation_record=cluster, + query_string=request.META["QUERY_STRING"], + total_authorities_count=await cluster.aauthority_count(), + view_all_url="view_authorities_url", + doc_type="opinion", + ) + await authorities_context.post_init() + + additional_context = { + "authorities_context": authorities_context, + "authorities_with_data": await cluster.aauthorities_with_data(), + } + ui_flag_for_o = await sync_to_async(waffle.flag_is_active)( + request, "ui_flag_for_o" + ) + user_flag_active = await sync_to_async(waffle.flag_is_active)( + request.user, "ui_flag_for_o" + ) + + if ui_flag_for_o or user_flag_active: + return await render_opinion_view( + request, pk, "authorities", additional_context + ) + else: + # Old page to load for people outside the flag + return await view_authorities( + request=request, pk=pk, slug="authorities" + ) + + +async def view_opinion_cited_by( + request: HttpRequest, pk: int, _: str +) -> HttpResponse: + """""" + cluster: OpinionCluster = await aget_object_or_404(OpinionCluster, pk=pk) + + ( + citing_clusters, + citing_cluster_count, + _, + ) = await es_get_cited_clusters_with_cache(cluster, request) + additional_context = { + "citing_clusters": citing_clusters, + "citing_cluster_count": citing_cluster_count, + } + return await render_opinion_view( + request, pk, "cited-by", additional_context + ) + + +async def view_opinion_summaries( + request: HttpRequest, pk: int, _: str +) -> HttpResponse: + """""" + cluster: OpinionCluster = await aget_object_or_404(OpinionCluster, pk=pk) + parenthetical_groups_qs = await get_or_create_parenthetical_groups(cluster) + parenthetical_groups = [ + parenthetical_group + async for parenthetical_group in parenthetical_groups_qs.prefetch_related( + Prefetch( + "parentheticals", + queryset=Parenthetical.objects.order_by("-score"), + ), + "parentheticals__describing_opinion__cluster__citations", + "parentheticals__describing_opinion__cluster__docket__court", + "representative__describing_opinion__cluster__citations", + "representative__describing_opinion__cluster__docket__court", + ) + ] + ui_flag_for_o = await sync_to_async(waffle.flag_is_active)( + request, "ui_flag_for_o" + ) + user_flag_active = await sync_to_async(waffle.flag_is_active)( + request.user, "ui_flag_for_o" + ) + + if ui_flag_for_o or user_flag_active: + additional_context = { + "parenthetical_groups": parenthetical_groups, + "ui_flag_for_o": ui_flag_for_o, + "user_flag_active": user_flag_active, + } + return await render_opinion_view( + request, pk, "summaries", additional_context + ) + else: + # Old page to load for people outside the flag + return await view_summaries(request=request, pk=pk, slug="summaries") + + +async def view_opinion_related_cases( + request: HttpRequest, pk: int, _: str +) -> HttpResponse: + """""" + cluster: OpinionCluster = await aget_object_or_404(OpinionCluster, pk=pk) + related_cluster_object = await es_get_related_clusters_with_cache( + cluster, request + ) + additional_context = { + "related_algorithm": "mlt", + "related_clusters": related_cluster_object.related_clusters, + "sub_opinion_ids": related_cluster_object.sub_opinion_pks, + "related_search_params": f"&{urlencode(related_cluster_object.url_search_params)}", + "queries_timeout": related_cluster_object.timeout, + "has_related_cases": related_cluster_object.has_related_cases, + } + return await render_opinion_view( + request, pk, "related-cases", additional_context + ) + + async def cluster_visualizations( request: HttpRequest, pk: int, slug: str ) -> HttpResponse: From 08a4e8624cc012b0a7741bd1b227d472b1353ed6 Mon Sep 17 00:00:00 2001 From: William Palin Date: Thu, 17 Oct 2024 15:40:16 -0400 Subject: [PATCH 04/37] feat(tests): Update to tests Generally just override flags to avoid testing old view opinion page against the new ui changes. --- cl/favorites/tests.py | 3 +++ cl/opinion_page/tests.py | 3 +++ cl/search/tests/tests.py | 3 +++ cl/search/tests/tests_es_opinion.py | 2 ++ cl/tests/test_feeds.py | 3 +++ cl/tests/test_visualizations.py | 2 ++ 6 files changed, 16 insertions(+) diff --git a/cl/favorites/tests.py b/cl/favorites/tests.py index 61d549477b..bdde7f8393 100644 --- a/cl/favorites/tests.py +++ b/cl/favorites/tests.py @@ -11,6 +11,7 @@ from django.utils.timezone import now from selenium.webdriver.common.by import By from timeout_decorator import timeout_decorator +from waffle.testutils import override_flag from cl.favorites.factories import NoteFactory, PrayerFactory from cl.favorites.models import DocketTag, Note, Prayer, UserTag @@ -96,6 +97,7 @@ def setUp(self) -> None: super().setUp() @timeout_decorator.timeout(SELENIUM_TIMEOUT) + @override_flag("ui_flag_for_o", False) def test_anonymous_user_is_prompted_when_favoriting_an_opinion( self, ) -> None: @@ -156,6 +158,7 @@ def test_anonymous_user_is_prompted_when_favoriting_an_opinion( modal_title = self.browser.find_element(By.ID, "save-note-title") self.assertIn("Save Note", modal_title.text) + @override_flag("ui_flag_for_o", False) @timeout_decorator.timeout(SELENIUM_TIMEOUT) def test_logged_in_user_can_save_note(self) -> None: # Meta: assure no Faves even if part of fixtures diff --git a/cl/opinion_page/tests.py b/cl/opinion_page/tests.py index c77afc5ee9..59fc9038b6 100644 --- a/cl/opinion_page/tests.py +++ b/cl/opinion_page/tests.py @@ -19,6 +19,7 @@ from django.urls import reverse from django.utils.text import slugify from factory import RelatedFactory +from waffle.models import Flag from waffle.testutils import override_flag from cl.lib.models import THUMBNAIL_STATUSES @@ -111,6 +112,7 @@ async def test_simple_rd_page(self) -> None: self.assertEqual(response.status_code, HTTPStatus.OK) +@override_flag("ui_flag_for_o", False) class OpinionPageLoadTest( ESIndexTestCase, CourtTestCase, @@ -649,6 +651,7 @@ async def test_volume_pagination(self) -> None: self.assertEqual(volume_next, None) @override_flag("o-es-active", False) + @override_flag("ui_flag_for_o", False) def test_full_citation_redirect(self) -> None: """Do we get redirected to the correct URL when we pass in a full citation?""" diff --git a/cl/search/tests/tests.py b/cl/search/tests/tests.py index b8f85f719d..8fdd6fbd88 100644 --- a/cl/search/tests/tests.py +++ b/cl/search/tests/tests.py @@ -25,6 +25,7 @@ from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.wait import WebDriverWait from timeout_decorator import timeout_decorator +from waffle.testutils import override_flag from cl.audio.factories import AudioFactory from cl.lib.elasticsearch_utils import simplify_estimated_count @@ -1120,6 +1121,7 @@ def test_pagerank_calculation(self) -> None: ) +@override_flag("ui_flag_for_o", False) class OpinionSearchFunctionalTest(AudioTestCase, BaseSeleniumTest): """ Test some of the primary search functionality of CL: searching opinions. @@ -1260,6 +1262,7 @@ def test_search_and_facet_docket_numbers(self) -> None: for result in search_results.find_elements(By.TAG_NAME, "article"): self.assertIn("1337", result.text) + @override_flag("ui_flag_for_o", False) @timeout_decorator.timeout(SELENIUM_TIMEOUT) def test_opinion_search_result_detail_page(self) -> None: # Dora navitages to CL and does a simple wild card search diff --git a/cl/search/tests/tests_es_opinion.py b/cl/search/tests/tests_es_opinion.py index 60c72aa8d9..6a493aa478 100644 --- a/cl/search/tests/tests_es_opinion.py +++ b/cl/search/tests/tests_es_opinion.py @@ -19,6 +19,7 @@ from elasticsearch_dsl import Q from factory import RelatedFactory from lxml import etree, html +from waffle.models import Flag from waffle.testutils import override_flag from cl.custom_filters.templatetags.text_filters import html_decode @@ -2247,6 +2248,7 @@ def test_uses_exact_version_for_case_name_field(self) -> None: cluster_2.delete() +@override_flag("ui_flag_for_o", False) class RelatedSearchTest( ESIndexTestCase, CourtTestCase, PeopleTestCase, SearchTestCase, TestCase ): diff --git a/cl/tests/test_feeds.py b/cl/tests/test_feeds.py index a9fb9c8c7c..90bac42ae5 100644 --- a/cl/tests/test_feeds.py +++ b/cl/tests/test_feeds.py @@ -10,6 +10,7 @@ from django.urls import reverse from selenium.webdriver.common.by import By from timeout_decorator import timeout_decorator +from waffle.testutils import override_flag from cl.search.models import Court from cl.tests.base import SELENIUM_TIMEOUT, BaseSeleniumTest @@ -28,6 +29,7 @@ class FeedsFunctionalTest(BaseSeleniumTest): "functest_audio.json", ] + @override_flag("ui_flag_for_o", False) @timeout_decorator.timeout(SELENIUM_TIMEOUT) def test_can_get_to_feeds_from_homepage(self) -> None: """Can we get to the feeds/podcasts page from the homepage?""" @@ -49,6 +51,7 @@ def test_can_get_to_feeds_from_homepage(self) -> None: self.assert_text_in_node("Podcasts", "body") @timeout_decorator.timeout(SELENIUM_TIMEOUT) + @override_flag("ui_flag_for_o", False) def test_feeds_page_shows_jurisdiction_links(self) -> None: """ Does the feeds page show all the proper links for each jurisdiction? diff --git a/cl/tests/test_visualizations.py b/cl/tests/test_visualizations.py index 0e5acb46f7..a0962ede8c 100644 --- a/cl/tests/test_visualizations.py +++ b/cl/tests/test_visualizations.py @@ -5,6 +5,7 @@ from django.contrib.auth.hashers import make_password from selenium.webdriver.common.by import By from timeout_decorator import timeout_decorator +from waffle.testutils import override_flag from cl.tests.base import SELENIUM_TIMEOUT, BaseSeleniumTest from cl.users.factories import UserProfileWithParentsFactory @@ -30,6 +31,7 @@ def tearDown(self) -> None: SCOTUSMap.objects.all().delete() JSONVersion.objects.all().delete() + @override_flag("ui_flag_for_o", False) @timeout_decorator.timeout(SELENIUM_TIMEOUT) def test_creating_new_visualization(self) -> None: """Test if a user can create a new Visualization""" From bc92162addf4c5b532cbef32897de94d753c2646 Mon Sep 17 00:00:00 2001 From: William Palin Date: Fri, 18 Oct 2024 11:06:02 -0400 Subject: [PATCH 05/37] fix(tests): Fix tests Remove decorator for selenium tests unaffected And modify css to only affect scrolling on opinion page --- cl/assets/static-global/css/override.css | 7 ++++--- cl/assets/static-global/js/base.js | 8 ++++++++ cl/tests/test_feeds.py | 5 ++--- cl/tests/test_visualizations.py | 1 - 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/cl/assets/static-global/css/override.css b/cl/assets/static-global/css/override.css index 32c21672a1..822b799e09 100644 --- a/cl/assets/static-global/css/override.css +++ b/cl/assets/static-global/css/override.css @@ -2420,8 +2420,9 @@ div.footnote:first-of-type { display: none; } } -} -html { - scroll-behavior: smooth; } + +html.smooth-scroll { + scroll-behavior: smooth; +} \ No newline at end of file diff --git a/cl/assets/static-global/js/base.js b/cl/assets/static-global/js/base.js index 31713c0df5..149e42a7f8 100644 --- a/cl/assets/static-global/js/base.js +++ b/cl/assets/static-global/js/base.js @@ -367,6 +367,14 @@ if (form && button) { }); } + +////////////////////////////////// +// Smooth Scrolling on Opinions // +///////////////////////////////// +if (document.body.classList.contains('opinion-body')) { + document.documentElement.classList.add('smooth-scroll'); +} + ////////////////// // SCOTUS STYLE // ////////////////// diff --git a/cl/tests/test_feeds.py b/cl/tests/test_feeds.py index 90bac42ae5..7a67cd7e6d 100644 --- a/cl/tests/test_feeds.py +++ b/cl/tests/test_feeds.py @@ -29,7 +29,6 @@ class FeedsFunctionalTest(BaseSeleniumTest): "functest_audio.json", ] - @override_flag("ui_flag_for_o", False) @timeout_decorator.timeout(SELENIUM_TIMEOUT) def test_can_get_to_feeds_from_homepage(self) -> None: """Can we get to the feeds/podcasts page from the homepage?""" @@ -51,7 +50,6 @@ def test_can_get_to_feeds_from_homepage(self) -> None: self.assert_text_in_node("Podcasts", "body") @timeout_decorator.timeout(SELENIUM_TIMEOUT) - @override_flag("ui_flag_for_o", False) def test_feeds_page_shows_jurisdiction_links(self) -> None: """ Does the feeds page show all the proper links for each jurisdiction? @@ -67,7 +65,8 @@ def test_feeds_page_shows_jurisdiction_links(self) -> None: link.get_attribute("href"), f"{self.live_server_url}/feed/court/{court.pk}/", ) - link.click() + with self.wait_for_page_load(timeout=10): + link.click() print("clicked...", end=" ") self.assertIn( 'feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"', diff --git a/cl/tests/test_visualizations.py b/cl/tests/test_visualizations.py index a0962ede8c..d6760944d4 100644 --- a/cl/tests/test_visualizations.py +++ b/cl/tests/test_visualizations.py @@ -31,7 +31,6 @@ def tearDown(self) -> None: SCOTUSMap.objects.all().delete() JSONVersion.objects.all().delete() - @override_flag("ui_flag_for_o", False) @timeout_decorator.timeout(SELENIUM_TIMEOUT) def test_creating_new_visualization(self) -> None: """Test if a user can create a new Visualization""" From be333b364225bf4524a6c51f25344a6100b8079b Mon Sep 17 00:00:00 2001 From: William Palin Date: Fri, 18 Oct 2024 11:11:16 -0400 Subject: [PATCH 06/37] fix(opinion_page): Remove comments and fix lint Remove print statement and fix return for bot or scraping detection --- cl/opinion_page/utils.py | 17 ++++++----------- cl/opinion_page/views.py | 6 +++--- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/cl/opinion_page/utils.py b/cl/opinion_page/utils.py index b8d5e581dc..3bd0eb6144 100644 --- a/cl/opinion_page/utils.py +++ b/cl/opinion_page/utils.py @@ -3,7 +3,7 @@ import traceback from dataclasses import dataclass, field from io import StringIO -from typing import Dict, Tuple, Union +from typing import Dict, List, Tuple, Union from asgiref.sync import sync_to_async from django.conf import settings @@ -334,14 +334,9 @@ async def es_get_related_clusters_with_cache( related_cluster_result.has_related_cases = True if response else False if timeout_related == False: - # print("SETTING", ( - # related_cluster_result.related_clusters, - # timeout_related, - # related_cluster_result.has_related_cases, - # )) await cache.aset( mlt_cache_key, - (results.related_clusters, timeout_related), + (related_cluster_result.related_clusters, timeout_related), settings.RELATED_CACHE_TIMEOUT, ) @@ -375,7 +370,7 @@ async def es_get_cited_clusters_with_cache( async for pk in cluster.sub_opinions.values_list("pk", flat=True) ] if is_bot(request) or not sub_opinion_pks: - return related_cluster_result + return (None, False, False) cached_citing_results, cahced_citing_clusters_count, timeout_cited = ( await cache.aget(cache_citing_key) or (None, False, False) @@ -402,7 +397,7 @@ async def es_get_cited_clusters_with_cache( logger.warning("Error getting cited and related clusters: %s", e) if settings.DEBUG is True: traceback.print_exc() - return related_cluster_result + return (None, False, False) except ConnectionTimeout as e: logger.warning( "ConnectionTimeout getting cited and related clusters: %s", e @@ -561,7 +556,7 @@ async def es_get_citing_and_related_clusters_with_cache( return results -async def es_cited_case_count(cluster_id, sub_opinion_pks: [int]): +async def es_cited_case_count(cluster_id: int, sub_opinion_pks: List[str]): """Elastic quick cited by count query :param cluster_id: The cluster id to search with @@ -594,7 +589,7 @@ async def es_cited_case_count(cluster_id, sub_opinion_pks: [int]): return cited_by_count -async def es_related_case_count(cluster_id, sub_opinion_pks: [int]): +async def es_related_case_count(cluster_id, sub_opinion_pks: List[str]): """Elastic quick related cases count :param cluster_id: The cluster id of the object diff --git a/cl/opinion_page/views.py b/cl/opinion_page/views.py index e3f774945a..fe7e93bc33 100644 --- a/cl/opinion_page/views.py +++ b/cl/opinion_page/views.py @@ -996,14 +996,14 @@ async def setup_opinion_context( async def render_opinion_view( - request: HttpRequest, pk: int, tab: str, additional_context: dict = None + request: HttpRequest, pk: int, tab: str, additional_context: dict = {} ) -> HttpResponse: """Helper function to render opinion views with common context. :param request: The HttpRequest object :param pk: The primary key for the OpinionCluster - :param tab: The tab name to display - :param additional_context: Any additional context to be passed to the template + :param tab: The selected tab + :param additional_context: Any additional context to be passed to the view :return: HttpResponse """ cluster: OpinionCluster = await aget_object_or_404(OpinionCluster, pk=pk) From 5b0cf27610ebd8607cc08a0526f47e4722a56bf4 Mon Sep 17 00:00:00 2001 From: William Palin Date: Fri, 18 Oct 2024 14:04:48 -0400 Subject: [PATCH 07/37] feat(printing): Prettify Printing Hide unwanted content during printing --- cl/opinion_page/templates/includes/add_download_button.html | 2 +- cl/opinion_page/templates/opinions.html | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cl/opinion_page/templates/includes/add_download_button.html b/cl/opinion_page/templates/includes/add_download_button.html index 1d7a4d828e..a4844bc075 100644 --- a/cl/opinion_page/templates/includes/add_download_button.html +++ b/cl/opinion_page/templates/includes/add_download_button.html @@ -1,4 +1,4 @@ -
+
+
+ +
\ No newline at end of file diff --git a/cl/opinion_page/templates/opinions.html b/cl/opinion_page/templates/opinions.html index bf2cf23ebc..320dbb40d9 100644 --- a/cl/opinion_page/templates/opinions.html +++ b/cl/opinion_page/templates/opinions.html @@ -61,64 +61,58 @@

Admin

{% endif %} - {% if cluster.sub_opinions.all.first.extracted_by_ocr or "U" in cluster.source and tab == "opinions" %}
@@ -196,30 +190,31 @@

+
{{ cluster.date_filed }} +
+ + {% if pdf_path %} + {% include "includes/add_download_button.html" %} + {% endif %} {% include "includes/add_note_button.html" with form_instance_id=note_form.instance.cluster_id %} - - {% if pdf_path %} - {% include "includes/add_download_button.html" %} - {% endif %} - - - +
+

{{ cluster.docket.court }}

diff --git a/cl/opinion_page/utils.py b/cl/opinion_page/utils.py index d199bb395c..9fc779b37f 100644 --- a/cl/opinion_page/utils.py +++ b/cl/opinion_page/utils.py @@ -330,7 +330,6 @@ async def es_get_related_clusters_with_cache( ) related_cluster_result.timeout = False related_cluster_result.sub_opinion_pks = list(map(int, sub_opinion_pks)) - # related_cluster_result.has_related_cases = True if response else False if timeout_related == False: await cache.aset( diff --git a/cl/search/models.py b/cl/search/models.py index b7c4d808b4..94275afdbe 100644 --- a/cl/search/models.py +++ b/cl/search/models.py @@ -16,6 +16,7 @@ from django.urls import NoReverseMatch, reverse from django.utils import timezone from django.utils.encoding import force_str +from django.utils.functional import cached_property from django.utils.text import slugify from eyecite import get_citations from eyecite.tokenizers import HyperscanTokenizer From abaa31a1a7680ac9ac3dc6a2818182f6e0c69fe7 Mon Sep 17 00:00:00 2001 From: William Palin Date: Fri, 22 Nov 2024 13:47:53 -0500 Subject: [PATCH 34/37] fix(search.models): Update aauthorities with data Add prefetch related objects along with authorities data query --- cl/search/models.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cl/search/models.py b/cl/search/models.py index 94275afdbe..1b0197bdd2 100644 --- a/cl/search/models.py +++ b/cl/search/models.py @@ -2989,7 +2989,13 @@ async def aauthorities_with_data(self): The returned list is sorted by that citation count field. """ authorities_with_data = [] - async for authority in await self.aauthorities(): + authorities_base = await self.aauthorities() + authorities_qs = ( + authorities_base.prefetch_related("citations") + .select_related("docket__court") + .order_by("-citation_count", "-date_filed") + ) + async for authority in authorities_qs: authority.citation_depth = ( await get_citation_depth_between_clusters( citing_cluster_pk=self.pk, cited_cluster_pk=authority.pk From fe1ee4f5c5d4103ef58f70b5fe2a83d6a3780c01 Mon Sep 17 00:00:00 2001 From: William Palin Date: Fri, 22 Nov 2024 13:51:59 -0500 Subject: [PATCH 35/37] fix(search.models): Update acaption Remove extra cluster query Make docket and court async --- cl/search/models.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cl/search/models.py b/cl/search/models.py index 1b0197bdd2..ab875f7661 100644 --- a/cl/search/models.py +++ b/cl/search/models.py @@ -2799,9 +2799,8 @@ async def acaption(self): else: caption += f", {citations[0]}" - cluster = await OpinionCluster.objects.aget(pk=self.pk) - docket = await Docket.objects.aget(id=cluster.docket_id) - court = await Court.objects.aget(pk=docket.court_id) + docket = await sync_to_async(lambda: self.docket)() + court = await sync_to_async(lambda: docket.court)() if docket.court_id != "scotus": court = re.sub(" ", " ", court.citation_string) # Strftime fails before 1900. Do it this way instead. From 3b60523ce90c06dc623ad49e4bc081e980c37beb Mon Sep 17 00:00:00 2001 From: William Palin Date: Fri, 22 Nov 2024 14:35:50 -0500 Subject: [PATCH 36/37] fix(search.models): Optimize opinion views Optimize opinion view rendering by removing redundant cluster query --- cl/opinion_page/views.py | 84 +++++++++++++++++++++++++++------------- 1 file changed, 58 insertions(+), 26 deletions(-) diff --git a/cl/opinion_page/views.py b/cl/opinion_page/views.py index 6e266bf362..5b059bb0c5 100644 --- a/cl/opinion_page/views.py +++ b/cl/opinion_page/views.py @@ -10,7 +10,7 @@ from django.contrib import messages from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator -from django.db.models import IntegerField, Prefetch +from django.db.models import IntegerField, Prefetch, QuerySet from django.db.models.functions import Cast from django.http import HttpRequest, HttpResponseRedirect from django.http.response import ( @@ -994,8 +994,17 @@ async def setup_opinion_context( return context +async def get_opinions_base_queryset() -> QuerySet: + return OpinionCluster.objects.prefetch_related( + "sub_opinions__opinions_cited", "citations" + ).select_related("docket__court") + + async def render_opinion_view( - request: HttpRequest, pk: int, tab: str, additional_context: dict = {} + request: HttpRequest, + cluster: OpinionCluster, + tab: str, + additional_context: dict = {}, ) -> HttpResponse: """Helper function to render opinion views with common context. @@ -1005,15 +1014,15 @@ async def render_opinion_view( :param additional_context: Any additional context to be passed to the view :return: HttpResponse """ - queryset = OpinionCluster.objects.prefetch_related("sub_opinions") - cluster: OpinionCluster = await aget_object_or_404(queryset, pk=pk) - ui_flag_for_o = await sync_to_async(waffle.flag_is_active)( request, "ui_flag_for_o" ) if not ui_flag_for_o: return await view_opinion_old(request, pk, "str") + if not any([ui_flag_for_o]): + return await view_opinion_old(request, cluster.pk, "str") + context = await setup_opinion_context(cluster, request, tab=tab) if additional_context: @@ -1107,9 +1116,13 @@ async def view_opinion(request: HttpRequest, pk: int, _: str) -> HttpResponse: ui_flag_for_o = await sync_to_async(waffle.flag_is_active)( request, "ui_flag_for_o" ) - if ui_flag_for_o: - return await render_opinion_view(request, pk, "opinions") - return await view_opinion_old(request, pk, "str") + if not ui_flag_for_o: + return await view_opinion_old(request, pk, "str") + + cluster: OpinionCluster = await aget_object_or_404( + await get_opinions_base_queryset(), pk=pk + ) + return await render_opinion_view(request, cluster, "opinions") async def view_opinion_pdf( @@ -1122,7 +1135,10 @@ async def view_opinion_pdf( :param _: url slug :return: Opinion PDF tab """ - return await render_opinion_view(request, pk, "pdf") + cluster: OpinionCluster = await aget_object_or_404( + await get_opinions_base_queryset(), pk=pk + ) + return await render_opinion_view(request, cluster, "pdf") async def view_opinion_authorities( @@ -1135,22 +1151,25 @@ async def view_opinion_authorities( :param _: url slug :return: Table of Authorities tab """ - cluster: OpinionCluster = await aget_object_or_404(OpinionCluster, pk=pk) - - additional_context = { - "authorities_with_data": await cluster.aauthorities_with_data(), - } - ui_flag_for_o = await sync_to_async(waffle.flag_is_active)( request, "ui_flag_for_o" ) - if ui_flag_for_o: - return await render_opinion_view( - request, pk, "authorities", additional_context + if not ui_flag_for_o: + # Old page to load for people outside the flag + return await view_authorities( + request=request, pk=pk, slug="authorities" ) - # Old page to load for people outside the flag - return await view_authorities(request=request, pk=pk, slug="authorities") + cluster: OpinionCluster = await aget_object_or_404( + await get_opinions_base_queryset(), pk=pk + ) + + additional_context = { + "authorities_with_data": await cluster.aauthorities_with_data(), + } + return await render_opinion_view( + request, cluster, "authorities", additional_context + ) async def view_opinion_cited_by( @@ -1163,14 +1182,16 @@ async def view_opinion_cited_by( :param _: url slug :return: Cited By tab """ - cluster: OpinionCluster = await aget_object_or_404(OpinionCluster, pk=pk) + cluster: OpinionCluster = await aget_object_or_404( + await get_opinions_base_queryset(), pk=pk + ) cited_query = await es_get_cited_clusters_with_cache(cluster, request) additional_context = { "citing_clusters": cited_query.citing_clusters, "citing_cluster_count": cited_query.citing_cluster_count, } return await render_opinion_view( - request, pk, "cited-by", additional_context + request, cluster, "cited-by", additional_context ) @@ -1184,7 +1205,16 @@ async def view_opinion_summaries( :param _: url slug :return: Summaries tab """ - cluster: OpinionCluster = await aget_object_or_404(OpinionCluster, pk=pk) + ui_flag_for_o = await sync_to_async(waffle.flag_is_active)( + request, "ui_flag_for_o" + ) + if not ui_flag_for_o: + # Old page to load for people outside the flag + return await view_summaries(request=request, pk=pk, slug="summaries") + + cluster: OpinionCluster = await aget_object_or_404( + await get_opinions_base_queryset(), pk=pk + ) parenthetical_groups_qs = await get_or_create_parenthetical_groups(cluster) parenthetical_groups = [ parenthetical_group @@ -1210,7 +1240,7 @@ async def view_opinion_summaries( "ui_flag_for_o": ui_flag_for_o, } return await render_opinion_view( - request, pk, "summaries", additional_context + request, cluster, "summaries", additional_context ) @@ -1224,7 +1254,9 @@ async def view_opinion_related_cases( :param _: url slug :return: Related Cases tab """ - cluster: OpinionCluster = await aget_object_or_404(OpinionCluster, pk=pk) + cluster: OpinionCluster = await aget_object_or_404( + await get_opinions_base_queryset(), pk=pk + ) related_cluster_object = await es_get_related_clusters_with_cache( cluster, request ) @@ -1236,7 +1268,7 @@ async def view_opinion_related_cases( "queries_timeout": related_cluster_object.timeout, } return await render_opinion_view( - request, pk, "related-cases", additional_context + request, cluster, "related-cases", additional_context ) From ebfbcb9ba9543de22e81ed30bbf6859522a3d4a4 Mon Sep 17 00:00:00 2001 From: William Palin Date: Fri, 22 Nov 2024 16:15:41 -0500 Subject: [PATCH 37/37] fix(opinion-page.views): Remove extra code --- cl/opinion_page/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/cl/opinion_page/views.py b/cl/opinion_page/views.py index 5b059bb0c5..df3e7fdbd1 100644 --- a/cl/opinion_page/views.py +++ b/cl/opinion_page/views.py @@ -1017,8 +1017,6 @@ async def render_opinion_view( ui_flag_for_o = await sync_to_async(waffle.flag_is_active)( request, "ui_flag_for_o" ) - if not ui_flag_for_o: - return await view_opinion_old(request, pk, "str") if not any([ui_flag_for_o]): return await view_opinion_old(request, cluster.pk, "str")