From ca7a3774d3f0c2a1f9ce2b09bf360bb2f1322c78 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Fri, 13 Sep 2024 17:45:20 -0700 Subject: [PATCH 01/18] Created nice effect to have sweep out from the center. --- frontend/package.json | 3 +- .../annotator/display/Containers.tsx | 1 + .../annotator/display/Selection.tsx | 271 +++++++++++++++--- frontend/src/components/types.ts | 1 + .../widgets/buttons/RadialButtonCloud.tsx | 155 ++++++++++ frontend/yarn.lock | 108 ++++++- 6 files changed, 486 insertions(+), 53 deletions(-) create mode 100644 frontend/src/components/widgets/buttons/RadialButtonCloud.tsx diff --git a/frontend/package.json b/frontend/package.json index 5eb59fad..911b597a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,13 +22,14 @@ "@types/react-textfit": "^1.1.0", "@types/styled-components": "^5.1.19", "axios": "^0.24.0", + "framer-motion": "6.*", "fuse.js": "^6.5.3", "graphql": "^16.2.0", "lodash": "^4.17.21", "lodash.uniqueid": "^4.0.1", "lucide-react": "^0.438.0", "pdfjs-dist": "^2.13.216", - "react": "16", + "react": "17.*", "react-beautiful-dnd": "^13.1.0", "react-color": "^2.19.3", "react-cool-inview": "^3.0.1", diff --git a/frontend/src/components/annotator/display/Containers.tsx b/frontend/src/components/annotator/display/Containers.tsx index fee75f77..c1396d90 100644 --- a/frontend/src/components/annotator/display/Containers.tsx +++ b/frontend/src/components/annotator/display/Containers.tsx @@ -86,6 +86,7 @@ export const LabelTagContainer = styled.div<{ color: ${(props) => getContrastColor(props.color)}; padding: 2px 6px; border-radius: 3px; + position: relative; `; export const StyledIcon = styled(Icon)<{ color: string }>` diff --git a/frontend/src/components/annotator/display/Selection.tsx b/frontend/src/components/annotator/display/Selection.tsx index 7eeab8e9..9f9e1b84 100644 --- a/frontend/src/components/annotator/display/Selection.tsx +++ b/frontend/src/components/annotator/display/Selection.tsx @@ -6,7 +6,7 @@ import React, { SyntheticEvent, useRef, } from "react"; -import styled from "styled-components"; +import styled, { keyframes } from "styled-components"; import _ from "lodash"; import uniqueId from "lodash/uniqueId"; @@ -18,6 +18,8 @@ import { Image, Button, Icon, + SemanticICONS, + ButtonProps, } from "semantic-ui-react"; import { @@ -43,6 +45,7 @@ import { SelectionInfoContainer, } from "./Containers"; import { getBorderWidthFromBounds } from "../../../utils/transform"; +import RadialButtonCloud from "../../widgets/buttons/RadialButtonCloud"; interface TokenSpanProps { id?: string; @@ -58,6 +61,81 @@ interface TokenSpanProps { theme?: any; } +// Define animations +const pulse = keyframes` + 0% { + box-shadow: 0 0 0 0 rgba(0, 255, 0, 0.7); + } + 70% { + box-shadow: 0 0 0 10px rgba(0, 255, 0, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(0, 255, 0, 0); + } +`; + +const PulsingDot = styled.div` + width: 12px; + height: 12px; + background-color: #00ff00; + border-radius: 50%; + animation: ${pulse} 2s infinite; + cursor: pointer; + position: relative; +`; + +const CloudContainer = styled.div` + position: absolute; + top: -60px; /* Adjust as needed */ + left: -60px; /* Adjust as needed */ + width: 120px; + height: 120px; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + pointer-events: auto; + opacity: 0; + animation: fadeIn 0.5s forwards; + + @keyframes fadeIn { + to { + opacity: 1; + } + } +`; + +interface CloudButtonProps extends ButtonProps { + delay: number; + xOffset: number; + yOffset: number; +} + +interface CloudButtonItem { + name: SemanticICONS; // Semantic UI icon names + color: string; + tooltip: string; + onClick: () => void; +} + +const CloudButton = styled(Button)` + position: absolute; + opacity: 0; + animation: moveOut 0.5s forwards; + animation-delay: ${(props) => props.delay}s; + transform: translate(0, 0); + + @keyframes moveOut { + to { + opacity: 1; + transform: translate( + ${(props) => props.xOffset}px, + ${(props) => props.yOffset}px + ); + } + } +`; + /** * Originally Got This Error: * Over 200 classes were generated for component styled.div with the id of "sc-dlVxhl". @@ -324,6 +402,8 @@ export const Selection: React.FC = ({ }) => { const [hovered, setHovered] = useState(false); const [isEditLabelModalVisible, setIsEditLabelModalVisible] = useState(false); + const [cloudVisible, setCloudVisible] = useState(false); + const cloudRef = useRef(null); const annotationStore = useContext(AnnotationStore); const label = annotation.annotationLabel; @@ -338,6 +418,24 @@ export const Selection: React.FC = ({ annotationStore.deleteAnnotation(annotation.id); }; + const buttonList: CloudButtonItem[] = [ + { + name: "pencil", + color: "blue", + tooltip: "Edit Annotation", + onClick: () => { + setIsEditLabelModalVisible(true); + }, + }, + { + name: "trash alternate outline", + color: "red", + tooltip: "Delete Annotation", + onClick: removeAnnotation, + }, + // Add more buttons as needed + ]; + const onShiftClick = () => { const current = annotationStore.selectedAnnotations.slice(0); if (current.some((other) => other === annotation.id)) { @@ -349,6 +447,30 @@ export const Selection: React.FC = ({ } }; + const handleClickOutside = (event: Event): void => { + if ( + cloudRef.current && + !cloudRef.current.contains(event.target as Node) && + !(event.target as Element).closest(".pulsing-dot") + ) { + setCloudVisible(false); + } + }; + + useEffect(() => { + if (cloudVisible) { + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("touchstart", handleClickOutside); + } else { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchstart", handleClickOutside); + } + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchstart", handleClickOutside); + }; + }, [cloudVisible]); + const selected = annotationStore.selectedAnnotations.includes(annotation.id); let relationship_type = ""; @@ -414,52 +536,109 @@ export const Selection: React.FC = ({ color={color} display_behavior={labelBehavior} > -
- {label.text} -
- {annotation.myPermissions.includes( - PermissionTypes.CAN_UPDATE - ) && - !annotation.annotationLabel.readonly && ( - { - e.stopPropagation(); - setIsEditLabelModalVisible(true); - }} - onMouseDown={(e: React.SyntheticEvent) => { - e.stopPropagation(); - }} - /> - )} - {annotation.myPermissions.includes( - PermissionTypes.CAN_REMOVE - ) && - !annotation.annotationLabel.readonly && ( - { - e.stopPropagation(); - removeAnnotation(); - }} - // We have to prevent the default behaviour for - // the pdf canvas here, in order to be able to capture - // the click event. - onMouseDown={(e: React.SyntheticEvent) => { - e.stopPropagation(); - }} - /> +
+ {/* setCloudVisible(true)} + onMouseLeave={() => { + // Delay hiding to allow interaction with the cloud + setTimeout(() => { + if (!cloudRef.current?.contains(document.activeElement as Node)) { + setCloudVisible(false); + } + }, 200); + }} + /> */} + + {cloudVisible && ( + + {buttonList.map((btn, index) => ( + ) => { + e.stopPropagation(); + btn.onClick(); + setCloudVisible(false); + }} + title={btn.tooltip} + delay={index * 0.1} + xOffset={ + Math.cos( + (index / buttonList.length) * 2 * Math.PI + ) * 50 + } + yOffset={ + Math.sin( + (index / buttonList.length) * 2 * Math.PI + ) * 50 + } + > + + + ))} + )} +
+ {label.text} +
+ {annotation.myPermissions.includes( + PermissionTypes.CAN_UPDATE + ) && + !annotation.annotationLabel.readonly && ( + { + e.stopPropagation(); + setIsEditLabelModalVisible(true); + }} + onMouseDown={(e: React.SyntheticEvent) => { + e.stopPropagation(); + }} + /> + )} + {annotation.myPermissions.includes( + PermissionTypes.CAN_REMOVE + ) && + !annotation.annotationLabel.readonly && ( + { + e.stopPropagation(); + removeAnnotation(); + }} + // We have to prevent the default behaviour for + // the pdf canvas here, in order to be able to capture + // the click event. + onMouseDown={(e: React.SyntheticEvent) => { + e.stopPropagation(); + }} + /> + )} +
diff --git a/frontend/src/components/types.ts b/frontend/src/components/types.ts index c288d430..82678761 100644 --- a/frontend/src/components/types.ts +++ b/frontend/src/components/types.ts @@ -25,6 +25,7 @@ export enum ExportTypes { export enum PermissionTypes { CAN_PERMISSION = "CAN_PERMISSION", CAN_PUBLISH = "CAN_PUBLISH", + CAN_COMMENT = "CAN_COMMENT", CAN_CREATE = "CAN_CREATE", CAN_READ = "CAN_READ", CAN_UPDATE = "CAN_UPDATE", diff --git a/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx b/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx new file mode 100644 index 00000000..7cc6f45d --- /dev/null +++ b/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx @@ -0,0 +1,155 @@ +import React, { useState, useRef, useEffect } from "react"; +import styled, { keyframes } from "styled-components"; +import { Button, Icon, SemanticICONS, ButtonProps } from "semantic-ui-react"; + +const pulse = keyframes` + 0% { + box-shadow: 0 0 0 0 rgba(0, 255, 0, 0.7); + } + 70% { + box-shadow: 0 0 0 10px rgba(0, 255, 0, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(0, 255, 0, 0); + } +`; + +const PulsingDot = styled.div` + width: 12px; + height: 12px; + background-color: #00ff00; + border-radius: 50%; + animation: ${pulse} 2s infinite; + cursor: pointer; + position: relative; +`; + +const CloudContainer = styled.div` + position: absolute; + top: -60px; + left: -60px; + width: 120px; + height: 120px; + display: flex; + align-items: center; + justify-content: center; + pointer-events: auto; +`; + +interface CloudButtonProps extends ButtonProps { + delay: number; + angle: number; + distance: number; +} + +const moveOut = (props: CloudButtonProps) => keyframes` + from { + opacity: 0; + transform: translate(0, 0); + } + to { + opacity: 1; + transform: translate( + ${Math.cos(props.angle) * props.distance}px, + ${Math.sin(props.angle) * props.distance}px + ); + } +`; + +const CloudButton = styled(Button)` + position: absolute; + opacity: 0; + animation: ${moveOut} 0.5s forwards; + animation-delay: ${(props) => props.delay}s; +`; + +interface CloudButtonItem { + name: SemanticICONS; + color: string; + tooltip: string; + onClick: () => void; +} + +const RadialButtonCloud: React.FC = () => { + const [cloudVisible, setCloudVisible] = useState(false); + const cloudRef = useRef(null); + + const buttonList: CloudButtonItem[] = [ + { + name: "pencil", + color: "blue", + tooltip: "Edit Annotation", + onClick: () => { + console.log("Edit clicked"); + }, + }, + { + name: "trash alternate outline", + color: "red", + tooltip: "Delete Annotation", + onClick: () => { + console.log("Delete clicked"); + }, + }, + // Add more buttons as needed + ]; + + const handleClickOutside = (event: MouseEvent) => { + if ( + cloudRef.current && + !cloudRef.current.contains(event.target as Node) && + !(event.target as Element).closest(".pulsing-dot") + ) { + setCloudVisible(false); + } + }; + + useEffect(() => { + if (cloudVisible) { + document.addEventListener("mousedown", handleClickOutside); + } else { + document.removeEventListener("mousedown", handleClickOutside); + } + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [cloudVisible]); + + return ( +
+ setCloudVisible(true)} + /> + {cloudVisible && ( + + {buttonList.map((btn, index) => { + const angle = (index / buttonList.length) * 2 * Math.PI; + return ( + ) => { + e.stopPropagation(); + btn.onClick(); + setCloudVisible(false); + }} + title={btn.tooltip} + delay={index * 0.1} + angle={angle} + distance={50} + > + + + ); + })} + + )} +
+ ); +}; + +export default RadialButtonCloud; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index f5491e70..bd9efafb 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1765,7 +1765,7 @@ resolved "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-12.0.0.tgz" integrity sha512-M0qqxAcwCsIVfpFQSlGN5XjXWu8l5JDZN+fPt1LeW5SZexQTgnaEvgXAY+CeygRw0EeppWHi12JxESWiWrB0Sg== -"@emotion/is-prop-valid@^0.8.8": +"@emotion/is-prop-valid@^0.8.2", "@emotion/is-prop-valid@^0.8.8": version "0.8.8" resolved "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz" integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA== @@ -2700,6 +2700,59 @@ resolved "https://registry.yarnpkg.com/@kamilkisiela/fast-url-parser/-/fast-url-parser-1.1.4.tgz#9d68877a489107411b953c54ea65d0658b515809" integrity sha512-gbkePEBupNydxCelHCESvFSFM8XPh1Zs/OAVRW/rKpEqPAl5PbOM90Si8mv9bvnR53uPD2s/FiRxdvSejpRJew== +"@motionone/animation@^10.12.0": + version "10.18.0" + resolved "https://registry.yarnpkg.com/@motionone/animation/-/animation-10.18.0.tgz#868d00b447191816d5d5cf24b1cafa144017922b" + integrity sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw== + dependencies: + "@motionone/easing" "^10.18.0" + "@motionone/types" "^10.17.1" + "@motionone/utils" "^10.18.0" + tslib "^2.3.1" + +"@motionone/dom@10.12.0": + version "10.12.0" + resolved "https://registry.yarnpkg.com/@motionone/dom/-/dom-10.12.0.tgz#ae30827fd53219efca4e1150a5ff2165c28351ed" + integrity sha512-UdPTtLMAktHiqV0atOczNYyDd/d8Cf5fFsd1tua03PqTwwCe/6lwhLSQ8a7TbnQ5SN0gm44N1slBfj+ORIhrqw== + dependencies: + "@motionone/animation" "^10.12.0" + "@motionone/generators" "^10.12.0" + "@motionone/types" "^10.12.0" + "@motionone/utils" "^10.12.0" + hey-listen "^1.0.8" + tslib "^2.3.1" + +"@motionone/easing@^10.18.0": + version "10.18.0" + resolved "https://registry.yarnpkg.com/@motionone/easing/-/easing-10.18.0.tgz#7b82f6010dfee3a1bb0ee83abfbaff6edae0c708" + integrity sha512-VcjByo7XpdLS4o9T8t99JtgxkdMcNWD3yHU/n6CLEz3bkmKDRZyYQ/wmSf6daum8ZXqfUAgFeCZSpJZIMxaCzg== + dependencies: + "@motionone/utils" "^10.18.0" + tslib "^2.3.1" + +"@motionone/generators@^10.12.0": + version "10.18.0" + resolved "https://registry.yarnpkg.com/@motionone/generators/-/generators-10.18.0.tgz#fe09ab5cfa0fb9a8884097feb7eb60abeb600762" + integrity sha512-+qfkC2DtkDj4tHPu+AFKVfR/C30O1vYdvsGYaR13W/1cczPrrcjdvYCj0VLFuRMN+lP1xvpNZHCRNM4fBzn1jg== + dependencies: + "@motionone/types" "^10.17.1" + "@motionone/utils" "^10.18.0" + tslib "^2.3.1" + +"@motionone/types@^10.12.0", "@motionone/types@^10.17.1": + version "10.17.1" + resolved "https://registry.yarnpkg.com/@motionone/types/-/types-10.17.1.tgz#cf487badbbdc9da0c2cb86ffc1e5d11147c6e6fb" + integrity sha512-KaC4kgiODDz8hswCrS0btrVrzyU2CSQKO7Ps90ibBVSQmjkrt2teqta6/sOG59v7+dPnKMAg13jyqtMKV2yJ7A== + +"@motionone/utils@^10.12.0", "@motionone/utils@^10.18.0": + version "10.18.0" + resolved "https://registry.yarnpkg.com/@motionone/utils/-/utils-10.18.0.tgz#a59ff8932ed9009624bca07c56b28ef2bb2f885e" + integrity sha512-3XVF7sgyTSI2KWvTf6uLlBJ5iAgRgmvp3bpuOiQJvInd4nZ19ET8lX5unn30SlmRH7hXbBbH+Gxd0m0klJ3Xtw== + dependencies: + "@motionone/types" "^10.17.1" + hey-listen "^1.0.8" + tslib "^2.3.1" + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" @@ -6765,6 +6818,27 @@ fraction.js@^4.1.1: resolved "https://registry.npmjs.org/fraction.js/-/fraction.js-4.1.2.tgz" integrity sha512-o2RiJQ6DZaR/5+Si0qJUIy637QMRudSi9kU/FFzx9EZazrIdnBgpU+3sEWCxAVhH2RtxW2Oz+T4p2o8uOPVcgA== +framer-motion@6.*: + version "6.5.1" + resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-6.5.1.tgz#802448a16a6eb764124bf36d8cbdfa6dd6b931a7" + integrity sha512-o1BGqqposwi7cgDrtg0dNONhkmPsUFDaLcKXigzuTFC5x58mE8iyTazxSudFzmT6MEyJKfjjU8ItoMe3W+3fiw== + dependencies: + "@motionone/dom" "10.12.0" + framesync "6.0.1" + hey-listen "^1.0.8" + popmotion "11.0.3" + style-value-types "5.0.0" + tslib "^2.1.0" + optionalDependencies: + "@emotion/is-prop-valid" "^0.8.2" + +framesync@6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/framesync/-/framesync-6.0.1.tgz#5e32fc01f1c42b39c654c35b16440e07a25d6f20" + integrity sha512-fUY88kXvGiIItgNC7wcTOl0SNRCVXMKSWW2Yzfmn7EKNc+MpCzcz9DhdHcdjbrtN3c6R4H5dTY2jiCpPdysEjA== + dependencies: + tslib "^2.1.0" + fresh@0.5.2: version "0.5.2" resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz" @@ -7145,6 +7219,11 @@ header-case@^2.0.4: capital-case "^1.0.4" tslib "^2.0.3" +hey-listen@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68" + integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q== + highlight.js@^10.4.1, highlight.js@~10.7.0: version "10.7.3" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531" @@ -10199,6 +10278,16 @@ pkg-up@^3.1.0: dependencies: find-up "^3.0.0" +popmotion@11.0.3: + version "11.0.3" + resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-11.0.3.tgz#565c5f6590bbcddab7a33a074bb2ba97e24b0cc9" + integrity sha512-Y55FLdj3UxkR7Vl3s7Qr4e9m0onSnP8W7d/xQLsoJM40vs6UKHFdygs6SWryasTZYqugMjm3BepCF4CWXDiHgA== + dependencies: + framesync "6.0.1" + hey-listen "^1.0.8" + style-value-types "5.0.0" + tslib "^2.1.0" + popper.js@^1.14.4: version "1.16.1" resolved "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz" @@ -11293,14 +11382,13 @@ react-toastify@^8.1.0: dependencies: clsx "^1.1.1" -react@16: - version "16.14.0" - resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" - integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== +react@17.*: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" + integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" - prop-types "^15.6.2" reactcss@^1.2.0: version "1.2.3" @@ -12347,6 +12435,14 @@ style-to-object@^0.4.0: dependencies: inline-style-parser "0.1.1" +style-value-types@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/style-value-types/-/style-value-types-5.0.0.tgz#76c35f0e579843d523187989da866729411fc8ad" + integrity sha512-08yq36Ikn4kx4YU6RD7jWEv27v4V+PUsOGa4n/as8Et3CuODMJQ00ENeAVXAeydX4Z2j1XHZF1K2sX4mGl18fA== + dependencies: + hey-listen "^1.0.8" + tslib "^2.1.0" + styled-components@^5.3.3: version "5.3.3" resolved "https://registry.npmjs.org/styled-components/-/styled-components-5.3.3.tgz" From 0ca36f4344ba7a098d3f8db3572cf79a4f145567 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Fri, 13 Sep 2024 17:58:11 -0700 Subject: [PATCH 02/18] Improved RadialButtonCloud. --- frontend/package.json | 1 + .../annotator/display/Selection.tsx | 2 +- .../widgets/buttons/RadialButtonCloud.tsx | 38 ++++++++++++++++--- frontend/yarn.lock | 14 +++++++ 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 911b597a..ee92daea 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "lodash.uniqueid": "^4.0.1", "lucide-react": "^0.438.0", "pdfjs-dist": "^2.13.216", + "polished": "^4.3.1", "react": "17.*", "react-beautiful-dnd": "^13.1.0", "react-color": "^2.19.3", diff --git a/frontend/src/components/annotator/display/Selection.tsx b/frontend/src/components/annotator/display/Selection.tsx index 9f9e1b84..b1bc16bf 100644 --- a/frontend/src/components/annotator/display/Selection.tsx +++ b/frontend/src/components/annotator/display/Selection.tsx @@ -555,7 +555,7 @@ export const Selection: React.FC = ({ }, 200); }} /> */} - + {cloudVisible && ( {buttonList.map((btn, index) => ( diff --git a/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx b/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx index 7cc6f45d..393bad9e 100644 --- a/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx +++ b/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useEffect } from "react"; -import styled, { keyframes } from "styled-components"; +import styled, { keyframes, css } from "styled-components"; import { Button, Icon, SemanticICONS, ButtonProps } from "semantic-ui-react"; +import { getLuminance, getContrast } from "polished"; const pulse = keyframes` 0% { @@ -14,10 +15,14 @@ const pulse = keyframes` } `; -const PulsingDot = styled.div` +interface PulsingDotProps { + backgroundColor: string; +} + +const PulsingDot = styled.div` width: 12px; height: 12px; - background-color: #00ff00; + background-color: ${(props) => props.backgroundColor}; border-radius: 50%; animation: ${pulse} 2s infinite; cursor: pointer; @@ -70,7 +75,13 @@ interface CloudButtonItem { onClick: () => void; } -const RadialButtonCloud: React.FC = () => { +interface RadialButtonCloudProps { + parentBackgroundColor: string; +} + +const RadialButtonCloud: React.FC = ({ + parentBackgroundColor, +}) => { const [cloudVisible, setCloudVisible] = useState(false); const cloudRef = useRef(null); @@ -115,16 +126,31 @@ const RadialButtonCloud: React.FC = () => { }; }, [cloudVisible]); + // Calculate the radius based on viewport width, with a minimum of 5vw + const radius = Math.max(5, Math.min(10, window.innerWidth * 0.05)); // vw to px + const buttonSize = 30; // Assuming each button is roughly 30px + const arcLength = buttonList.length * (buttonSize + 10); // 10px padding between buttons + const arcAngle = arcLength / radius; + + // Calculate dot color with good contrast + const getContrastColor = (bgColor: string) => { + const luminance = getLuminance(bgColor); + return luminance > 0.5 ? "#00aa00" : "#00ff00"; + }; + + const dotColor = getContrastColor(parentBackgroundColor); + return (
setCloudVisible(true)} + backgroundColor={dotColor} /> {cloudVisible && ( {buttonList.map((btn, index) => { - const angle = (index / buttonList.length) * 2 * Math.PI; + const angle = (index / (buttonList.length - 1) - 0.5) * arcAngle; return ( { title={btn.tooltip} delay={index * 0.1} angle={angle} - distance={50} + distance={radius} > diff --git a/frontend/yarn.lock b/frontend/yarn.lock index bd9efafb..f0207f90 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1650,6 +1650,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.17.8": + version "7.25.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.6.tgz#9afc3289f7184d8d7f98b099884c26317b9264d2" + integrity sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.3.1": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.7.tgz#f4f0d5530e8dbdf59b3451b9b3e594b6ba082e12" @@ -10278,6 +10285,13 @@ pkg-up@^3.1.0: dependencies: find-up "^3.0.0" +polished@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/polished/-/polished-4.3.1.tgz#5a00ae32715609f83d89f6f31d0f0261c6170548" + integrity sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA== + dependencies: + "@babel/runtime" "^7.17.8" + popmotion@11.0.3: version "11.0.3" resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-11.0.3.tgz#565c5f6590bbcddab7a33a074bb2ba97e24b0cc9" From 98e56af07a268e63e95137c47535c65d58a3be83 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Fri, 13 Sep 2024 22:14:32 -0700 Subject: [PATCH 03/18] Reworked the button positioning to follow a certain step distance along a parabolics spiral. --- .../widgets/buttons/RadialButtonCloud.tsx | 170 +++++++++++++----- 1 file changed, 128 insertions(+), 42 deletions(-) diff --git a/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx b/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx index 393bad9e..595bfa9f 100644 --- a/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx +++ b/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx @@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect } from "react"; import styled, { keyframes, css } from "styled-components"; import { Button, Icon, SemanticICONS, ButtonProps } from "semantic-ui-react"; import { getLuminance, getContrast } from "polished"; +import useWindowDimensions from "../../hooks/WindowDimensionHook"; const pulse = keyframes` 0% { @@ -41,25 +42,57 @@ const CloudContainer = styled.div` pointer-events: auto; `; +interface ButtonPosition { + x: number; + y: number; +} + +function calculateButtonPositions( + n: number, + a: number, + spacingAlong: number, + skipCount: number = 2 +): ButtonPosition[] { + const positions: ButtonPosition[] = []; + let t = 0; + + for (let i = 0; i < n + skipCount; i++) { + const r = a * t; + positions.push({ + x: r * Math.cos(t), + y: r * Math.sin(t), + }); + + // Calculate the next t value based on the desired arc length + // The arc length of a spiral from 0 to t is approximately (a/2) * (t^2) + // So, we solve for the next t that gives us an additional arc length of 'spacingAlong' + const currentArcLength = (a / 2) * (t * t); + const nextArcLength = currentArcLength + spacingAlong; + t = Math.sqrt((2 * nextArcLength) / a); + } + + // Return only the positions after skipping the specified number + return positions.slice(skipCount); +} + interface CloudButtonProps extends ButtonProps { delay: number; - angle: number; - distance: number; + position: ButtonPosition; } const moveOut = (props: CloudButtonProps) => keyframes` - from { - opacity: 0; - transform: translate(0, 0); - } - to { - opacity: 1; - transform: translate( - ${Math.cos(props.angle) * props.distance}px, - ${Math.sin(props.angle) * props.distance}px - ); - } -`; + from { + opacity: 0; + transform: translate(0, 0); + } + to { + opacity: 1; + transform: translate( + ${props.position.x}px, + ${props.position.y}px + ); + } + `; const CloudButton = styled(Button)` position: absolute; @@ -83,9 +116,58 @@ const RadialButtonCloud: React.FC = ({ parentBackgroundColor, }) => { const [cloudVisible, setCloudVisible] = useState(false); + const { height } = useWindowDimensions(); const cloudRef = useRef(null); const buttonList: CloudButtonItem[] = [ + { + name: "pencil", + color: "blue", + tooltip: "Edit Annotation", + onClick: () => { + console.log("Edit clicked"); + }, + }, + { + name: "trash alternate outline", + color: "red", + tooltip: "Delete Annotation", + onClick: () => { + console.log("Delete clicked"); + }, + }, + { + name: "pencil", + color: "blue", + tooltip: "Edit Annotation", + onClick: () => { + console.log("Edit clicked"); + }, + }, + { + name: "trash alternate outline", + color: "red", + tooltip: "Delete Annotation", + onClick: () => { + console.log("Delete clicked"); + }, + }, + { + name: "pencil", + color: "blue", + tooltip: "Edit Annotation", + onClick: () => { + console.log("Edit clicked"); + }, + }, + { + name: "trash alternate outline", + color: "red", + tooltip: "Delete Annotation", + onClick: () => { + console.log("Delete clicked"); + }, + }, { name: "pencil", color: "blue", @@ -126,11 +208,19 @@ const RadialButtonCloud: React.FC = ({ }; }, [cloudVisible]); - // Calculate the radius based on viewport width, with a minimum of 5vw - const radius = Math.max(5, Math.min(10, window.innerWidth * 0.05)); // vw to px - const buttonSize = 30; // Assuming each button is roughly 30px - const arcLength = buttonList.length * (buttonSize + 10); // 10px padding between buttons - const arcAngle = arcLength / radius; + const numButtons = buttonList.length; + const a = 6; // Controls the growth rate of the spiral + const spacingAlongPercent = 3; // 5% of the container height + const spacingAlong = (height * spacingAlongPercent) / 100; + const skipCount = 2; // Number of inner positions to skip + + // Calculate button positions + const buttonPositions = calculateButtonPositions( + numButtons, + a, + spacingAlong, + skipCount + ); // Calculate dot color with good contrast const getContrastColor = (bgColor: string) => { @@ -149,29 +239,25 @@ const RadialButtonCloud: React.FC = ({ /> {cloudVisible && ( - {buttonList.map((btn, index) => { - const angle = (index / (buttonList.length - 1) - 0.5) * arcAngle; - return ( - ) => { - e.stopPropagation(); - btn.onClick(); - setCloudVisible(false); - }} - title={btn.tooltip} - delay={index * 0.1} - angle={angle} - distance={radius} - > - - - ); - })} + {buttonList.map((btn, index) => ( + ) => { + e.stopPropagation(); + btn.onClick(); + setCloudVisible(false); + }} + title={btn.tooltip} + delay={index * 0.1} + position={buttonPositions[index]} + > + + + ))} )}
From 5e00679466ba92405fdca70467e80065cb37ee96 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Fri, 13 Sep 2024 22:50:59 -0700 Subject: [PATCH 04/18] Added approval feedback mechanism... create gentle pulsing effect around annotation bounds when approved or rejected props are set. --- .../annotator/display/Containers.tsx | 37 ++++++++-- .../src/components/annotator/display/Page.tsx | 2 + .../annotator/display/Selection.tsx | 68 +++---------------- .../annotator/display/SelectionBoundary.tsx | 48 ++++++++++++- .../components/annotator/display/effects.tsx | 25 +++++++ .../widgets/buttons/RadialButtonCloud.tsx | 5 +- 6 files changed, 119 insertions(+), 66 deletions(-) create mode 100644 frontend/src/components/annotator/display/effects.tsx diff --git a/frontend/src/components/annotator/display/Containers.tsx b/frontend/src/components/annotator/display/Containers.tsx index c1396d90..a298fa8e 100644 --- a/frontend/src/components/annotator/display/Containers.tsx +++ b/frontend/src/components/annotator/display/Containers.tsx @@ -1,5 +1,5 @@ import React, { useState, useContext } from "react"; -import styled from "styled-components"; +import styled, { css } from "styled-components"; import { Icon, Image } from "semantic-ui-react"; import { AnnotationStore, ServerAnnotation, PDFPageInfo } from "../context"; import { BoundingBox, PermissionTypes } from "../../types"; @@ -10,6 +10,7 @@ import { getRelationImageHref, } from "../utils"; import { getContrastColor } from "../../../utils/transform"; +import { pulseGreen, pulseMaroon } from "./effects"; // ... (keep the existing interfaces) @@ -36,18 +37,23 @@ export const SelectionContainer = styled.div<{ // We use transform here because we need to translate the label upward // to sit on top of the bounds as a function of *its own* height, // not the height of it's parent. -export interface SelectionInfoProps { +interface SelectionInfoProps { + bounds: { + left: number; + right: number; + }; border: number; - bounds: BoundingBox; color: string; showBoundingBox: boolean; + approved?: boolean; + rejected?: boolean; } export const SelectionInfo = styled.div` position: absolute; width: ${(props) => props.bounds.right - props.bounds.left}px; - right: -${(props) => props.border}px; - transform: translateY(-100%); + right: -${(props) => props.border + (props?.approved || props?.rejected ? 1 : 0)}px; + bottom: calc(100% - 2px); border-radius: 4px 4px 0 0; background: ${(props) => props.showBoundingBox ? props.color : "rgba(255, 255, 255, 0.9)"}; @@ -55,7 +61,26 @@ export const SelectionInfo = styled.div` font-weight: bold; font-size: 12px; user-select: none; - box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1); + box-sizing: border-box; + + ${(props) => + props.approved && + css` + border-top: 2px solid green; + border-left: 2px solid green; + border-right: 2px solid green; + animation: ${pulseGreen} 2s infinite; + `} + + ${(props) => + props.rejected && + css` + border-top: 2px solid maroon; + border-left: 2px solid maroon; + border-right: 2px solid maroon; + animation: ${pulseMaroon} 2s infinite; + `} + * { vertical-align: middle; } diff --git a/frontend/src/components/annotator/display/Page.tsx b/frontend/src/components/annotator/display/Page.tsx index 1af00cb4..0469cf76 100644 --- a/frontend/src/components/annotator/display/Page.tsx +++ b/frontend/src/components/annotator/display/Page.tsx @@ -284,6 +284,8 @@ export const Page = ({ pageInfo={pageInfo} annotation={annotation} setJumpedToAnnotationOnLoad={setJumpedToAnnotationOnLoad} + // approved + rejected /> )); }, [ diff --git a/frontend/src/components/annotator/display/Selection.tsx b/frontend/src/components/annotator/display/Selection.tsx index b1bc16bf..6c0fa8d7 100644 --- a/frontend/src/components/annotator/display/Selection.tsx +++ b/frontend/src/components/annotator/display/Selection.tsx @@ -6,7 +6,7 @@ import React, { SyntheticEvent, useRef, } from "react"; -import styled, { keyframes } from "styled-components"; +import styled, { css, keyframes } from "styled-components"; import _ from "lodash"; import uniqueId from "lodash/uniqueId"; @@ -61,29 +61,6 @@ interface TokenSpanProps { theme?: any; } -// Define animations -const pulse = keyframes` - 0% { - box-shadow: 0 0 0 0 rgba(0, 255, 0, 0.7); - } - 70% { - box-shadow: 0 0 0 10px rgba(0, 255, 0, 0); - } - 100% { - box-shadow: 0 0 0 0 rgba(0, 255, 0, 0); - } -`; - -const PulsingDot = styled.div` - width: 12px; - height: 12px; - background-color: #00ff00; - border-radius: 50%; - animation: ${pulse} 2s infinite; - cursor: pointer; - position: relative; -`; - const CloudContainer = styled.div` position: absolute; top: -60px; /* Adjust as needed */ @@ -136,26 +113,6 @@ const CloudButton = styled(Button)` } `; -/** - * Originally Got This Error: - * Over 200 classes were generated for component styled.div with the id of "sc-dlVxhl". - * Consider using the attrs method, together with a style object for frequently changed styles. - * - * Example: - * const Component = styled.div.attrs(props => ({ - * style: { - * background: props.background, - * }, - * }))`width: 100%;` - * - * Refactored to reflect this pattern. - * - * FYI, Tokens don't respond to pointerEvents because - * they are ontop of the bounding boxes and the canvas, - * which do respond to pointer events. - * - */ - const TokenSpan = styled.span.attrs( ({ id, @@ -385,6 +342,8 @@ interface SelectionProps { labelBehavior: LabelDisplayBehavior; showInfo?: boolean; children?: React.ReactNode; + approved?: boolean; + rejected?: boolean; setJumpedToAnnotationOnLoad: (annot: string) => null | void; } @@ -397,6 +356,8 @@ export const Selection: React.FC = ({ labelBehavior, annotation, children, + approved, + rejected, showInfo = true, setJumpedToAnnotationOnLoad, }) => { @@ -494,18 +455,23 @@ export const Selection: React.FC = ({ bounds={bounds} onHover={setHovered} onClick={onShiftClick} + approved={approved} + rejected={rejected} setJumpedToAnnotationOnLoad={setJumpedToAnnotationOnLoad} selected={selected} > {showInfo && !annotationStore.hideLabels && ( - +
@@ -543,18 +509,6 @@ export const Selection: React.FC = ({ alignItems: "center", }} > - {/* setCloudVisible(true)} - onMouseLeave={() => { - // Delay hiding to allow interaction with the cloud - setTimeout(() => { - if (!cloudRef.current?.contains(document.activeElement as Node)) { - setCloudVisible(false); - } - }, 200); - }} - /> */} {cloudVisible && ( diff --git a/frontend/src/components/annotator/display/SelectionBoundary.tsx b/frontend/src/components/annotator/display/SelectionBoundary.tsx index f36b2bfd..d022f944 100644 --- a/frontend/src/components/annotator/display/SelectionBoundary.tsx +++ b/frontend/src/components/annotator/display/SelectionBoundary.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from "react"; -import styled from "styled-components"; +import styled, { keyframes, css } from "styled-components"; import { BoundingBox } from "../../types"; import { getBorderWidthFromBounds, hexToRgb } from "../../../utils/transform"; @@ -19,8 +19,34 @@ interface SelectionBoundaryProps { onHover?: (hovered: boolean) => void; onClick?: () => void; setJumpedToAnnotationOnLoad?: (annot_id: string) => null | void; + approved?: boolean; + rejected?: boolean; } +const pulseGreen = keyframes` + 0% { + box-shadow: 0 0 0 0 rgba(0, 128, 0, 0.4); + } + 70% { + box-shadow: 0 0 0 10px rgba(0, 128, 0, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(0, 128, 0, 0); + } +`; + +const pulseMaroon = keyframes` + 0% { + box-shadow: 0 0 0 0 rgba(128, 0, 0, 0.4); + } + 70% { + box-shadow: 0 0 0 10px rgba(128, 0, 0, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(128, 0, 0, 0); + } +`; + const BoundarySpan = styled.span<{ width: number; height: number; @@ -32,6 +58,8 @@ const BoundarySpan = styled.span<{ color: string; bounds: BoundingBox; backgroundColor: string; + approved?: boolean; + rejected?: boolean; }>` position: absolute; left: ${(props) => props.bounds.left}px; @@ -47,6 +75,20 @@ const BoundarySpan = styled.span<{ : "none"}; background-color: ${(props) => props.backgroundColor}; transition: background-color 0.2s ease; + + ${(props) => + props.approved && + css` + border: 2px solid green; + animation: ${pulseGreen} 2s infinite; + `} + + ${(props) => + props.rejected && + css` + border: 2px solid maroon; + animation: ${pulseMaroon} 2s infinite; + `} `; export const SelectionBoundary: React.FC = ({ @@ -62,6 +104,8 @@ export const SelectionBoundary: React.FC = ({ onClick, setJumpedToAnnotationOnLoad, selected, + approved, + rejected, }) => { const width = bounds.right - bounds.left; const height = bounds.bottom - bounds.top; @@ -124,6 +168,8 @@ export const SelectionBoundary: React.FC = ({ color={color} backgroundColor={backgroundColor} bounds={bounds} + approved={approved} + rejected={rejected} > {children || null} diff --git a/frontend/src/components/annotator/display/effects.tsx b/frontend/src/components/annotator/display/effects.tsx new file mode 100644 index 00000000..aa4a26ba --- /dev/null +++ b/frontend/src/components/annotator/display/effects.tsx @@ -0,0 +1,25 @@ +import { keyframes } from "styled-components"; + +export const pulseGreen = keyframes` + 0% { + box-shadow: 0 -2px 4px rgba(0, 128, 0, 0.4); + } + 70% { + box-shadow: 0 -2px 10px rgba(0, 128, 0, 0); + } + 100% { + box-shadow: 0 -2px 4px rgba(0, 128, 0, 0); + } +`; + +export const pulseMaroon = keyframes` + 0% { + box-shadow: 0 -2px 4px rgba(128, 0, 0, 0.4); + } + 70% { + box-shadow: 0 -2px 10px rgba(128, 0, 0, 0); + } + 100% { + box-shadow: 0 -2px 4px rgba(128, 0, 0, 0); + } +`; diff --git a/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx b/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx index 595bfa9f..1d95fc2a 100644 --- a/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx +++ b/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx @@ -3,6 +3,7 @@ import styled, { keyframes, css } from "styled-components"; import { Button, Icon, SemanticICONS, ButtonProps } from "semantic-ui-react"; import { getLuminance, getContrast } from "polished"; import useWindowDimensions from "../../hooks/WindowDimensionHook"; +import { MOBILE_VIEW_BREAKPOINT } from "../../../assets/configurations/constants"; const pulse = keyframes` 0% { @@ -116,7 +117,7 @@ const RadialButtonCloud: React.FC = ({ parentBackgroundColor, }) => { const [cloudVisible, setCloudVisible] = useState(false); - const { height } = useWindowDimensions(); + const { height, width } = useWindowDimensions(); const cloudRef = useRef(null); const buttonList: CloudButtonItem[] = [ @@ -210,7 +211,7 @@ const RadialButtonCloud: React.FC = ({ const numButtons = buttonList.length; const a = 6; // Controls the growth rate of the spiral - const spacingAlongPercent = 3; // 5% of the container height + const spacingAlongPercent = width <= MOBILE_VIEW_BREAKPOINT ? 8 : 3; // 5% of the container height const spacingAlong = (height * spacingAlongPercent) / 100; const skipCount = 2; // Number of inner positions to skip From 6dfe06dfcdf7afd2e30bd608b52d3a279883f0fb Mon Sep 17 00:00:00 2001 From: JSv4 Date: Sat, 14 Sep 2024 01:07:00 -0700 Subject: [PATCH 05/18] Adding UserFeedback object and fixing potential permission issue on nested fields. --- config/graphql/base.py | 81 ++++ config/graphql/custom_connections.py | 63 +++ config/graphql/graphene_types.py | 57 +-- config/settings/base.py | 1 + frontend/src/App.css | 8 + .../annotator/display/Containers.tsx | 2 +- .../src/components/annotator/display/Page.tsx | 9 +- .../annotator/display/Selection.tsx | 436 ++++-------------- .../annotator/display/SelectionBoundary.tsx | 27 +- .../annotator/display/SelectionTokenGroup.tsx | 68 +++ .../components/annotator/display/Tokens.tsx | 48 ++ .../widgets/buttons/RadialButtonCloud.tsx | 154 ++++--- opencontractserver/feedback/__init__.py | 0 opencontractserver/feedback/admin.py | 10 + opencontractserver/feedback/apps.py | 6 + .../feedback/migrations/0001_initial.py | 159 +++++++ .../feedback/migrations/__init__.py | 0 opencontractserver/feedback/models.py | 47 ++ opencontractserver/feedback/tests.py | 3 + opencontractserver/feedback/views.py | 3 + .../tests/test_custom_permission_filters.py | 229 +++++++++ requirements/base.txt | 4 +- 22 files changed, 927 insertions(+), 488 deletions(-) create mode 100644 frontend/src/components/annotator/display/SelectionTokenGroup.tsx create mode 100644 opencontractserver/feedback/__init__.py create mode 100644 opencontractserver/feedback/admin.py create mode 100644 opencontractserver/feedback/apps.py create mode 100644 opencontractserver/feedback/migrations/0001_initial.py create mode 100644 opencontractserver/feedback/migrations/__init__.py create mode 100644 opencontractserver/feedback/models.py create mode 100644 opencontractserver/feedback/tests.py create mode 100644 opencontractserver/feedback/views.py create mode 100644 opencontractserver/tests/test_custom_permission_filters.py diff --git a/config/graphql/base.py b/config/graphql/base.py index c4f233a5..97d26526 100644 --- a/config/graphql/base.py +++ b/config/graphql/base.py @@ -5,11 +5,15 @@ import django.db.models import graphene +from graphene import Int from graphene.relay import Node from graphene_django import DjangoObjectType from graphql_jwt.decorators import login_required from graphql_relay import from_global_id, to_global_id +from graphene_django import DjangoObjectType +from graphene_django.filter import DjangoFilterConnectionField +from config.graphql.custom_connections import CustomPermissionFilteredConnection, CustomDjangoFilterConnectionField from opencontractserver.shared.resolvers import resolve_single_oc_model_from_id from opencontractserver.types.enums import PermissionTypes from opencontractserver.utils.permissioning import ( @@ -67,6 +71,20 @@ def resolve_total_count(root, info): return len(root.iterable) # And no, root.iterable.count() did not work for me. +class CountableConnection(CustomPermissionFilteredConnection): + class Meta: + abstract = True + + total_count = Int() + edge_count = Int() + + def resolve_total_count(root, info, **kwargs): + return root.length + + def resolve_edge_count(root, info, **kwargs): + return len(root.edges) + + class DRFDeletion(graphene.Mutation): class IOSettings(ABC): lookup_field = "id" @@ -236,3 +254,66 @@ def mutate(cls, root, info, *args, **kwargs): message = f"Mutation failed due to error: {e}" return cls(ok=ok, message=message, obj_id=obj_id) + + +class CustomDjangoObjectType(DjangoObjectType): + class Meta: + abstract = True + + @classmethod + def __init_subclass_with_meta__( + cls, + model=None, + registry=None, + skip_registry=False, + only_fields=None, + fields=None, + exclude_fields=None, + exclude=None, + filter_fields=None, + filterset_class=None, + connection=None, + connection_class=None, + use_connection=None, + interfaces=(), + convert_choices_to_enum=None, + _meta=None, + **options + ): + if filter_fields is not None and filterset_class is None: + from graphene_django.filter.utils import get_filterset_class + filterset_class = get_filterset_class(model, filter_fields) + + super().__init_subclass_with_meta__( + model=model, + registry=registry, + skip_registry=skip_registry, + only_fields=only_fields, + fields=fields, + exclude_fields=exclude_fields, + exclude=exclude, + filter_fields=filter_fields, + filterset_class=filterset_class, + connection=connection, + connection_class=connection_class, + use_connection=use_connection, + interfaces=interfaces, + convert_choices_to_enum=convert_choices_to_enum, + _meta=_meta, + **options + ) + + # Replace any DjangoFilterConnectionField with CustomDjangoFilterConnectionField + if cls._meta.fields: + for name, field in cls._meta.fields.items(): + if isinstance(field, DjangoFilterConnectionField): + new_field = CustomDjangoFilterConnectionField( + type(field.node), + filters=field.filterset_class.Meta.fields if field.filterset_class else None, + filterset_class=field.filterset_class, + ) + cls._meta.fields[name] = new_field + + @classmethod + def get_node(cls, info, id): + return super().get_node(info, id) diff --git a/config/graphql/custom_connections.py b/config/graphql/custom_connections.py index e8f7f862..21da3b5c 100644 --- a/config/graphql/custom_connections.py +++ b/config/graphql/custom_connections.py @@ -1,6 +1,8 @@ import logging from graphene import Connection, Int +from graphene_django.filter import DjangoFilterConnectionField + logger = logging.getLogger(__name__) @@ -28,3 +30,64 @@ def resolve_page_count(root, info, **kwargs): # print(f"PdfPageAwareConnection - resolve_edge_count kwargs: {kwargs}") return largest_page_number + + +class CustomPermissionFilteredConnection(Connection): + class Meta: + abstract = True + + @classmethod + def connection_resolver(cls, resolver, connection, default_manager, max_limit, + enforce_first_or_last, filterset_class, filtering_args, + root, info, **args): + + # Get the model from the default_manager + model = default_manager.model + + # Check if the user has the required permission + user = info.context.user + if not user.has_perm(f'read_{model._meta.model_name}') and not model.is_public: + return super(CustomPermissionFilteredConnection, cls).connection_resolver( + lambda *args, **kwargs: default_manager.none(), + connection, + default_manager, + max_limit, + enforce_first_or_last, + filterset_class, + filtering_args, + root, + info, + **args + ) + + return super(CustomPermissionFilteredConnection, cls).connection_resolver( + resolver, + connection, + default_manager, + max_limit, + enforce_first_or_last, + filterset_class, + filtering_args, + root, + info, + **args + ) + + +class CustomDjangoFilterConnectionField(DjangoFilterConnectionField): + @classmethod + def connection_resolver(cls, resolver, connection, default_manager, max_limit, + enforce_first_or_last, filterset_class, filtering_args, + root, info, **args): + return CustomPermissionFilteredConnection.connection_resolver( + resolver, + connection, + default_manager, + max_limit, + enforce_first_or_last, + filterset_class, + filtering_args, + root, + info, + **args + ) diff --git a/config/graphql/graphene_types.py b/config/graphql/graphene_types.py index 2ca48563..1b357311 100644 --- a/config/graphql/graphene_types.py +++ b/config/graphql/graphene_types.py @@ -4,12 +4,10 @@ from django.contrib.auth import get_user_model from graphene import relay from graphene.types.generic import GenericScalar -from graphene_django import DjangoObjectType -from graphene_django import DjangoObjectType as ModelType from graphene_django.filter import DjangoFilterConnectionField from graphql_relay import from_global_id -from config.graphql.base import CountableConnection +from config.graphql.base import CountableConnection, CustomDjangoObjectType from config.graphql.filters import AnnotationFilter, LabelFilter from config.graphql.permission_annotator.mixins import AnnotatePermissionsForReadMixin from opencontractserver.analyzer.models import Analysis, Analyzer, GremlinEngine @@ -22,27 +20,28 @@ from opencontractserver.corpuses.models import Corpus, CorpusAction, CorpusQuery from opencontractserver.documents.models import Document, DocumentAnalysisRow from opencontractserver.extracts.models import Column, Datacell, Extract, Fieldset +from opencontractserver.feedback.models import UserFeedback from opencontractserver.users.models import Assignment, UserExport, UserImport User = get_user_model() logger = logging.getLogger(__name__) -class UserType(AnnotatePermissionsForReadMixin, ModelType): +class UserType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): class Meta: model = User interfaces = [relay.Node] connection_class = CountableConnection -class AssignmentType(AnnotatePermissionsForReadMixin, ModelType): +class AssignmentType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): class Meta: model = Assignment interfaces = [relay.Node] connection_class = CountableConnection -class RelationshipType(AnnotatePermissionsForReadMixin, ModelType): +class RelationshipType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): class Meta: model = Relationship interfaces = [relay.Node] @@ -67,7 +66,7 @@ class AnnotationInputType(AnnotatePermissionsForReadMixin, graphene.InputObjectT is_public = graphene.Boolean() -class AnnotationType(AnnotatePermissionsForReadMixin, ModelType): +class AnnotationType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): json = GenericScalar() all_source_node_in_relationship = graphene.List(lambda: RelationshipType) @@ -121,14 +120,14 @@ class PageAwareAnnotationType(graphene.ObjectType): page_annotations = graphene.List(AnnotationType) -class AnnotationLabelType(AnnotatePermissionsForReadMixin, ModelType): +class AnnotationLabelType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): class Meta: model = AnnotationLabel interfaces = [relay.Node] connection_class = CountableConnection -class LabelSetType(AnnotatePermissionsForReadMixin, ModelType): +class LabelSetType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): annotation_labels = DjangoFilterConnectionField( AnnotationLabelType, filterset_class=LabelFilter ) @@ -149,7 +148,7 @@ class Meta: connection_class = CountableConnection -class DocumentType(AnnotatePermissionsForReadMixin, ModelType): +class DocumentType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): def resolve_pdf_file(self, info): return ( "" @@ -245,7 +244,7 @@ class Meta: connection_class = CountableConnection -class CorpusType(AnnotatePermissionsForReadMixin, ModelType): +class CorpusType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): all_annotation_summaries = graphene.List( AnnotationType, analysis_id=graphene.ID(), @@ -295,7 +294,7 @@ class Meta: connection_class = CountableConnection -class CorpusActionType(AnnotatePermissionsForReadMixin, ModelType): +class CorpusActionType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): class Meta: model = CorpusAction interfaces = [relay.Node] @@ -311,7 +310,7 @@ class Meta: } -class UserImportType(AnnotatePermissionsForReadMixin, ModelType): +class UserImportType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): def resolve_zip(self, info): return "" if not self.file else info.context.build_absolute_uri(self.zip.url) @@ -321,7 +320,7 @@ class Meta: connection_class = CountableConnection -class UserExportType(AnnotatePermissionsForReadMixin, ModelType): +class UserExportType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): def resolve_file(self, info): return "" if not self.file else info.context.build_absolute_uri(self.file.url) @@ -331,7 +330,7 @@ class Meta: connection_class = CountableConnection -class AnalyzerType(AnnotatePermissionsForReadMixin, ModelType): +class AnalyzerType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): analyzer_id = graphene.String() def resolve_analyzer_id(self, info): @@ -353,7 +352,7 @@ class Meta: connection_class = CountableConnection -class GremlinEngineType_READ(AnnotatePermissionsForReadMixin, ModelType): +class GremlinEngineType_READ(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): class Meta: model = GremlinEngine exclude = ("api_key",) @@ -361,15 +360,14 @@ class Meta: connection_class = CountableConnection -class GremlinEngineType_WRITE(AnnotatePermissionsForReadMixin, ModelType): +class GremlinEngineType_WRITE(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): class Meta: model = GremlinEngine interfaces = [relay.Node] connection_class = CountableConnection -class AnalysisType(AnnotatePermissionsForReadMixin, ModelType): - +class AnalysisType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): full_annotation_list = graphene.List(AnnotationType) def resolve_full_annotation_list(self, info): @@ -381,14 +379,14 @@ class Meta: connection_class = CountableConnection -class ColumnType(AnnotatePermissionsForReadMixin, DjangoObjectType): +class ColumnType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): class Meta: model = Column interfaces = [relay.Node] connection_class = CountableConnection -class FieldsetType(AnnotatePermissionsForReadMixin, DjangoObjectType): +class FieldsetType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): full_column_list = graphene.List(ColumnType) class Meta: @@ -400,8 +398,7 @@ def resolve_full_column_list(self, info): return self.columns.all() -class DatacellType(AnnotatePermissionsForReadMixin, DjangoObjectType): - +class DatacellType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): data = GenericScalar() full_source_list = graphene.List(AnnotationType) @@ -414,7 +411,7 @@ class Meta: connection_class = CountableConnection -class ExtractType(AnnotatePermissionsForReadMixin, DjangoObjectType): +class ExtractType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): full_datacell_list = graphene.List(DatacellType) full_document_list = graphene.List(DocumentType) @@ -430,8 +427,7 @@ def resolve_full_document_list(self, info): return self.documents.all() -class CorpusQueryType(AnnotatePermissionsForReadMixin, DjangoObjectType): - +class CorpusQueryType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): full_source_list = graphene.List(AnnotationType) def resolve_full_source_list(self, info): @@ -443,7 +439,7 @@ class Meta: connection_class = CountableConnection -class DocumentAnalysisRowType(AnnotatePermissionsForReadMixin, DjangoObjectType): +class DocumentAnalysisRowType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): class Meta: model = DocumentAnalysisRow interfaces = [relay.Node] @@ -454,3 +450,10 @@ class DocumentCorpusActionsType(graphene.ObjectType): corpus_actions = graphene.List(CorpusActionType) extracts = graphene.List(ExtractType) analysis_rows = graphene.List(DocumentAnalysisRowType) + + +class UserFeedbackType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): + class Meta: + model = UserFeedback + interfaces = [relay.Node] + connection_class = CountableConnection diff --git a/config/settings/base.py b/config/settings/base.py index c107245d..1fa786a7 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -106,6 +106,7 @@ "opencontractserver.annotations", "opencontractserver.analyzer", "opencontractserver.extracts", + "opencontractserver.feedback" ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps diff --git a/frontend/src/App.css b/frontend/src/App.css index d9f54950..8515b17d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -54,3 +54,11 @@ div > .SettingsPopup { display: none !important; } } + +#ConfirmModal > div.ui.page.modals.dimmer.transition.visible.active { + z-index: 20000 !important; +} + +#ConfirmModal { + z-index: 20001 !important; +} diff --git a/frontend/src/components/annotator/display/Containers.tsx b/frontend/src/components/annotator/display/Containers.tsx index a298fa8e..a78101ac 100644 --- a/frontend/src/components/annotator/display/Containers.tsx +++ b/frontend/src/components/annotator/display/Containers.tsx @@ -52,7 +52,7 @@ interface SelectionInfoProps { export const SelectionInfo = styled.div` position: absolute; width: ${(props) => props.bounds.right - props.bounds.left}px; - right: -${(props) => props.border + (props?.approved || props?.rejected ? 1 : 0)}px; + right: -${(props) => props.border + ((props?.approved ? 1 : props?.rejected) ? -1 : 0)}px; bottom: calc(100% - 2px); border-radius: 4px 4px 0 0; background: ${(props) => diff --git a/frontend/src/components/annotator/display/Page.tsx b/frontend/src/components/annotator/display/Page.tsx index 0469cf76..81a43d45 100644 --- a/frontend/src/components/annotator/display/Page.tsx +++ b/frontend/src/components/annotator/display/Page.tsx @@ -11,9 +11,10 @@ import { getPageBoundsFromCanvas } from "../../../utils/transform"; import { PageProps, BoundingBox, PermissionTypes } from "../../types"; import { AnnotationStore, normalizeBounds, PDFStore } from "../context"; import { PDFPageRenderer, PageAnnotationsContainer, PageCanvas } from "./PDF"; -import { Selection, SelectionTokens } from "./Selection"; +import { Selection } from "./Selection"; import { SearchResult } from "./SearchResult"; import { SelectionBoundary } from "./SelectionBoundary"; +import { SelectionTokenGroup } from "./SelectionTokenGroup"; export const Page = ({ pageInfo, @@ -80,7 +81,7 @@ export const Page = ({ bounds={selection} selected={false} /> - + ); }, @@ -284,8 +285,8 @@ export const Page = ({ pageInfo={pageInfo} annotation={annotation} setJumpedToAnnotationOnLoad={setJumpedToAnnotationOnLoad} - // approved - rejected + approved + // rejected /> )); }, [ diff --git a/frontend/src/components/annotator/display/Selection.tsx b/frontend/src/components/annotator/display/Selection.tsx index 6c0fa8d7..48478e40 100644 --- a/frontend/src/components/annotator/display/Selection.tsx +++ b/frontend/src/components/annotator/display/Selection.tsx @@ -1,41 +1,20 @@ -import React, { - MouseEvent, - useContext, - useState, - useEffect, - SyntheticEvent, - useRef, -} from "react"; -import styled, { css, keyframes } from "styled-components"; +import React, { useContext, useState, useEffect, useRef } from "react"; import _ from "lodash"; -import uniqueId from "lodash/uniqueId"; -import { - Modal, - Dropdown, - DropdownItemProps, - DropdownProps, - Image, - Button, - Icon, - SemanticICONS, - ButtonProps, -} from "semantic-ui-react"; +import { Image, Icon } from "semantic-ui-react"; + +import { PDFPageInfo, AnnotationStore, ServerAnnotation } from "../context"; -import { - TokenId, - PDFPageInfo, - AnnotationStore, - ServerAnnotation, -} from "../context"; import { HorizontallyJustifiedStartDiv, VerticallyJustifiedEndDiv, } from "../sidebar/common"; + import { annotationSelectedViaRelationship, getRelationImageHref, } from "../utils"; + import { PermissionTypes } from "../../types"; import { LabelDisplayBehavior } from "../../../graphql/types"; import { SelectionBoundary } from "./SelectionBoundary"; @@ -45,290 +24,11 @@ import { SelectionInfoContainer, } from "./Containers"; import { getBorderWidthFromBounds } from "../../../utils/transform"; -import RadialButtonCloud from "../../widgets/buttons/RadialButtonCloud"; - -interface TokenSpanProps { - id?: string; - hidden?: boolean; - color?: string; - isSelected?: boolean; - highOpacity?: boolean; - left: number; - right: number; - top: number; - bottom: number; - pointerEvents: string; - theme?: any; -} - -const CloudContainer = styled.div` - position: absolute; - top: -60px; /* Adjust as needed */ - left: -60px; /* Adjust as needed */ - width: 120px; - height: 120px; - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: center; - pointer-events: auto; - opacity: 0; - animation: fadeIn 0.5s forwards; - - @keyframes fadeIn { - to { - opacity: 1; - } - } -`; - -interface CloudButtonProps extends ButtonProps { - delay: number; - xOffset: number; - yOffset: number; -} - -interface CloudButtonItem { - name: SemanticICONS; // Semantic UI icon names - color: string; - tooltip: string; - onClick: () => void; -} - -const CloudButton = styled(Button)` - position: absolute; - opacity: 0; - animation: moveOut 0.5s forwards; - animation-delay: ${(props) => props.delay}s; - transform: translate(0, 0); - - @keyframes moveOut { - to { - opacity: 1; - transform: translate( - ${(props) => props.xOffset}px, - ${(props) => props.yOffset}px - ); - } - } -`; - -const TokenSpan = styled.span.attrs( - ({ - id, - theme, - top, - bottom, - left, - right, - pointerEvents, - hidden, - color, - isSelected, - highOpacity, - }: TokenSpanProps) => ({ - id, - style: { - background: isSelected - ? color - ? color.toUpperCase() - : theme.color.B3 - : "none", - opacity: hidden ? 0.0 : highOpacity ? 0.4 : 0.2, - left: `${left}px`, - top: `${top}px`, - width: `${right - left}px`, - height: `${bottom - top}px`, - pointerEvents: pointerEvents, - }, - }) -)` - position: absolute; - border-radius: 3px; -`; - -interface SelectionTokenProps { - id?: string; - color?: string; - className?: string; - hidden?: boolean; - pageInfo: PDFPageInfo; - highOpacity?: boolean; - tokens: TokenId[] | null; - scrollTo?: boolean; -} -export const SelectionTokens = ({ - id, - color, - className, - hidden, - pageInfo, - highOpacity, - tokens, - scrollTo, -}: SelectionTokenProps) => { - const containerRef = useRef(null); - - useEffect(() => { - if (scrollTo) { - if (containerRef.current !== undefined && containerRef.current !== null) { - console.log("Scroll to", scrollTo); - containerRef.current.scrollIntoView(); - } - } - }, [scrollTo]); - - return ( -
- {tokens ? ( - tokens.map((t, i) => { - const b = pageInfo.getScaledTokenBounds( - pageInfo.tokens[t.tokenIndex] - ); - return ( -
- ); -}; - -interface EditLabelModalProps { - annotation: ServerAnnotation; - visible: boolean; - onHide: () => void; -} - -const EditLabelModal = ({ - annotation, - visible, - onHide, -}: EditLabelModalProps) => { - const annotationStore = useContext(AnnotationStore); - - const [selectedLabel, setSelectedLabel] = useState( - annotation.annotationLabel - ); - - // There are onMouseDown listeners on the that handle the - // creation of new annotations. We use this function to prevent that - // from being triggered when the user engages with other UI elements. - const onMouseDown = (e: MouseEvent) => { - e.stopPropagation(); - }; - - useEffect(() => { - const onKeyPress = (e: KeyboardEvent) => { - // Numeric keys 1-9 - e.preventDefault(); - e.stopPropagation(); - if (e.keyCode >= 49 && e.keyCode <= 57) { - const index = Number.parseInt(e.key) - 1; - if (index < annotationStore.spanLabels.length) { - annotationStore.updateAnnotation( - new ServerAnnotation( - annotation.page, - annotationStore.spanLabels[index], - annotation.rawText, - annotation.structural, - annotation.json, - annotation.myPermissions, - annotation.id - ) - ); - onHide(); - } - } - }; - window.addEventListener("keydown", onKeyPress); - return () => { - window.removeEventListener("keydown", onKeyPress); - }; - }, [annotationStore, annotation]); - - const dropdownOptions: DropdownItemProps[] = annotationStore.spanLabels.map( - (label, index) => ({ - key: label.id, - text: label.text, - value: label.id, - }) - ); - - const handleDropdownChange = ( - event: SyntheticEvent, - data: DropdownProps - ) => { - event.stopPropagation(); - event.preventDefault(); - const label = annotationStore.spanLabels.find((l) => l.id === data.value); - if (!label) { - return; - } - setSelectedLabel(label); - }; - - return ( - - - - - - - - - - ); -}; +import RadialButtonCloud, { + CloudButtonItem, +} from "../../widgets/buttons/RadialButtonCloud"; +import { SelectionTokenGroup } from "./SelectionTokenGroup"; +import { EditLabelModal } from "../../widgets/modals/EditLabelModal"; interface SelectionProps { selectionRef: @@ -344,6 +44,7 @@ interface SelectionProps { children?: React.ReactNode; approved?: boolean; rejected?: boolean; + actions?: CloudButtonItem[]; setJumpedToAnnotationOnLoad: (annot: string) => null | void; } @@ -370,33 +71,85 @@ export const Selection: React.FC = ({ const label = annotation.annotationLabel; const color = label?.color || "#616a6b"; // grey as the default - const bounds = pageInfo.getScaledBounds( - annotation.json[pageInfo.page.pageNumber - 1].bounds - ); - const border = getBorderWidthFromBounds(bounds); - - const removeAnnotation = () => { - annotationStore.deleteAnnotation(annotation.id); - }; - - const buttonList: CloudButtonItem[] = [ + const actions: CloudButtonItem[] = [ + { + name: "pencil", + color: "blue", + tooltip: "Edit Annotation", + onClick: () => { + console.log("Edit clicked"); + }, + protected_message: "Confirm shit", + }, + { + name: "trash alternate outline", + color: "red", + tooltip: "Delete Annotation", + onClick: () => { + console.log("Delete clicked"); + }, + protected_message: "Are you sure you want to delete this annotation?", + }, + { + name: "pencil", + color: "blue", + tooltip: "Edit Annotation", + onClick: () => { + console.log("Edit clicked"); + }, + }, + { + name: "trash alternate outline", + color: "red", + tooltip: "Delete Annotation", + onClick: () => { + console.log("Delete clicked"); + }, + }, + { + name: "pencil", + color: "blue", + tooltip: "Edit Annotation", + onClick: () => { + console.log("Edit clicked"); + }, + }, + { + name: "trash alternate outline", + color: "red", + tooltip: "Delete Annotation", + onClick: () => { + console.log("Delete clicked"); + }, + }, { name: "pencil", color: "blue", tooltip: "Edit Annotation", onClick: () => { - setIsEditLabelModalVisible(true); + console.log("Edit clicked"); }, }, { name: "trash alternate outline", color: "red", tooltip: "Delete Annotation", - onClick: removeAnnotation, + onClick: () => { + console.log("Delete clicked"); + }, }, // Add more buttons as needed ]; + const bounds = pageInfo.getScaledBounds( + annotation.json[pageInfo.page.pageNumber - 1].bounds + ); + const border = getBorderWidthFromBounds(bounds); + + const removeAnnotation = () => { + annotationStore.deleteAnnotation(annotation.id); + }; + const onShiftClick = () => { const current = annotationStore.selectedAnnotations.slice(0); if (current.some((other) => other === annotation.id)) { @@ -509,37 +262,10 @@ export const Selection: React.FC = ({ alignItems: "center", }} > - - {cloudVisible && ( - - {buttonList.map((btn, index) => ( - ) => { - e.stopPropagation(); - btn.onClick(); - setCloudVisible(false); - }} - title={btn.tooltip} - delay={index * 0.1} - xOffset={ - Math.cos( - (index / buttonList.length) * 2 * Math.PI - ) * 50 - } - yOffset={ - Math.sin( - (index / buttonList.length) * 2 * Math.PI - ) * 50 - } - > - - - ))} - - )} +
= ({ // positioned element. This is why SelectionTokens are not inside // SelectionBoundary. annotation.json[pageInfo.page.pageNumber - 1].tokensJsons && ( - { + const containerRef = useRef(null); + + useEffect(() => { + if (scrollTo) { + if (containerRef.current !== undefined && containerRef.current !== null) { + console.log("Scroll to", scrollTo); + containerRef.current.scrollIntoView(); + } + } + }, [scrollTo]); + + return ( +
+ {tokens ? ( + tokens.map((t, i) => { + const b = pageInfo.getScaledTokenBounds( + pageInfo.tokens[t.tokenIndex] + ); + return ( +
+ ); +}; diff --git a/frontend/src/components/annotator/display/Tokens.tsx b/frontend/src/components/annotator/display/Tokens.tsx index a0457a92..55790306 100644 --- a/frontend/src/components/annotator/display/Tokens.tsx +++ b/frontend/src/components/annotator/display/Tokens.tsx @@ -18,3 +18,51 @@ export const TokenSpan = styled.span( border-radius: 3px; ` ); + +interface SelectionTokenSpanProps { + id?: string; + hidden?: boolean; + color?: string; + isSelected?: boolean; + highOpacity?: boolean; + left: number; + right: number; + top: number; + bottom: number; + pointerEvents: string; + theme?: any; +} + +export const SelectionTokenSpan = styled.span.attrs( + ({ + id, + theme, + top, + bottom, + left, + right, + pointerEvents, + hidden, + color, + isSelected, + highOpacity, + }: SelectionTokenSpanProps) => ({ + id, + style: { + background: isSelected + ? color + ? color.toUpperCase() + : theme.color.B3 + : "none", + opacity: hidden ? 0.0 : highOpacity ? 0.4 : 0.2, + left: `${left}px`, + top: `${top}px`, + width: `${right - left}px`, + height: `${bottom - top}px`, + pointerEvents: pointerEvents, + }, + }) +)` + position: absolute; + border-radius: 3px; +`; diff --git a/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx b/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx index 1d95fc2a..b57146ad 100644 --- a/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx +++ b/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx @@ -1,7 +1,13 @@ import React, { useState, useRef, useEffect } from "react"; -import styled, { keyframes, css } from "styled-components"; -import { Button, Icon, SemanticICONS, ButtonProps } from "semantic-ui-react"; -import { getLuminance, getContrast } from "polished"; +import styled, { createGlobalStyle, keyframes } from "styled-components"; +import { + Button, + Icon, + SemanticICONS, + ButtonProps, + Modal, +} from "semantic-ui-react"; +import { getLuminance } from "polished"; import useWindowDimensions from "../../hooks/WindowDimensionHook"; import { MOBILE_VIEW_BREAKPOINT } from "../../../assets/configurations/constants"; @@ -102,92 +108,43 @@ const CloudButton = styled(Button)` animation-delay: ${(props) => props.delay}s; `; -interface CloudButtonItem { +const GlobalStyle = createGlobalStyle` + .confirm-modal-container.ui.page.modals.dimmer.transition.visible.active { + z-index: 20000 !important; + } + + #ConfirmModal { + z-index: 20001 !important; + } +`; + +export interface CloudButtonItem { name: SemanticICONS; color: string; tooltip: string; + protected_message?: string | null; onClick: () => void; } interface RadialButtonCloudProps { parentBackgroundColor: string; + actions: CloudButtonItem[]; } const RadialButtonCloud: React.FC = ({ parentBackgroundColor, + actions: buttonList, }) => { const [cloudVisible, setCloudVisible] = useState(false); + const [confirmModal, setConfirmModal] = useState<{ + open: boolean; + message: string; + onConfirm: () => void; + }>({ open: false, message: "", onConfirm: () => {} }); + const { height, width } = useWindowDimensions(); const cloudRef = useRef(null); - const buttonList: CloudButtonItem[] = [ - { - name: "pencil", - color: "blue", - tooltip: "Edit Annotation", - onClick: () => { - console.log("Edit clicked"); - }, - }, - { - name: "trash alternate outline", - color: "red", - tooltip: "Delete Annotation", - onClick: () => { - console.log("Delete clicked"); - }, - }, - { - name: "pencil", - color: "blue", - tooltip: "Edit Annotation", - onClick: () => { - console.log("Edit clicked"); - }, - }, - { - name: "trash alternate outline", - color: "red", - tooltip: "Delete Annotation", - onClick: () => { - console.log("Delete clicked"); - }, - }, - { - name: "pencil", - color: "blue", - tooltip: "Edit Annotation", - onClick: () => { - console.log("Edit clicked"); - }, - }, - { - name: "trash alternate outline", - color: "red", - tooltip: "Delete Annotation", - onClick: () => { - console.log("Delete clicked"); - }, - }, - { - name: "pencil", - color: "blue", - tooltip: "Edit Annotation", - onClick: () => { - console.log("Edit clicked"); - }, - }, - { - name: "trash alternate outline", - color: "red", - tooltip: "Delete Annotation", - onClick: () => { - console.log("Delete clicked"); - }, - }, - // Add more buttons as needed - ]; - const handleClickOutside = (event: MouseEvent) => { if ( cloudRef.current && @@ -198,6 +155,24 @@ const RadialButtonCloud: React.FC = ({ } }; + const handleButtonClick = (btn: CloudButtonItem) => { + console.log("handleButtonClick", btn); + if (btn.protected_message) { + console.log("Should show confirm!"); + setConfirmModal({ + open: true, + message: btn.protected_message, + onConfirm: () => { + btn.onClick(); + setCloudVisible(false); + }, + }); + } else { + btn.onClick(); + setCloudVisible(false); + } + }; + useEffect(() => { if (cloudVisible) { document.addEventListener("mousedown", handleClickOutside); @@ -238,6 +213,7 @@ const RadialButtonCloud: React.FC = ({ onMouseEnter={() => setCloudVisible(true)} backgroundColor={dotColor} /> + {cloudVisible && ( {buttonList.map((btn, index) => ( @@ -249,8 +225,7 @@ const RadialButtonCloud: React.FC = ({ size="mini" onClick={(e: React.MouseEvent) => { e.stopPropagation(); - btn.onClick(); - setCloudVisible(false); + handleButtonClick(btn); }} title={btn.tooltip} delay={index * 0.1} @@ -261,6 +236,37 @@ const RadialButtonCloud: React.FC = ({ ))} )} + setConfirmModal({ ...confirmModal, open: false })} + > + +

{confirmModal.message}

+
+ + + + +
); }; diff --git a/opencontractserver/feedback/__init__.py b/opencontractserver/feedback/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/opencontractserver/feedback/admin.py b/opencontractserver/feedback/admin.py new file mode 100644 index 00000000..1c2a4b41 --- /dev/null +++ b/opencontractserver/feedback/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin +from guardian.admin import GuardedModelAdmin + +from opencontractserver.feedback.models import UserFeedback + + +@admin.register(UserFeedback) +class AnnotationAdmin(GuardedModelAdmin): + list_display = ["id", "approved", "rejected", "comment", "creator"] + list_filter = ("approved", "rejected") diff --git a/opencontractserver/feedback/apps.py b/opencontractserver/feedback/apps.py new file mode 100644 index 00000000..fafa406e --- /dev/null +++ b/opencontractserver/feedback/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FeedbackConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "opencontractserver.feedback" diff --git a/opencontractserver/feedback/migrations/0001_initial.py b/opencontractserver/feedback/migrations/0001_initial.py new file mode 100644 index 00000000..500222dd --- /dev/null +++ b/opencontractserver/feedback/migrations/0001_initial.py @@ -0,0 +1,159 @@ +# Generated by Django 4.2.15 on 2024-09-14 07:37 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import opencontractserver.shared.defaults +import opencontractserver.shared.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ( + "annotations", + "0017_remove_annotationlabel_only_install_one_label_of_given_name_for_each_analyzer_id_no_duplicates__and_", + ), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="UserFeedback", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("backend_lock", models.BooleanField(default=False)), + ("is_public", models.BooleanField(default=False)), + ("created", models.DateTimeField(auto_now_add=True)), + ("modified", models.DateTimeField(auto_now=True)), + ("approved", models.BooleanField(default=False)), + ("rejected", models.BooleanField(default=False)), + ("comment", models.TextField(blank=True, default="")), + ("markdown", models.TextField(blank=True, default="")), + ( + "metadata", + opencontractserver.shared.fields.NullableJSONField( + blank=True, + default=opencontractserver.shared.defaults.jsonfield_default_value, + null=True, + ), + ), + ( + "commented_annotation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="user_feedback", + to="annotations.annotation", + ), + ), + ( + "creator", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user_lock", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="locked_%(class)s_objects", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="UserFeedbackObjectPermission", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "content_object", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="feedback.userfeedback", + ), + ), + ( + "permission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="auth.permission", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + "unique_together": {("user", "permission", "content_object")}, + }, + ), + migrations.CreateModel( + name="UserFeedbackGroupObjectPermission", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "content_object", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="feedback.userfeedback", + ), + ), + ( + "group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="auth.group" + ), + ), + ( + "permission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="auth.permission", + ), + ), + ], + options={ + "abstract": False, + "unique_together": {("group", "permission", "content_object")}, + }, + ), + ] diff --git a/opencontractserver/feedback/migrations/__init__.py b/opencontractserver/feedback/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/opencontractserver/feedback/models.py b/opencontractserver/feedback/models.py new file mode 100644 index 00000000..6089cd8d --- /dev/null +++ b/opencontractserver/feedback/models.py @@ -0,0 +1,47 @@ +from django.db import models +from guardian.models import UserObjectPermissionBase, GroupObjectPermissionBase + +from opencontractserver.annotations.models import Annotation +from opencontractserver.shared.Models import BaseOCModel +from opencontractserver.shared.defaults import jsonfield_default_value +from opencontractserver.shared.fields import NullableJSONField + + +class UserFeedback(BaseOCModel): + approved=models.BooleanField(default=False) + rejected=models.BooleanField(default=False) + comment=models.TextField(blank=True, default="", null=False) + markdown=models.TextField(blank=True, default="", null=False) + metadata=NullableJSONField(default=jsonfield_default_value, null=True, blank=True) + commented_annotation=models.ForeignKey( + Annotation, + on_delete=models.CASCADE, + blank=False, + null=False, + related_name="user_feedback" + ) + + class Meta: + permissions = ( + ("permission_userfeedback", "permission UserFeedback"), + ("publish_userfeedback", "publish UserFeedback"), + ("create_userfeedback", "create UserFeedback"), + ("read_userfeedback", "read UserFeedback"), + ("update_userfeedback", "update UserFeedback"), + ("remove_userfeedback", "delete UserFeedback"), + ) + +# Model for Django Guardian permissions... trying to improve performance... +class UserFeedbackObjectPermission(UserObjectPermissionBase): + content_object = models.ForeignKey( + "UserFeedback", on_delete=models.CASCADE + ) + # enabled = False + + +# Model for Django Guardian permissions... trying to improve performance... +class UserFeedbackGroupObjectPermission(GroupObjectPermissionBase): + content_object = models.ForeignKey( + "UserFeedback", on_delete=models.CASCADE + ) + # enabled = False diff --git a/opencontractserver/feedback/tests.py b/opencontractserver/feedback/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/opencontractserver/feedback/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/opencontractserver/feedback/views.py b/opencontractserver/feedback/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/opencontractserver/feedback/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/opencontractserver/tests/test_custom_permission_filters.py b/opencontractserver/tests/test_custom_permission_filters.py new file mode 100644 index 00000000..eac26bc7 --- /dev/null +++ b/opencontractserver/tests/test_custom_permission_filters.py @@ -0,0 +1,229 @@ +import logging +from django.contrib.auth import get_user_model +from django.test import TestCase +from graphene.test import Client + +from config.graphql.schema import schema +from opencontractserver.corpuses.models import Corpus +from opencontractserver.documents.models import Document +from opencontractserver.annotations.models import Annotation, AnnotationLabel +from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user +from opencontractserver.types.enums import PermissionTypes + +User = get_user_model() +logger = logging.getLogger(__name__) + + +class TestContext: + def __init__(self, user): + self.user = user + + +class PermissionFilteringTestCase(TestCase): + def setUp(self): + # Create users + self.user1 = User.objects.create_user(username="user1", password="password1") + self.user2 = User.objects.create_user(username="user2", password="password2") + + # Create GraphQL clients + self.client1 = Client(schema, context_value=TestContext(self.user1)) + self.client2 = Client(schema, context_value=TestContext(self.user2)) + + # Create test data + self.corpus1 = Corpus.objects.create(title="Corpus 1", creator=self.user1) + self.corpus2 = Corpus.objects.create(title="Corpus 2", creator=self.user2) + + self.document1 = Document.objects.create(title="Document 1", creator=self.user1) + self.document2 = Document.objects.create(title="Document 2", creator=self.user2) + + self.label1 = AnnotationLabel.objects.create(text="Label 1", creator=self.user1) + self.label2 = AnnotationLabel.objects.create(text="Label 2", creator=self.user2) + + self.annotation1 = Annotation.objects.create( + document=self.document1, + annotation_label=self.label1, + creator=self.user1 + ) + self.annotation2 = Annotation.objects.create( + document=self.document2, + annotation_label=self.label2, + creator=self.user2 + ) + + # Set permissions + set_permissions_for_obj_to_user(self.user1, self.corpus1, [PermissionTypes.READ]) + set_permissions_for_obj_to_user(self.user1, self.document1, [PermissionTypes.READ]) + set_permissions_for_obj_to_user(self.user1, self.label1, [PermissionTypes.READ]) + set_permissions_for_obj_to_user(self.user1, self.annotation1, [PermissionTypes.READ]) + + set_permissions_for_obj_to_user(self.user2, self.corpus2, [PermissionTypes.READ]) + set_permissions_for_obj_to_user(self.user2, self.document2, [PermissionTypes.READ]) + set_permissions_for_obj_to_user(self.user2, self.label2, [PermissionTypes.READ]) + set_permissions_for_obj_to_user(self.user2, self.annotation2, [PermissionTypes.READ]) + + def test_corpus_permission_filtering(self): + query = """ + query { + corpuses { + edges { + node { + id + title + } + } + } + } + """ + + # Test for user1 + result1 = self.client1.execute(query) + self.assertEqual(len(result1['data']['corpuses']['edges']), 1) + self.assertEqual(result1['data']['corpuses']['edges'][0]['node']['title'], 'Corpus 1') + + # Test for user2 + result2 = self.client2.execute(query) + self.assertEqual(len(result2['data']['corpuses']['edges']), 1) + self.assertEqual(result2['data']['corpuses']['edges'][0]['node']['title'], 'Corpus 2') + + def test_document_permission_filtering(self): + query = """ + query { + documents { + edges { + node { + id + title + } + } + } + } + """ + + # Test for user1 + result1 = self.client1.execute(query) + self.assertEqual(len(result1['data']['documents']['edges']), 1) + self.assertEqual(result1['data']['documents']['edges'][0]['node']['title'], 'Document 1') + + # Test for user2 + result2 = self.client2.execute(query) + self.assertEqual(len(result2['data']['documents']['edges']), 1) + self.assertEqual(result2['data']['documents']['edges'][0]['node']['title'], 'Document 2') + + def test_annotation_label_permission_filtering(self): + query = """ + query { + annotationLabels { + edges { + node { + id + text + } + } + } + } + """ + + # Test for user1 + result1 = self.client1.execute(query) + self.assertEqual(len(result1['data']['annotationLabels']['edges']), 1) + self.assertEqual(result1['data']['annotationLabels']['edges'][0]['node']['text'], 'Label 1') + + # Test for user2 + result2 = self.client2.execute(query) + self.assertEqual(len(result2['data']['annotationLabels']['edges']), 1) + self.assertEqual(result2['data']['annotationLabels']['edges'][0]['node']['text'], 'Label 2') + + def test_annotation_permission_filtering(self): + query = """ + query { + annotations { + edges { + node { + id + document { + title + } + } + } + } + } + """ + + # Test for user1 + result1 = self.client1.execute(query) + self.assertEqual(len(result1['data']['annotations']['edges']), 1) + self.assertEqual(result1['data']['annotations']['edges'][0]['node']['document']['title'], 'Document 1') + + # Test for user2 + result2 = self.client2.execute(query) + self.assertEqual(len(result2['data']['annotations']['edges']), 1) + self.assertEqual(result2['data']['annotations']['edges'][0]['node']['document']['title'], 'Document 2') + + def test_nested_permission_filtering(self): + query = """ + query { + corpuses { + edges { + node { + id + title + documents { + edges { + node { + id + title + } + } + } + } + } + } + } + """ + + # Add documents to corpuses + self.corpus1.documents.add(self.document1) + self.corpus2.documents.add(self.document2) + + # Test for user1 + result1 = self.client1.execute(query) + self.assertEqual(len(result1['data']['corpuses']['edges']), 1) + self.assertEqual(len(result1['data']['corpuses']['edges'][0]['node']['documents']['edges']), 1) + self.assertEqual(result1['data']['corpuses']['edges'][0]['node']['documents']['edges'][0]['node']['title'], + 'Document 1') + + # Test for user2 + result2 = self.client2.execute(query) + self.assertEqual(len(result2['data']['corpuses']['edges']), 1) + self.assertEqual(len(result2['data']['corpuses']['edges'][0]['node']['documents']['edges']), 1) + self.assertEqual(result2['data']['corpuses']['edges'][0]['node']['documents']['edges'][0]['node']['title'], + 'Document 2') + + def test_permission_change(self): + query = """ + query { + corpuses { + edges { + node { + id + title + } + } + } + } + """ + + # Initial test for user2 + result1 = self.client2.execute(query) + self.assertEqual(len(result1['data']['corpuses']['edges']), 1) + self.assertEqual(result1['data']['corpuses']['edges'][0]['node']['title'], 'Corpus 2') + + # Grant permission to user2 for corpus1 + set_permissions_for_obj_to_user(self.user2, self.corpus1, [PermissionTypes.READ]) + + # Test again for user2 + result2 = self.client2.execute(query) + self.assertEqual(len(result2['data']['corpuses']['edges']), 2) + titles = [edge['node']['title'] for edge in result2['data']['corpuses']['edges']] + self.assertIn('Corpus 1', titles) + self.assertIn('Corpus 2', titles) diff --git a/requirements/base.txt b/requirements/base.txt index c97d6c1d..cfd43a48 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -3,7 +3,7 @@ python-slugify==8.0.4 # https://github.com/un33k/python-slugify Pillow==9.4.0 # https://github.com/python-pillow/Pillow argon2-cffi==21.3.0 # https://github.com/hynek/argon2_cffi redis==4.5.1 # https://github.com/redis/redis-py -hiredis==2.0.0 # https://github.com/redis/hiredis-py +hiredis==3.0.0 # https://github.com/redis/hiredis-py celery==5.4.0 # pyup: < 6.0 # https://github.com/celery/celery flower==2.0.1 # https://github.com/mher/flower django-celery-beat==2.6.0 # https://github.com/celery/django-celery-beat @@ -75,5 +75,5 @@ django-guardian # GraphQL # ------------------------------------------------------------------------------ -graphene-django==3.0.0 +graphene-django==3.2.2 django-graphql-jwt From 4df27e28f378c362770ad3f9be7571620ca65e2a Mon Sep 17 00:00:00 2001 From: JSv4 Date: Sat, 14 Sep 2024 01:09:19 -0700 Subject: [PATCH 06/18] Code cleanup. --- config/graphql/base.py | 27 ++-- config/graphql/custom_connections.py | 45 ++++-- config/settings/base.py | 2 +- opencontractserver/feedback/models.py | 27 ++-- opencontractserver/feedback/tests.py | 4 +- opencontractserver/feedback/views.py | 4 +- .../tests/test_custom_permission_filters.py | 137 ++++++++++++------ 7 files changed, 150 insertions(+), 96 deletions(-) diff --git a/config/graphql/base.py b/config/graphql/base.py index 97d26526..fd0a5f90 100644 --- a/config/graphql/base.py +++ b/config/graphql/base.py @@ -8,12 +8,14 @@ from graphene import Int from graphene.relay import Node from graphene_django import DjangoObjectType +from graphene_django.filter import DjangoFilterConnectionField from graphql_jwt.decorators import login_required from graphql_relay import from_global_id, to_global_id -from graphene_django import DjangoObjectType -from graphene_django.filter import DjangoFilterConnectionField -from config.graphql.custom_connections import CustomPermissionFilteredConnection, CustomDjangoFilterConnectionField +from config.graphql.custom_connections import ( + CustomDjangoFilterConnectionField, + CustomPermissionFilteredConnection, +) from opencontractserver.shared.resolvers import resolve_single_oc_model_from_id from opencontractserver.types.enums import PermissionTypes from opencontractserver.utils.permissioning import ( @@ -61,16 +63,6 @@ def get_node_from_global_id(cls, info, global_id, only_type=None): ) -class CountableConnection(graphene.relay.Connection): - class Meta: - abstract = True - - total_count = graphene.Int() - - def resolve_total_count(root, info): - return len(root.iterable) # And no, root.iterable.count() did not work for me. - - class CountableConnection(CustomPermissionFilteredConnection): class Meta: abstract = True @@ -278,10 +270,11 @@ def __init_subclass_with_meta__( interfaces=(), convert_choices_to_enum=None, _meta=None, - **options + **options, ): if filter_fields is not None and filterset_class is None: from graphene_django.filter.utils import get_filterset_class + filterset_class = get_filterset_class(model, filter_fields) super().__init_subclass_with_meta__( @@ -300,7 +293,7 @@ def __init_subclass_with_meta__( interfaces=interfaces, convert_choices_to_enum=convert_choices_to_enum, _meta=_meta, - **options + **options, ) # Replace any DjangoFilterConnectionField with CustomDjangoFilterConnectionField @@ -309,7 +302,9 @@ def __init_subclass_with_meta__( if isinstance(field, DjangoFilterConnectionField): new_field = CustomDjangoFilterConnectionField( type(field.node), - filters=field.filterset_class.Meta.fields if field.filterset_class else None, + filters=field.filterset_class.Meta.fields + if field.filterset_class + else None, filterset_class=field.filterset_class, ) cls._meta.fields[name] = new_field diff --git a/config/graphql/custom_connections.py b/config/graphql/custom_connections.py index 21da3b5c..e03aae39 100644 --- a/config/graphql/custom_connections.py +++ b/config/graphql/custom_connections.py @@ -3,7 +3,6 @@ from graphene import Connection, Int from graphene_django.filter import DjangoFilterConnectionField - logger = logging.getLogger(__name__) @@ -37,17 +36,27 @@ class Meta: abstract = True @classmethod - def connection_resolver(cls, resolver, connection, default_manager, max_limit, - enforce_first_or_last, filterset_class, filtering_args, - root, info, **args): + def connection_resolver( + cls, + resolver, + connection, + default_manager, + max_limit, + enforce_first_or_last, + filterset_class, + filtering_args, + root, + info, + **args, + ): # Get the model from the default_manager model = default_manager.model # Check if the user has the required permission user = info.context.user - if not user.has_perm(f'read_{model._meta.model_name}') and not model.is_public: - return super(CustomPermissionFilteredConnection, cls).connection_resolver( + if not user.has_perm(f"read_{model._meta.model_name}") and not model.is_public: + return super().connection_resolver( lambda *args, **kwargs: default_manager.none(), connection, default_manager, @@ -57,10 +66,10 @@ def connection_resolver(cls, resolver, connection, default_manager, max_limit, filtering_args, root, info, - **args + **args, ) - return super(CustomPermissionFilteredConnection, cls).connection_resolver( + return super().connection_resolver( resolver, connection, default_manager, @@ -70,15 +79,25 @@ def connection_resolver(cls, resolver, connection, default_manager, max_limit, filtering_args, root, info, - **args + **args, ) class CustomDjangoFilterConnectionField(DjangoFilterConnectionField): @classmethod - def connection_resolver(cls, resolver, connection, default_manager, max_limit, - enforce_first_or_last, filterset_class, filtering_args, - root, info, **args): + def connection_resolver( + cls, + resolver, + connection, + default_manager, + max_limit, + enforce_first_or_last, + filterset_class, + filtering_args, + root, + info, + **args, + ): return CustomPermissionFilteredConnection.connection_resolver( resolver, connection, @@ -89,5 +108,5 @@ def connection_resolver(cls, resolver, connection, default_manager, max_limit, filtering_args, root, info, - **args + **args, ) diff --git a/config/settings/base.py b/config/settings/base.py index 1fa786a7..df9a57cf 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -106,7 +106,7 @@ "opencontractserver.annotations", "opencontractserver.analyzer", "opencontractserver.extracts", - "opencontractserver.feedback" + "opencontractserver.feedback", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps diff --git a/opencontractserver/feedback/models.py b/opencontractserver/feedback/models.py index 6089cd8d..d3a6bf63 100644 --- a/opencontractserver/feedback/models.py +++ b/opencontractserver/feedback/models.py @@ -1,24 +1,24 @@ from django.db import models -from guardian.models import UserObjectPermissionBase, GroupObjectPermissionBase +from guardian.models import GroupObjectPermissionBase, UserObjectPermissionBase from opencontractserver.annotations.models import Annotation -from opencontractserver.shared.Models import BaseOCModel from opencontractserver.shared.defaults import jsonfield_default_value from opencontractserver.shared.fields import NullableJSONField +from opencontractserver.shared.Models import BaseOCModel class UserFeedback(BaseOCModel): - approved=models.BooleanField(default=False) - rejected=models.BooleanField(default=False) - comment=models.TextField(blank=True, default="", null=False) - markdown=models.TextField(blank=True, default="", null=False) - metadata=NullableJSONField(default=jsonfield_default_value, null=True, blank=True) - commented_annotation=models.ForeignKey( + approved = models.BooleanField(default=False) + rejected = models.BooleanField(default=False) + comment = models.TextField(blank=True, default="", null=False) + markdown = models.TextField(blank=True, default="", null=False) + metadata = NullableJSONField(default=jsonfield_default_value, null=True, blank=True) + commented_annotation = models.ForeignKey( Annotation, on_delete=models.CASCADE, blank=False, null=False, - related_name="user_feedback" + related_name="user_feedback", ) class Meta: @@ -31,17 +31,14 @@ class Meta: ("remove_userfeedback", "delete UserFeedback"), ) + # Model for Django Guardian permissions... trying to improve performance... class UserFeedbackObjectPermission(UserObjectPermissionBase): - content_object = models.ForeignKey( - "UserFeedback", on_delete=models.CASCADE - ) + content_object = models.ForeignKey("UserFeedback", on_delete=models.CASCADE) # enabled = False # Model for Django Guardian permissions... trying to improve performance... class UserFeedbackGroupObjectPermission(GroupObjectPermissionBase): - content_object = models.ForeignKey( - "UserFeedback", on_delete=models.CASCADE - ) + content_object = models.ForeignKey("UserFeedback", on_delete=models.CASCADE) # enabled = False diff --git a/opencontractserver/feedback/tests.py b/opencontractserver/feedback/tests.py index 7ce503c2..2ae28399 100644 --- a/opencontractserver/feedback/tests.py +++ b/opencontractserver/feedback/tests.py @@ -1,3 +1 @@ -from django.test import TestCase - -# Create your tests here. +pass diff --git a/opencontractserver/feedback/views.py b/opencontractserver/feedback/views.py index 91ea44a2..2ae28399 100644 --- a/opencontractserver/feedback/views.py +++ b/opencontractserver/feedback/views.py @@ -1,3 +1 @@ -from django.shortcuts import render - -# Create your views here. +pass diff --git a/opencontractserver/tests/test_custom_permission_filters.py b/opencontractserver/tests/test_custom_permission_filters.py index eac26bc7..792fc781 100644 --- a/opencontractserver/tests/test_custom_permission_filters.py +++ b/opencontractserver/tests/test_custom_permission_filters.py @@ -1,14 +1,15 @@ import logging + from django.contrib.auth import get_user_model from django.test import TestCase from graphene.test import Client from config.graphql.schema import schema +from opencontractserver.annotations.models import Annotation, AnnotationLabel from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document -from opencontractserver.annotations.models import Annotation, AnnotationLabel -from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user User = get_user_model() logger = logging.getLogger(__name__) @@ -40,26 +41,34 @@ def setUp(self): self.label2 = AnnotationLabel.objects.create(text="Label 2", creator=self.user2) self.annotation1 = Annotation.objects.create( - document=self.document1, - annotation_label=self.label1, - creator=self.user1 + document=self.document1, annotation_label=self.label1, creator=self.user1 ) self.annotation2 = Annotation.objects.create( - document=self.document2, - annotation_label=self.label2, - creator=self.user2 + document=self.document2, annotation_label=self.label2, creator=self.user2 ) # Set permissions - set_permissions_for_obj_to_user(self.user1, self.corpus1, [PermissionTypes.READ]) - set_permissions_for_obj_to_user(self.user1, self.document1, [PermissionTypes.READ]) + set_permissions_for_obj_to_user( + self.user1, self.corpus1, [PermissionTypes.READ] + ) + set_permissions_for_obj_to_user( + self.user1, self.document1, [PermissionTypes.READ] + ) set_permissions_for_obj_to_user(self.user1, self.label1, [PermissionTypes.READ]) - set_permissions_for_obj_to_user(self.user1, self.annotation1, [PermissionTypes.READ]) + set_permissions_for_obj_to_user( + self.user1, self.annotation1, [PermissionTypes.READ] + ) - set_permissions_for_obj_to_user(self.user2, self.corpus2, [PermissionTypes.READ]) - set_permissions_for_obj_to_user(self.user2, self.document2, [PermissionTypes.READ]) + set_permissions_for_obj_to_user( + self.user2, self.corpus2, [PermissionTypes.READ] + ) + set_permissions_for_obj_to_user( + self.user2, self.document2, [PermissionTypes.READ] + ) set_permissions_for_obj_to_user(self.user2, self.label2, [PermissionTypes.READ]) - set_permissions_for_obj_to_user(self.user2, self.annotation2, [PermissionTypes.READ]) + set_permissions_for_obj_to_user( + self.user2, self.annotation2, [PermissionTypes.READ] + ) def test_corpus_permission_filtering(self): query = """ @@ -77,13 +86,17 @@ def test_corpus_permission_filtering(self): # Test for user1 result1 = self.client1.execute(query) - self.assertEqual(len(result1['data']['corpuses']['edges']), 1) - self.assertEqual(result1['data']['corpuses']['edges'][0]['node']['title'], 'Corpus 1') + self.assertEqual(len(result1["data"]["corpuses"]["edges"]), 1) + self.assertEqual( + result1["data"]["corpuses"]["edges"][0]["node"]["title"], "Corpus 1" + ) # Test for user2 result2 = self.client2.execute(query) - self.assertEqual(len(result2['data']['corpuses']['edges']), 1) - self.assertEqual(result2['data']['corpuses']['edges'][0]['node']['title'], 'Corpus 2') + self.assertEqual(len(result2["data"]["corpuses"]["edges"]), 1) + self.assertEqual( + result2["data"]["corpuses"]["edges"][0]["node"]["title"], "Corpus 2" + ) def test_document_permission_filtering(self): query = """ @@ -101,13 +114,17 @@ def test_document_permission_filtering(self): # Test for user1 result1 = self.client1.execute(query) - self.assertEqual(len(result1['data']['documents']['edges']), 1) - self.assertEqual(result1['data']['documents']['edges'][0]['node']['title'], 'Document 1') + self.assertEqual(len(result1["data"]["documents"]["edges"]), 1) + self.assertEqual( + result1["data"]["documents"]["edges"][0]["node"]["title"], "Document 1" + ) # Test for user2 result2 = self.client2.execute(query) - self.assertEqual(len(result2['data']['documents']['edges']), 1) - self.assertEqual(result2['data']['documents']['edges'][0]['node']['title'], 'Document 2') + self.assertEqual(len(result2["data"]["documents"]["edges"]), 1) + self.assertEqual( + result2["data"]["documents"]["edges"][0]["node"]["title"], "Document 2" + ) def test_annotation_label_permission_filtering(self): query = """ @@ -125,13 +142,17 @@ def test_annotation_label_permission_filtering(self): # Test for user1 result1 = self.client1.execute(query) - self.assertEqual(len(result1['data']['annotationLabels']['edges']), 1) - self.assertEqual(result1['data']['annotationLabels']['edges'][0]['node']['text'], 'Label 1') + self.assertEqual(len(result1["data"]["annotationLabels"]["edges"]), 1) + self.assertEqual( + result1["data"]["annotationLabels"]["edges"][0]["node"]["text"], "Label 1" + ) # Test for user2 result2 = self.client2.execute(query) - self.assertEqual(len(result2['data']['annotationLabels']['edges']), 1) - self.assertEqual(result2['data']['annotationLabels']['edges'][0]['node']['text'], 'Label 2') + self.assertEqual(len(result2["data"]["annotationLabels"]["edges"]), 1) + self.assertEqual( + result2["data"]["annotationLabels"]["edges"][0]["node"]["text"], "Label 2" + ) def test_annotation_permission_filtering(self): query = """ @@ -151,13 +172,19 @@ def test_annotation_permission_filtering(self): # Test for user1 result1 = self.client1.execute(query) - self.assertEqual(len(result1['data']['annotations']['edges']), 1) - self.assertEqual(result1['data']['annotations']['edges'][0]['node']['document']['title'], 'Document 1') + self.assertEqual(len(result1["data"]["annotations"]["edges"]), 1) + self.assertEqual( + result1["data"]["annotations"]["edges"][0]["node"]["document"]["title"], + "Document 1", + ) # Test for user2 result2 = self.client2.execute(query) - self.assertEqual(len(result2['data']['annotations']['edges']), 1) - self.assertEqual(result2['data']['annotations']['edges'][0]['node']['document']['title'], 'Document 2') + self.assertEqual(len(result2["data"]["annotations"]["edges"]), 1) + self.assertEqual( + result2["data"]["annotations"]["edges"][0]["node"]["document"]["title"], + "Document 2", + ) def test_nested_permission_filtering(self): query = """ @@ -187,17 +214,31 @@ def test_nested_permission_filtering(self): # Test for user1 result1 = self.client1.execute(query) - self.assertEqual(len(result1['data']['corpuses']['edges']), 1) - self.assertEqual(len(result1['data']['corpuses']['edges'][0]['node']['documents']['edges']), 1) - self.assertEqual(result1['data']['corpuses']['edges'][0]['node']['documents']['edges'][0]['node']['title'], - 'Document 1') + self.assertEqual(len(result1["data"]["corpuses"]["edges"]), 1) + self.assertEqual( + len(result1["data"]["corpuses"]["edges"][0]["node"]["documents"]["edges"]), + 1, + ) + self.assertEqual( + result1["data"]["corpuses"]["edges"][0]["node"]["documents"]["edges"][0][ + "node" + ]["title"], + "Document 1", + ) # Test for user2 result2 = self.client2.execute(query) - self.assertEqual(len(result2['data']['corpuses']['edges']), 1) - self.assertEqual(len(result2['data']['corpuses']['edges'][0]['node']['documents']['edges']), 1) - self.assertEqual(result2['data']['corpuses']['edges'][0]['node']['documents']['edges'][0]['node']['title'], - 'Document 2') + self.assertEqual(len(result2["data"]["corpuses"]["edges"]), 1) + self.assertEqual( + len(result2["data"]["corpuses"]["edges"][0]["node"]["documents"]["edges"]), + 1, + ) + self.assertEqual( + result2["data"]["corpuses"]["edges"][0]["node"]["documents"]["edges"][0][ + "node" + ]["title"], + "Document 2", + ) def test_permission_change(self): query = """ @@ -215,15 +256,21 @@ def test_permission_change(self): # Initial test for user2 result1 = self.client2.execute(query) - self.assertEqual(len(result1['data']['corpuses']['edges']), 1) - self.assertEqual(result1['data']['corpuses']['edges'][0]['node']['title'], 'Corpus 2') + self.assertEqual(len(result1["data"]["corpuses"]["edges"]), 1) + self.assertEqual( + result1["data"]["corpuses"]["edges"][0]["node"]["title"], "Corpus 2" + ) # Grant permission to user2 for corpus1 - set_permissions_for_obj_to_user(self.user2, self.corpus1, [PermissionTypes.READ]) + set_permissions_for_obj_to_user( + self.user2, self.corpus1, [PermissionTypes.READ] + ) # Test again for user2 result2 = self.client2.execute(query) - self.assertEqual(len(result2['data']['corpuses']['edges']), 2) - titles = [edge['node']['title'] for edge in result2['data']['corpuses']['edges']] - self.assertIn('Corpus 1', titles) - self.assertIn('Corpus 2', titles) + self.assertEqual(len(result2["data"]["corpuses"]["edges"]), 2) + titles = [ + edge["node"]["title"] for edge in result2["data"]["corpuses"]["edges"] + ] + self.assertIn("Corpus 1", titles) + self.assertIn("Corpus 2", titles) From 539f09dc21eacff2f8f737542170e15c964ec13c Mon Sep 17 00:00:00 2001 From: JSv4 Date: Sat, 14 Sep 2024 18:11:07 -0700 Subject: [PATCH 07/18] Improving filtering on permissions. --- config/graphql/base.py | 83 +---- config/graphql/custom_connections.py | 135 ++++---- config/graphql/graphene_types.py | 50 +-- .../__init__.py | 0 config/graphql/permissioning/filters.py | 120 +++++++ .../graphene_django_filtering/__init__.py | 0 .../graphene_django_filtering/converters.py | 67 ++++ .../graphene_django_filtering/objects.py | 299 ++++++++++++++++++ .../permissioned_connections.py | 149 +++++++++ .../permission_annotator/__init__.py | 0 .../permission_annotator/middleware.py | 0 .../permission_annotator/mixins.py | 2 +- .../permission_annotator/utils.py | 0 opencontractserver/shared/Models.py | 32 +- .../tasks/permissioning_tasks.py | 6 +- .../tests/test_permissioning.py | 88 +++++- opencontractserver/utils/permissioning.py | 184 +---------- opencontractserver/utils/sharing.py | 189 +++++++++++ requirements/base.txt | 2 +- 19 files changed, 1035 insertions(+), 371 deletions(-) rename config/graphql/{permission_annotator => permissioning}/__init__.py (100%) create mode 100644 config/graphql/permissioning/filters.py create mode 100644 config/graphql/permissioning/graphene_django_filtering/__init__.py create mode 100644 config/graphql/permissioning/graphene_django_filtering/converters.py create mode 100644 config/graphql/permissioning/graphene_django_filtering/objects.py create mode 100644 config/graphql/permissioning/graphene_django_filtering/permissioned_connections.py create mode 100644 config/graphql/permissioning/permission_annotator/__init__.py rename config/graphql/{ => permissioning}/permission_annotator/middleware.py (100%) rename config/graphql/{ => permissioning}/permission_annotator/mixins.py (99%) rename config/graphql/{ => permissioning}/permission_annotator/utils.py (100%) create mode 100644 opencontractserver/utils/sharing.py diff --git a/config/graphql/base.py b/config/graphql/base.py index fd0a5f90..204df516 100644 --- a/config/graphql/base.py +++ b/config/graphql/base.py @@ -8,14 +8,9 @@ from graphene import Int from graphene.relay import Node from graphene_django import DjangoObjectType -from graphene_django.filter import DjangoFilterConnectionField from graphql_jwt.decorators import login_required from graphql_relay import from_global_id, to_global_id -from config.graphql.custom_connections import ( - CustomDjangoFilterConnectionField, - CustomPermissionFilteredConnection, -) from opencontractserver.shared.resolvers import resolve_single_oc_model_from_id from opencontractserver.types.enums import PermissionTypes from opencontractserver.utils.permissioning import ( @@ -63,18 +58,14 @@ def get_node_from_global_id(cls, info, global_id, only_type=None): ) -class CountableConnection(CustomPermissionFilteredConnection): +class CountableConnection(graphene.relay.Connection): class Meta: abstract = True - total_count = Int() - edge_count = Int() - - def resolve_total_count(root, info, **kwargs): - return root.length + total_count = graphene.Int() - def resolve_edge_count(root, info, **kwargs): - return len(root.edges) + def resolve_total_count(root, info): + return len(root.iterable) # And no, root.iterable.count() did not work for me. class DRFDeletion(graphene.Mutation): @@ -246,69 +237,3 @@ def mutate(cls, root, info, *args, **kwargs): message = f"Mutation failed due to error: {e}" return cls(ok=ok, message=message, obj_id=obj_id) - - -class CustomDjangoObjectType(DjangoObjectType): - class Meta: - abstract = True - - @classmethod - def __init_subclass_with_meta__( - cls, - model=None, - registry=None, - skip_registry=False, - only_fields=None, - fields=None, - exclude_fields=None, - exclude=None, - filter_fields=None, - filterset_class=None, - connection=None, - connection_class=None, - use_connection=None, - interfaces=(), - convert_choices_to_enum=None, - _meta=None, - **options, - ): - if filter_fields is not None and filterset_class is None: - from graphene_django.filter.utils import get_filterset_class - - filterset_class = get_filterset_class(model, filter_fields) - - super().__init_subclass_with_meta__( - model=model, - registry=registry, - skip_registry=skip_registry, - only_fields=only_fields, - fields=fields, - exclude_fields=exclude_fields, - exclude=exclude, - filter_fields=filter_fields, - filterset_class=filterset_class, - connection=connection, - connection_class=connection_class, - use_connection=use_connection, - interfaces=interfaces, - convert_choices_to_enum=convert_choices_to_enum, - _meta=_meta, - **options, - ) - - # Replace any DjangoFilterConnectionField with CustomDjangoFilterConnectionField - if cls._meta.fields: - for name, field in cls._meta.fields.items(): - if isinstance(field, DjangoFilterConnectionField): - new_field = CustomDjangoFilterConnectionField( - type(field.node), - filters=field.filterset_class.Meta.fields - if field.filterset_class - else None, - filterset_class=field.filterset_class, - ) - cls._meta.fields[name] = new_field - - @classmethod - def get_node(cls, info, id): - return super().get_node(info, id) diff --git a/config/graphql/custom_connections.py b/config/graphql/custom_connections.py index e03aae39..0a96271c 100644 --- a/config/graphql/custom_connections.py +++ b/config/graphql/custom_connections.py @@ -1,7 +1,14 @@ import logging +from collections import OrderedDict +from functools import partial + +from django.core.exceptions import ValidationError + from graphene import Connection, Int -from graphene_django.filter import DjangoFilterConnectionField +from graphene.types.argument import to_arguments +from graphene.utils.str_converters import to_snake_case +from graphene_django.filter.fields import convert_enum, DjangoFilterConnectionField logger = logging.getLogger(__name__) @@ -31,82 +38,64 @@ def resolve_page_count(root, info, **kwargs): return largest_page_number -class CustomPermissionFilteredConnection(Connection): - class Meta: - abstract = True - - @classmethod - def connection_resolver( - cls, - resolver, - connection, - default_manager, - max_limit, - enforce_first_or_last, - filterset_class, - filtering_args, - root, - info, - **args, +class CustomDjangoFilterConnectionField(DjangoFilterConnectionField): + def __init__( + self, + type_, + fields=None, + order_by=None, + extra_filter_meta=None, + filterset_class=None, + *args, + **kwargs ): + print(F"CustomDjangoFilterConnectionField - kwargs: {kwargs}") + super().__init__(type_, fields, order_by, extra_filter_meta, filterset_class, *args, **kwargs) - # Get the model from the default_manager - model = default_manager.model - - # Check if the user has the required permission - user = info.context.user - if not user.has_perm(f"read_{model._meta.model_name}") and not model.is_public: - return super().connection_resolver( - lambda *args, **kwargs: default_manager.none(), - connection, - default_manager, - max_limit, - enforce_first_or_last, - filterset_class, - filtering_args, - root, - info, - **args, - ) - - return super().connection_resolver( - resolver, - connection, - default_manager, - max_limit, - enforce_first_or_last, - filterset_class, - filtering_args, - root, - info, - **args, - ) + @property + def args(self): + return to_arguments(self._base_args or OrderedDict(), self.filtering_args) + @args.setter + def args(self, args): + self._base_args = args -class CustomDjangoFilterConnectionField(DjangoFilterConnectionField): @classmethod - def connection_resolver( - cls, - resolver, - connection, - default_manager, - max_limit, - enforce_first_or_last, - filterset_class, - filtering_args, - root, - info, - **args, + def resolve_queryset( + cls, connection, iterable, info, args, filtering_args, filterset_class ): - return CustomPermissionFilteredConnection.connection_resolver( - resolver, - connection, - default_manager, - max_limit, - enforce_first_or_last, - filterset_class, - filtering_args, - root, - info, - **args, + def filter_kwargs(): + kwargs = {} + for k, v in args.items(): + if k in filtering_args: + if k == "order_by" and v is not None: + v = to_snake_case(v) + kwargs[k] = convert_enum(v) + return kwargs + + qs = super(DjangoFilterConnectionField, cls).resolve_queryset( + connection, iterable, info, args + ) + + filterset = filterset_class( + data=filter_kwargs(), queryset=qs, request=info.context + ) + + if filterset.is_valid(): + qs = filterset.qs + # Apply permission filtering + model = qs.model + user = info.context.user + if hasattr(model, 'get_queryset'): + qs = model.get_queryset(qs, user) + elif hasattr(model, 'objects') and hasattr(model.objects, 'get_queryset'): + qs = model.objects.get_queryset(qs, user) + return qs + raise ValidationError(filterset.form.errors.as_json()) + + def get_queryset_resolver(self): + return partial( + self.resolve_queryset, + filterset_class=self.filterset_class, + filtering_args=self.filtering_args, ) diff --git a/config/graphql/graphene_types.py b/config/graphql/graphene_types.py index 1b357311..7afbb62a 100644 --- a/config/graphql/graphene_types.py +++ b/config/graphql/graphene_types.py @@ -4,12 +4,14 @@ from django.contrib.auth import get_user_model from graphene import relay from graphene.types.generic import GenericScalar +from graphene_django import DjangoObjectType from graphene_django.filter import DjangoFilterConnectionField from graphql_relay import from_global_id -from config.graphql.base import CountableConnection, CustomDjangoObjectType +from config.graphql.base import CountableConnection + from config.graphql.filters import AnnotationFilter, LabelFilter -from config.graphql.permission_annotator.mixins import AnnotatePermissionsForReadMixin +from config.graphql.permissioning.permission_annotator.mixins import AnnotatePermissionsForReadMixin from opencontractserver.analyzer.models import Analysis, Analyzer, GremlinEngine from opencontractserver.annotations.models import ( Annotation, @@ -27,21 +29,21 @@ logger = logging.getLogger(__name__) -class UserType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class UserType(AnnotatePermissionsForReadMixin, DjangoObjectType): class Meta: model = User interfaces = [relay.Node] connection_class = CountableConnection -class AssignmentType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class AssignmentType(AnnotatePermissionsForReadMixin, DjangoObjectType): class Meta: model = Assignment interfaces = [relay.Node] connection_class = CountableConnection -class RelationshipType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class RelationshipType(AnnotatePermissionsForReadMixin, DjangoObjectType): class Meta: model = Relationship interfaces = [relay.Node] @@ -66,7 +68,7 @@ class AnnotationInputType(AnnotatePermissionsForReadMixin, graphene.InputObjectT is_public = graphene.Boolean() -class AnnotationType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class AnnotationType(AnnotatePermissionsForReadMixin, DjangoObjectType): json = GenericScalar() all_source_node_in_relationship = graphene.List(lambda: RelationshipType) @@ -120,14 +122,14 @@ class PageAwareAnnotationType(graphene.ObjectType): page_annotations = graphene.List(AnnotationType) -class AnnotationLabelType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class AnnotationLabelType(AnnotatePermissionsForReadMixin, DjangoObjectType): class Meta: model = AnnotationLabel interfaces = [relay.Node] connection_class = CountableConnection -class LabelSetType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class LabelSetType(AnnotatePermissionsForReadMixin, DjangoObjectType): annotation_labels = DjangoFilterConnectionField( AnnotationLabelType, filterset_class=LabelFilter ) @@ -148,7 +150,7 @@ class Meta: connection_class = CountableConnection -class DocumentType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class DocumentType(AnnotatePermissionsForReadMixin, DjangoObjectType): def resolve_pdf_file(self, info): return ( "" @@ -244,7 +246,7 @@ class Meta: connection_class = CountableConnection -class CorpusType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class CorpusType(AnnotatePermissionsForReadMixin, DjangoObjectType): all_annotation_summaries = graphene.List( AnnotationType, analysis_id=graphene.ID(), @@ -294,7 +296,7 @@ class Meta: connection_class = CountableConnection -class CorpusActionType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class CorpusActionType(AnnotatePermissionsForReadMixin, DjangoObjectType): class Meta: model = CorpusAction interfaces = [relay.Node] @@ -310,7 +312,7 @@ class Meta: } -class UserImportType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class UserImportType(AnnotatePermissionsForReadMixin, DjangoObjectType): def resolve_zip(self, info): return "" if not self.file else info.context.build_absolute_uri(self.zip.url) @@ -320,7 +322,7 @@ class Meta: connection_class = CountableConnection -class UserExportType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class UserExportType(AnnotatePermissionsForReadMixin, DjangoObjectType): def resolve_file(self, info): return "" if not self.file else info.context.build_absolute_uri(self.file.url) @@ -330,7 +332,7 @@ class Meta: connection_class = CountableConnection -class AnalyzerType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class AnalyzerType(AnnotatePermissionsForReadMixin, DjangoObjectType): analyzer_id = graphene.String() def resolve_analyzer_id(self, info): @@ -352,7 +354,7 @@ class Meta: connection_class = CountableConnection -class GremlinEngineType_READ(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class GremlinEngineType_READ(AnnotatePermissionsForReadMixin, DjangoObjectType): class Meta: model = GremlinEngine exclude = ("api_key",) @@ -360,14 +362,14 @@ class Meta: connection_class = CountableConnection -class GremlinEngineType_WRITE(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class GremlinEngineType_WRITE(AnnotatePermissionsForReadMixin, DjangoObjectType): class Meta: model = GremlinEngine interfaces = [relay.Node] connection_class = CountableConnection -class AnalysisType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class AnalysisType(AnnotatePermissionsForReadMixin, DjangoObjectType): full_annotation_list = graphene.List(AnnotationType) def resolve_full_annotation_list(self, info): @@ -379,14 +381,14 @@ class Meta: connection_class = CountableConnection -class ColumnType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class ColumnType(AnnotatePermissionsForReadMixin, DjangoObjectType): class Meta: model = Column interfaces = [relay.Node] connection_class = CountableConnection -class FieldsetType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class FieldsetType(AnnotatePermissionsForReadMixin, DjangoObjectType): full_column_list = graphene.List(ColumnType) class Meta: @@ -398,7 +400,7 @@ def resolve_full_column_list(self, info): return self.columns.all() -class DatacellType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class DatacellType(AnnotatePermissionsForReadMixin, DjangoObjectType): data = GenericScalar() full_source_list = graphene.List(AnnotationType) @@ -411,7 +413,7 @@ class Meta: connection_class = CountableConnection -class ExtractType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class ExtractType(AnnotatePermissionsForReadMixin, DjangoObjectType): full_datacell_list = graphene.List(DatacellType) full_document_list = graphene.List(DocumentType) @@ -427,7 +429,7 @@ def resolve_full_document_list(self, info): return self.documents.all() -class CorpusQueryType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class CorpusQueryType(AnnotatePermissionsForReadMixin, DjangoObjectType): full_source_list = graphene.List(AnnotationType) def resolve_full_source_list(self, info): @@ -439,7 +441,7 @@ class Meta: connection_class = CountableConnection -class DocumentAnalysisRowType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class DocumentAnalysisRowType(AnnotatePermissionsForReadMixin, DjangoObjectType): class Meta: model = DocumentAnalysisRow interfaces = [relay.Node] @@ -452,7 +454,7 @@ class DocumentCorpusActionsType(graphene.ObjectType): analysis_rows = graphene.List(DocumentAnalysisRowType) -class UserFeedbackType(AnnotatePermissionsForReadMixin, CustomDjangoObjectType): +class UserFeedbackType(AnnotatePermissionsForReadMixin, DjangoObjectType): class Meta: model = UserFeedback interfaces = [relay.Node] diff --git a/config/graphql/permission_annotator/__init__.py b/config/graphql/permissioning/__init__.py similarity index 100% rename from config/graphql/permission_annotator/__init__.py rename to config/graphql/permissioning/__init__.py diff --git a/config/graphql/permissioning/filters.py b/config/graphql/permissioning/filters.py new file mode 100644 index 00000000..ba44f9ff --- /dev/null +++ b/config/graphql/permissioning/filters.py @@ -0,0 +1,120 @@ +# import django +# from django.apps import apps +# from django.contrib.auth import get_user_model +# from django.contrib.contenttypes.models import ContentType +# from django.db.models import Prefetch, Q +# from guardian.mixins import UserObjectPermission +# from guardian.models import GroupObjectPermission +# from opencontractserver.utils.permissioning import get_users_group_ids, get_permission_id_to_name_map_for_model +# +# User = get_user_model() +# +# +# def filter_queryset_by_user_read_permission( +# queryset: django.db.models.QuerySet, +# user: User, +# include_group_permissions: bool = True +# ) -> django.db.models.QuerySet: +# if not queryset.exists(): +# return queryset.none() +# +# model = queryset.model +# model_name = model._meta.model_name +# app_label = model._meta.app_label +# +# content_type = ContentType.objects.get_for_model(model) +# permission_id_to_name_map = get_permission_id_to_name_map_for_model(model) +# read_permission_id = next( +# (k for k, v in permission_id_to_name_map.items() if v == f"read_{model_name}"), +# None +# ) +# +# if read_permission_id is None: +# return queryset.none() +# +# user_permission_model = apps.get_model(f'{app_label}.{model_name}userobjectpermission') +# +# print(f"Get permissions for content type {content_type}") +# +# user_perms_queryset = user_permission_model.objects.filter( +# content_object=queryset, +# user=user, +# permission_id=read_permission_id +# ) +# +# group_permission_model = +# group_perms_queryset = GroupObjectPermission.objects.none() +# if include_group_permissions: +# user_group_ids = get_users_group_ids(user) +# group_perms_queryset = GroupObjectPermission.objects.filter( +# content_type=content_type, +# group_id__in=user_group_ids, +# permission_id=read_permission_id +# ) +# +# queryset = queryset.prefetch_related( +# Prefetch(f'{model_name}userobjectpermission_set', queryset=user_perms_queryset, to_attr='user_read_perms'), +# Prefetch(f'{model_name}groupobjectpermission_set', queryset=group_perms_queryset, to_attr='group_read_perms') +# ) +# +# return queryset.filter( +# Q(is_public=True) | +# Q(**{f'{model_name}userobjectpermission__in': user_perms_queryset}) | +# Q(**{f'{model_name}groupobjectpermission__in': group_perms_queryset}) +# ).distinct() +# + +from django.db.models.query import QuerySet +from django.db.models import Q, Exists, OuterRef +from django.contrib.contenttypes.models import ContentType +from guardian.models import UserObjectPermission, GroupObjectPermission +from django.core.cache import cache + +class PermissionQuerySet(QuerySet): + def for_user(self, user, perm, extra_conditions=None): + model = self.model + cache_key = f'content_type_{model._meta.app_label}_{model._meta.model_name}' + content_type = cache.get(cache_key) + if content_type is None: + content_type = ContentType.objects.get_for_model(model) + cache.set(cache_key, content_type, 3600) # Cache for 1 hour + + permission_codename = f'{perm}_{model._meta.model_name}' + + # Subqueries for user and group permissions + user_perm_subquery = UserObjectPermission.objects.filter( + user=user, + content_type=content_type, + object_pk=OuterRef('pk'), + permission__codename=permission_codename + ).values('pk') + group_perm_subquery = GroupObjectPermission.objects.filter( + group__user=user, + content_type=content_type, + object_pk=OuterRef('pk'), + permission__codename=permission_codename + ).values('pk') + + # Annotate with permission checks + qs = self.annotate( + has_user_perm=Exists(user_perm_subquery), + has_group_perm=Exists(group_perm_subquery) + ) + + # Base condition: user has the permission + base_condition = Q(has_user_perm=True) | Q(has_group_perm=True) + + # Default extra conditions based on permission + if extra_conditions is None: + if perm == 'read': + extra_conditions = Q(is_public=True) | Q(creator=user) + elif perm == 'publish': + extra_conditions = Q(creator=user) + else: + extra_conditions = Q() + + # Combine conditions + final_condition = base_condition | extra_conditions + + # Apply the filter + return qs.filter(final_condition).distinct() diff --git a/config/graphql/permissioning/graphene_django_filtering/__init__.py b/config/graphql/permissioning/graphene_django_filtering/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/config/graphql/permissioning/graphene_django_filtering/converters.py b/config/graphql/permissioning/graphene_django_filtering/converters.py new file mode 100644 index 00000000..065e6841 --- /dev/null +++ b/config/graphql/permissioning/graphene_django_filtering/converters.py @@ -0,0 +1,67 @@ +from django.db import models + +from graphene import ( + Dynamic, + Field, +) +from graphene_django.converter import get_django_field_description, convert_django_field +from graphene_django.fields import DjangoListField + +from config.graphql.custom_connections import CustomDjangoFilterConnectionField + + +@convert_django_field.register(models.OneToOneRel) +def convert_onetoone_field_to_djangomodel(field, registry=None): + model = field.related_model + + def dynamic_type(): + _type = registry.get_type_for_model(model) + if not _type: + return + + return Field(_type, required=not field.null) + + return Dynamic(dynamic_type) + + +@convert_django_field.register(models.ManyToManyField) +@convert_django_field.register(models.ManyToManyRel) +@convert_django_field.register(models.ManyToOneRel) +def convert_field_to_list_or_connection(field, registry=None): + + print(f"Custom convert_field_to_list_or_connection running...") + + model = field.related_model + + def dynamic_type(): + _type = registry.get_type_for_model(model) + if not _type: + return + + if isinstance(field, models.ManyToManyField): + description = get_django_field_description(field) + else: + description = get_django_field_description(field.field) + + # If there is a connection, we should transform the field + # into a DjangoConnectionField + if _type._meta.connection: + # Use a DjangoFilterConnectionField if there are + # defined filter_fields or a filterset_class in the + # DjangoObjectType Meta + if _type._meta.filter_fields or _type._meta.filterset_class: + from graphene_django.filter.fields import DjangoFilterConnectionField + + return DjangoFilterConnectionField( + _type, required=True, description=description + ) + + return CustomDjangoFilterConnectionField(_type, required=True, description=description) + + return DjangoListField( + _type, + required=True, # A Set is always returned, never None. + description=description, + ) + + return Dynamic(dynamic_type) diff --git a/config/graphql/permissioning/graphene_django_filtering/objects.py b/config/graphql/permissioning/graphene_django_filtering/objects.py new file mode 100644 index 00000000..08d536b7 --- /dev/null +++ b/config/graphql/permissioning/graphene_django_filtering/objects.py @@ -0,0 +1,299 @@ +import warnings +from collections import OrderedDict +from typing import Type + +import graphene +from django.db.models import Model +from graphene.relay import Connection, Node +from graphene.types.objecttype import ObjectType, ObjectTypeOptions +from graphene.types.utils import yank_fields_from_attrs +from graphene_django.converter import convert_django_field_with_choices + +from graphene_django.registry import Registry, get_global_registry +from graphene_django.settings import graphene_settings +from graphene_django.utils import ( + DJANGO_FILTER_INSTALLED, + camelize, + get_model_fields, + is_valid_django_model, +) + +ALL_FIELDS = "__all__" + + +def construct_fields( + model, registry, only_fields, exclude_fields, convert_choices_to_enum +): + _model_fields = get_model_fields(model) + + fields = OrderedDict() + for name, field in _model_fields: + is_not_in_only = ( + only_fields is not None + and only_fields != ALL_FIELDS + and name not in only_fields + ) + # is_already_created = name in options.fields + is_excluded = ( + exclude_fields is not None and name in exclude_fields + ) # or is_already_created + # https://docs.djangoproject.com/en/1.10/ref/models/fields/#django.db.models.ForeignKey.related_query_name + is_no_backref = str(name).endswith("+") + if is_not_in_only or is_excluded or is_no_backref: + # We skip this field if we specify only_fields and is not + # in there. Or when we exclude this field in exclude_fields. + # Or when there is no back reference. + continue + + _convert_choices_to_enum = convert_choices_to_enum + if not isinstance(_convert_choices_to_enum, bool): + # then `convert_choices_to_enum` is a list of field names to convert + if name in _convert_choices_to_enum: + _convert_choices_to_enum = True + else: + _convert_choices_to_enum = False + + converted = convert_django_field_with_choices( + field, registry, convert_choices_to_enum=_convert_choices_to_enum + ) + fields[name] = converted + + return fields + + +def validate_fields(type_, model, fields, only_fields, exclude_fields): + # Validate the given fields against the model's fields and custom fields + all_field_names = set(fields.keys()) + only_fields = only_fields if only_fields is not ALL_FIELDS else () + for name in only_fields or (): + if name in all_field_names: + continue + + if hasattr(model, name): + warnings.warn( + ( + 'Field name "{field_name}" matches an attribute on Django model "{app_label}.{object_name}" ' + "but it's not a model field so Graphene cannot determine what type it should be. " + 'Either define the type of the field on DjangoObjectType "{type_}" or remove it from the "fields" list.' + ).format( + field_name=name, + app_label=model._meta.app_label, + object_name=model._meta.object_name, + type_=type_, + ) + ) + + else: + warnings.warn( + ( + 'Field name "{field_name}" doesn\'t exist on Django model "{app_label}.{object_name}". ' + 'Consider removing the field from the "fields" list of DjangoObjectType "{type_}" because it has no effect.' + ).format( + field_name=name, + app_label=model._meta.app_label, + object_name=model._meta.object_name, + type_=type_, + ) + ) + + # Validate exclude fields + for name in exclude_fields or (): + if name in all_field_names: + # Field is a custom field + warnings.warn( + ( + 'Excluding the custom field "{field_name}" on DjangoObjectType "{type_}" has no effect. ' + 'Either remove the custom field or remove the field from the "exclude" list.' + ).format(field_name=name, type_=type_) + ) + else: + if not hasattr(model, name): + warnings.warn( + ( + 'Django model "{app_label}.{object_name}" does not have a field or attribute named "{field_name}". ' + 'Consider removing the field from the "exclude" list of DjangoObjectType "{type_}" because it has no effect' + ).format( + field_name=name, + app_label=model._meta.app_label, + object_name=model._meta.object_name, + type_=type_, + ) + ) + + +class DjangoObjectTypeOptions(ObjectTypeOptions): + model = None # type: Type[Model] + registry = None # type: Registry + connection = None # type: Type[Connection] + + filter_fields = () + filterset_class = None + +# TODO - confirm we actually may not need this... since I *think* we hooked into graphene's conversions and are +# replacing ConnectionFields + +class DjangoObjectType(ObjectType): + @classmethod + def __init_subclass_with_meta__( + cls, + model=None, + registry=None, + skip_registry=False, + only_fields=None, # deprecated in favour of `fields` + fields=None, + exclude_fields=None, # deprecated in favour of `exclude` + exclude=None, + filter_fields=None, + filterset_class=None, + connection=None, + connection_class=None, + use_connection=None, + interfaces=(), + convert_choices_to_enum=True, + _meta=None, + **options + ): + assert is_valid_django_model(model), ( + 'You need to pass a valid Django Model in {}.Meta, received "{}".' + ).format(cls.__name__, model) + + if not registry: + registry = get_global_registry() + + assert isinstance(registry, Registry), ( + "The attribute registry in {} needs to be an instance of " + 'Registry, received "{}".' + ).format(cls.__name__, registry) + + if filter_fields and filterset_class: + raise Exception("Can't set both filter_fields and filterset_class") + + if not DJANGO_FILTER_INSTALLED and (filter_fields or filterset_class): + raise Exception( + ( + "Can only set filter_fields or filterset_class if " + "Django-Filter is installed" + ) + ) + + assert not (fields and exclude), ( + "Cannot set both 'fields' and 'exclude' options on " + "DjangoObjectType {class_name}.".format(class_name=cls.__name__) + ) + + # Alias only_fields -> fields + if only_fields and fields: + raise Exception("Can't set both only_fields and fields") + if only_fields: + warnings.warn( + "Defining `only_fields` is deprecated in favour of `fields`.", + DeprecationWarning, + stacklevel=2, + ) + fields = only_fields + if fields and fields != ALL_FIELDS and not isinstance(fields, (list, tuple)): + raise TypeError( + 'The `fields` option must be a list or tuple or "__all__". ' + "Got %s." % type(fields).__name__ + ) + + # Alias exclude_fields -> exclude + if exclude_fields and exclude: + raise Exception("Can't set both exclude_fields and exclude") + if exclude_fields: + warnings.warn( + "Defining `exclude_fields` is deprecated in favour of `exclude`.", + DeprecationWarning, + stacklevel=2, + ) + exclude = exclude_fields + if exclude and not isinstance(exclude, (list, tuple)): + raise TypeError( + "The `exclude` option must be a list or tuple. Got %s." + % type(exclude).__name__ + ) + + if fields is None and exclude is None: + warnings.warn( + "Creating a DjangoObjectType without either the `fields` " + "or the `exclude` option is deprecated. Add an explicit `fields " + "= '__all__'` option on DjangoObjectType {class_name} to use all " + "fields".format(class_name=cls.__name__), + DeprecationWarning, + stacklevel=2, + ) + + # TODO - this chain needs modification + django_fields = yank_fields_from_attrs( + construct_fields(model, registry, fields, exclude, convert_choices_to_enum), + _as=graphene.Field, + ) + print(f"django fields: {django_fields}") + + if use_connection is None and interfaces: + use_connection = any( + (issubclass(interface, Node) for interface in interfaces) + ) + + if use_connection and not connection: + # We create the connection automatically + if not connection_class: + connection_class = Connection + + connection = connection_class.create_type( + "{}Connection".format(options.get("name") or cls.__name__), node=cls + ) + + if connection is not None: + assert issubclass(connection, Connection), ( + "The connection must be a Connection. Received {}" + ).format(connection.__name__) + + if not _meta: + _meta = DjangoObjectTypeOptions(cls) + + _meta.model = model + _meta.registry = registry + _meta.filter_fields = filter_fields + _meta.filterset_class = filterset_class + _meta.fields = django_fields + _meta.connection = connection + + super(DjangoObjectType, cls).__init_subclass_with_meta__( + _meta=_meta, interfaces=interfaces, **options + ) + + # Validate fields + validate_fields(cls, model, _meta.fields, fields, exclude) + + if not skip_registry: + registry.register(cls) + + def resolve_id(self, info): + return self.pk + + @classmethod + def is_type_of(cls, root, info): + if isinstance(root, cls): + return True + if not is_valid_django_model(root.__class__): + raise Exception(('Received incompatible instance "{}".').format(root)) + + if cls._meta.model._meta.proxy: + model = root._meta.model + else: + model = root._meta.model._meta.concrete_model + + return model == cls._meta.model + + @classmethod + def get_queryset(cls, queryset, info): + return queryset + + @classmethod + def get_node(cls, info, id): + queryset = cls.get_queryset(cls._meta.model.objects, info) + try: + return queryset.get(pk=id) + except cls._meta.model.DoesNotExist: + return None diff --git a/config/graphql/permissioning/graphene_django_filtering/permissioned_connections.py b/config/graphql/permissioning/graphene_django_filtering/permissioned_connections.py new file mode 100644 index 00000000..41fce900 --- /dev/null +++ b/config/graphql/permissioning/graphene_django_filtering/permissioned_connections.py @@ -0,0 +1,149 @@ +from collections import OrderedDict + +import graphene +from graphene_django import DjangoObjectType +from graphene_django.registry import Registry, get_global_registry +from graphene_django.types import ALL_FIELDS +from graphene_django.utils import is_valid_django_model, DJANGO_FILTER_INSTALLED, get_model_fields +from graphene.types.utils import yank_fields_from_attrs +from graphene.types.objecttype import ObjectType, ObjectTypeOptions +from graphene.relay import Connection, Node +from django.core.exceptions import ImproperlyConfigured + +from .converter import convert_django_field_with_choices + + +class CustomDjangoObjectTypeOptions(ObjectTypeOptions): + model = None + registry = None + connection = None + filter_fields = () + filterset_class = None + + +class CustomDjangoObjectType(DjangoObjectType): + @classmethod + def __init_subclass_with_meta__( + cls, + model=None, + registry=None, + skip_registry=False, + only_fields=None, + fields=None, + exclude_fields=None, + exclude=None, + filter_fields=None, + filterset_class=None, + connection=None, + connection_class=None, + use_connection=None, + interfaces=(), + convert_choices_to_enum=True, + _meta=None, + **options + ): + assert is_valid_django_model(model), ( + 'You need to pass a valid Django Model in {}.Meta, received "{}".' + ).format(cls.__name__, model) + + if not registry: + registry = get_global_registry() + + assert isinstance(registry, Registry), ( + "The attribute registry in {} needs to be an instance of " + 'Registry, received "{}".' + ).format(cls.__name__, registry) + + if filter_fields and filterset_class: + raise ImproperlyConfigured( + "Can't set both filter_fields and filterset_class" + ) + + if not DJANGO_FILTER_INSTALLED and (filter_fields or filterset_class): + raise ImproperlyConfigured( + "Can only set filter_fields or filterset_class if " + "Django-Filter is installed" + ) + + assert not (fields and exclude), ( + "Cannot set both 'fields' and 'exclude' options on " + "DjangoObjectType {}.".format(cls.__name__) + ) + + if fields and fields != ALL_FIELDS and not isinstance(fields, (list, tuple)): + raise TypeError( + 'The `fields` option must be a list or tuple or "__all__". ' + "Got {}".format(type(fields).__name__) + ) + + if exclude and not isinstance(exclude, (list, tuple)): + raise TypeError( + "The `exclude` option must be a list or tuple. Got {}".format( + type(exclude).__name__ + ) + ) + + django_fields = cls.construct_fields(model, registry, fields, exclude, convert_choices_to_enum) + + if use_connection is None and interfaces: + use_connection = any(issubclass(interface, Node) for interface in interfaces) + + if use_connection and not connection: + # We create the connection automatically + if not connection_class: + connection_class = Connection + + connection = connection_class.create_type( + "{}Connection".format(options.get("name") or cls.__name__), + node=cls, + ) + + if connection is not None: + assert issubclass(connection, Connection), ( + "The connection must be a Connection. Received {}" + ).format(connection.__name__) + + _meta = CustomDjangoObjectTypeOptions(cls) + _meta.model = model + _meta.registry = registry + _meta.filter_fields = filter_fields + _meta.filterset_class = filterset_class + _meta.fields = django_fields + _meta.connection = connection + + super(DjangoObjectType, cls).__init_subclass_with_meta__( + _meta=_meta, interfaces=interfaces, **options + ) + + if not skip_registry: + registry.register(cls) + + @classmethod + def construct_fields(cls, model, registry, fields, exclude, convert_choices_to_enum): + _model_fields = get_model_fields(model) + + fields_dict = OrderedDict() + for name, field in _model_fields: + is_not_in_only = ( + fields is not None + and fields != ALL_FIELDS + and name not in fields + ) + is_excluded = exclude is not None and name in exclude + is_no_backref = str(name).endswith("+") + if is_not_in_only or is_excluded or is_no_backref: + continue + + _convert_choices_to_enum = convert_choices_to_enum + if isinstance(_convert_choices_to_enum, (list, tuple)): + if name in _convert_choices_to_enum: + _convert_choices_to_enum = True + else: + _convert_choices_to_enum = False + + converted = convert_django_field_with_choices( + field, registry, convert_choices_to_enum=_convert_choices_to_enum + ) + fields_dict[name] = converted + + return yank_fields_from_attrs(fields_dict, _as=graphene.Field) diff --git a/config/graphql/permissioning/permission_annotator/__init__.py b/config/graphql/permissioning/permission_annotator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/config/graphql/permission_annotator/middleware.py b/config/graphql/permissioning/permission_annotator/middleware.py similarity index 100% rename from config/graphql/permission_annotator/middleware.py rename to config/graphql/permissioning/permission_annotator/middleware.py diff --git a/config/graphql/permission_annotator/mixins.py b/config/graphql/permissioning/permission_annotator/mixins.py similarity index 99% rename from config/graphql/permission_annotator/mixins.py rename to config/graphql/permissioning/permission_annotator/mixins.py index 519e2a87..7542d2e5 100644 --- a/config/graphql/permission_annotator/mixins.py +++ b/config/graphql/permissioning/permission_annotator/mixins.py @@ -5,7 +5,7 @@ from django.contrib.auth import get_user_model from graphene.types.generic import GenericScalar -from config.graphql.permission_annotator.middleware import ( +from config.graphql.permissioning.permission_annotator.middleware import ( get_permissions_for_user_on_model_in_app, ) from opencontractserver.types.enums import PermissionTypes diff --git a/config/graphql/permission_annotator/utils.py b/config/graphql/permissioning/permission_annotator/utils.py similarity index 100% rename from config/graphql/permission_annotator/utils.py rename to config/graphql/permissioning/permission_annotator/utils.py diff --git a/opencontractserver/shared/Models.py b/opencontractserver/shared/Models.py index 6c8394c2..326897ce 100644 --- a/opencontractserver/shared/Models.py +++ b/opencontractserver/shared/Models.py @@ -1,15 +1,39 @@ import django from django.contrib.auth import get_user_model from django.db import models +from django.db.models import Manager from django.utils import timezone +from django.db import models + +from config.graphql.permissioning.filters import PermissionQuerySet + + +# from config.graphql.permissioning.filters import filter_queryset_by_user_read_permission + + +# class PermissionQuerySet(models.QuerySet): +# def readable_by_user(self, user): +# return filter_queryset_by_user_read_permission(self, user) + + +class PermissionManager(Manager): + def get_queryset(self): + return PermissionQuerySet(self.model, using=self._db) + + def for_user(self, user, perm, extra_conditions=None): + return self.get_queryset().for_user(user, perm, extra_conditions) class BaseOCModel(models.Model): + """ Base model for all OpenContracts models that has some properties it's nice to have on all models. """ + # this makes the queryset function readable_by_user() available which will filter properly on permissioning system. + objects = PermissionManager() + class Meta: abstract = True @@ -22,15 +46,19 @@ class Meta: null=True, blank=True, related_name="locked_%(class)s_objects", + db_index=True ) - # This should be set to true if a long-running job is set on a model (e.g. change permissions or delete) backend_lock = django.db.models.BooleanField(default=False) # Sharing is_public = django.db.models.BooleanField(default=False) creator = django.db.models.ForeignKey( - get_user_model(), on_delete=django.db.models.CASCADE, null=False, blank=False + get_user_model(), + on_delete=django.db.models.CASCADE, + null=False, + blank=False, + db_index=True ) # Timing variables diff --git a/opencontractserver/tasks/permissioning_tasks.py b/opencontractserver/tasks/permissioning_tasks.py index f1d27f17..8ba78824 100644 --- a/opencontractserver/tasks/permissioning_tasks.py +++ b/opencontractserver/tasks/permissioning_tasks.py @@ -1,11 +1,7 @@ # Copyright (C) 2022 John Scrudato from config import celery_app -from opencontractserver.utils.permissioning import ( - MakePublicReturnType, - make_analysis_public, - make_corpus_public, -) +from opencontractserver.utils.sharing import MakePublicReturnType, make_analysis_public, make_corpus_public @celery_app.task() diff --git a/opencontractserver/tests/test_permissioning.py b/opencontractserver/tests/test_permissioning.py index 2e87588e..cfa35ce8 100644 --- a/opencontractserver/tests/test_permissioning.py +++ b/opencontractserver/tests/test_permissioning.py @@ -26,11 +26,12 @@ make_corpus_public_task, ) from opencontractserver.types.enums import PermissionTypes -from opencontractserver.utils.permissioning import ( - get_users_permissions_for_obj, +# from config.graphql.permissioning.filters import ( +# filter_queryset_by_user_read_permission, +# ) +from opencontractserver.utils.permissioning import (get_users_permissions_for_obj, set_permissions_for_obj_to_user, - user_has_permission_for_obj, -) + user_has_permission_for_obj) from .fixtures import SAMPLE_PDF_FILE_ONE_PATH @@ -173,6 +174,84 @@ def setUp(self): analysis=self.analysis, ) + def __test_query_efficient_filtering(self): + def __test_query_efficient_filtering(self): + logger.info( + "----- TEST QUERY EFFICIENT FILTERING FOR USER READ PERMISSIONS ------------------------------------" + ) + + # Create additional test corpuses + for i in range(5): + with transaction.atomic(): + corpus = Corpus.objects.create( + title=f"Test Corpus {i}", creator=self.superuser, backend_lock=False + ) + + # Assign different permissions to different corpuses + if i % 3 == 0: + set_permissions_for_obj_to_user(self.user, corpus, [PermissionTypes.READ]) + elif i % 3 == 1: + set_permissions_for_obj_to_user(self.user_2, corpus, [PermissionTypes.READ]) + else: + corpus.is_public = True + corpus.save() + + # Test filtering for user 1 using the new PermissionQuerySet + all_corpuses = Corpus.objects.all() + + # Use the new 'for_user' method with 'read' permission + user1_readable_corpuses = Corpus.objects.for_user(self.user, perm='read') + + logger.info(f"User 1 can read {user1_readable_corpuses.count()} corpuses") + self.assertTrue(user1_readable_corpuses.count() > 0) + for corpus in user1_readable_corpuses: + self.assertTrue( + corpus.is_public or + user_has_permission_for_obj(self.user, corpus, PermissionTypes.READ) + ) + + # Test filtering for user 2 + user2_readable_corpuses = Corpus.objects.for_user(self.user_2, perm='read') + + logger.info(f"User 2 can read {user2_readable_corpuses.count()} corpuses") + self.assertTrue(user2_readable_corpuses.count() > 0) + for corpus in user2_readable_corpuses: + self.assertTrue( + corpus.is_public or + user_has_permission_for_obj(self.user_2, corpus, PermissionTypes.READ) + ) + + # Test filtering for superuser + superuser_readable_corpuses = Corpus.objects.for_user(self.superuser, perm='read') + + logger.info(f"Superuser can read {superuser_readable_corpuses.count()} corpuses") + self.assertEqual(superuser_readable_corpuses.count(), Corpus.objects.count()) + + # Test that the filtered querysets are different for different users + self.assertNotEqual(set(user1_readable_corpuses), set(user2_readable_corpuses)) + + # Test performance + import time + + # Measure time for the efficient filtering using 'for_user' method + start_time = time.time() + Corpus.objects.for_user(self.user, perm='read') + end_time = time.time() + + logger.info(f"Time taken for efficient filtering: {end_time - start_time} seconds") + + # Compare with a naive approach + start_time = time.time() + naive_filtered = [corpus for corpus in all_corpuses if + corpus.is_public or + user_has_permission_for_obj(self.user, corpus, PermissionTypes.READ)] + end_time = time.time() + + logger.info(f"Time taken for naive filtering: {end_time - start_time} seconds") + + # Assert that both methods return the same results + self.assertEqual(set(user1_readable_corpuses), set(naive_filtered)) + def __test_user_retrieval_permissions(self): logger.info( @@ -732,3 +811,4 @@ def test_permissions(self): self.__test_make_analysis_public_mutation() self.__test_make_analysis_public_task() self.__test_actual_analysis_deletion() + self.__test_query_efficient_filtering() diff --git a/opencontractserver/utils/permissioning.py b/opencontractserver/utils/permissioning.py index 2157d833..1c024870 100644 --- a/opencontractserver/utils/permissioning.py +++ b/opencontractserver/utils/permissioning.py @@ -2,7 +2,7 @@ import logging from functools import reduce -from typing import NoReturn, TypedDict +from typing import NoReturn import django from django.contrib.auth import get_user_model @@ -12,20 +12,10 @@ from django.db.models import Q from guardian.shortcuts import assign_perm -from config.graphql.permission_annotator.middleware import combine -from opencontractserver.analyzer.models import Analysis, Analyzer -from opencontractserver.annotations.models import ( - Annotation, - AnnotationLabel, - Relationship, -) -from opencontractserver.corpuses.models import Corpus, CorpusQuery -from opencontractserver.documents.models import Document, DocumentAnalysisRow -from opencontractserver.extracts.models import Datacell, Extract, Fieldset +from config.graphql.permissioning.permission_annotator.middleware import combine from opencontractserver.types.enums import PermissionTypes User = get_user_model() - logger = logging.getLogger(__name__) @@ -328,173 +318,3 @@ def user_has_permission_for_obj( return False -class MakePublicReturnType(TypedDict): - message: str - ok: bool - - -def make_analysis_public(analysis_id: int | str) -> MakePublicReturnType: - """ - Given an analysis ID, make it and its annotations public. If you do this on a - Corpus that is not itself public, the underlying docs and corpus (and thus - the analysis itself) can only be seen by those who have at least read permission to the - Corpus. In current iteration of OC (Sept 22), that basically means only admins - and the person who created it will see the annotations. Long story short, MAKE - THE CORPUS PUBLIC TOO USING A SEPARATE CALL. - """ - - ok = False - - try: - - analysis = Analysis.objects.get(id=analysis_id) - - # Lock the analysis as this can take a long time depending on the number of - # documents and annotations to change permissions for. - with transaction.atomic(): - analysis.is_public = True - analysis.backend_lock = True - analysis.save() - - corpus = analysis.analyzed_corpus - - # Bulk update the analyzers labels - labels = AnnotationLabel.objects.filter(analyzer=analysis.analyzer) - for label in labels: - label.is_public = True - AnnotationLabel.objects.bulk_update(labels, ["is_public"], batch_size=100) - - # Bulk update actual annotations - analyzer_annotations = corpus.annotations.filter(analysis_id=analysis_id) - for annotation in analyzer_annotations: - # logger.info(f"Make annotation public: {annotation}") - annotation.is_public = True - Annotation.objects.bulk_update( - analyzer_annotations, ["is_public"], batch_size=100 - ) - - with transaction.atomic(): - analysis.backend_lock = False - analysis.save() - - analysis.refresh_from_db() - - message = "SUCCESS - Analysis is Public" - ok = True - - except Exception as e: - message = f"ERROR - Could not make analysis public due to unexpected error: {e}" - - return {"message": message, "ok": ok} - - -def make_corpus_public(corpus_id: int | str) -> MakePublicReturnType: - """ - Given a corpus ID, make it, its labelset, its docs, human annotations, extracts, - analyses, datacells, fieldsets, analyzers and related objects public. - """ - ok = False - - try: - corpus = Corpus.objects.get(id=corpus_id) - logger.info(f"Retrieved corpus with id {corpus_id}") - - # Lock the corpus while we re-permission - with transaction.atomic(): - corpus.backend_lock = True - corpus.is_public = True - corpus.save() - logger.info(f"Locked and set corpus {corpus_id} as public") - - # Make documents public - docs = corpus.documents.all() - updated_docs = Document.objects.filter(id__in=docs).update(is_public=True) - logger.info(f"Made {updated_docs} documents public") - - # !!DANGER!! - Make ALL labels for this corpus public - if corpus.label_set: - corpus.label_set.is_public = True - corpus.label_set.save() - logger.info(f"Set label_set {corpus.label_set.id} as public") - - # Make labels public - updated_labels = AnnotationLabel.objects.filter( - included_in_labelset=corpus.label_set - ).update(is_public=True) - logger.info(f"Made {updated_labels} annotation labels public") - - # Make human annotations public - updated_annotations = Annotation.objects.filter(corpus=corpus).update( - is_public=True - ) - logger.info(f"Made {updated_annotations} human annotations public") - - # Make extracts public - updated_extracts = Extract.objects.filter(corpus=corpus).update(is_public=True) - logger.info(f"Made {updated_extracts} extracts public") - - # Make analyses public - analyses = Analysis.objects.filter(analyzed_corpus=corpus) - updated_analyses = Analysis.objects.filter(id__in=analyses).update( - is_public=True - ) - logger.info(f"Made {updated_analyses} analyses public") - - # Make datacells public - updated_datacells = Datacell.objects.filter(extract__corpus=corpus).update( - is_public=True - ) - logger.info(f"Made {updated_datacells} datacells public") - - # Make fieldsets public - fieldsets = Fieldset.objects.filter(extracts__corpus=corpus).distinct() - updated_fieldsets = Fieldset.objects.filter(id__in=fieldsets).update( - is_public=True - ) - logger.info(f"Made {updated_fieldsets} fieldsets public") - - # Make analyzers public - analyzers = Analyzer.objects.filter( - Q(analysis__analyzed_corpus=corpus) | Q(corpusaction__corpus=corpus) - ).distinct() - updated_analyzers = Analyzer.objects.filter(id__in=analyzers).update( - is_public=True - ) - logger.info(f"Made {updated_analyzers} analyzers public") - - # Make related objects public - # Relationships - updated_relationships = Relationship.objects.filter(corpus=corpus).update( - is_public=True - ) - logger.info(f"Made {updated_relationships} relationships public") - - # CorpusQueries - updated_queries = CorpusQuery.objects.filter(corpus=corpus).update( - is_public=True - ) - logger.info(f"Made {updated_queries} corpus queries public") - - # DocumentAnalysisRows - updated_rows = DocumentAnalysisRow.objects.filter( - Q(analysis__analyzed_corpus=corpus) | Q(extract__corpus=corpus) - ).update(is_public=True) - logger.info(f"Made {updated_rows} document analysis rows public") - - # Unlock the corpus - with transaction.atomic(): - corpus.backend_lock = False - corpus.save() - logger.info(f"Unlocked corpus {corpus_id}") - - corpus.refresh_from_db() - logger.info(f"Refreshed corpus {corpus_id} from database") - - message = "SUCCESS - Corpus and related objects are now public" - ok = True - - except Exception as e: - message = f"ERROR - Could not make public due to unexpected error: {e}" - logger.error(message, exc_info=True) - - return {"message": message, "ok": ok} diff --git a/opencontractserver/utils/sharing.py b/opencontractserver/utils/sharing.py new file mode 100644 index 00000000..cfb0fbae --- /dev/null +++ b/opencontractserver/utils/sharing.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import logging +from typing import TypedDict + +from django.contrib.auth import get_user_model +from django.db import transaction +from django.db.models import Q + +from opencontractserver.analyzer.models import Analysis, Analyzer +from opencontractserver.annotations.models import AnnotationLabel, Annotation, Relationship +from opencontractserver.corpuses.models import Corpus, CorpusQuery +from opencontractserver.documents.models import Document, DocumentAnalysisRow +from opencontractserver.extracts.models import Extract, Datacell, Fieldset + +User = get_user_model() +logger = logging.getLogger(__name__) + + +class MakePublicReturnType(TypedDict): + message: str + ok: bool + + +def make_analysis_public(analysis_id: int | str) -> MakePublicReturnType: + """ + Given an analysis ID, make it and its annotations public. If you do this on a + Corpus that is not itself public, the underlying docs and corpus (and thus + the analysis itself) can only be seen by those who have at least read permission to the + Corpus. In current iteration of OC (Sept 22), that basically means only admins + and the person who created it will see the annotations. Long story short, MAKE + THE CORPUS PUBLIC TOO USING A SEPARATE CALL. + """ + + ok = False + + try: + + analysis = Analysis.objects.get(id=analysis_id) + + # Lock the analysis as this can take a long time depending on the number of + # documents and annotations to change permissions for. + with transaction.atomic(): + analysis.is_public = True + analysis.backend_lock = True + analysis.save() + + corpus = analysis.analyzed_corpus + + # Bulk update the analyzers labels + labels = AnnotationLabel.objects.filter(analyzer=analysis.analyzer) + for label in labels: + label.is_public = True + AnnotationLabel.objects.bulk_update(labels, ["is_public"], batch_size=100) + + # Bulk update actual annotations + analyzer_annotations = corpus.annotations.filter(analysis_id=analysis_id) + for annotation in analyzer_annotations: + # logger.info(f"Make annotation public: {annotation}") + annotation.is_public = True + Annotation.objects.bulk_update( + analyzer_annotations, ["is_public"], batch_size=100 + ) + + with transaction.atomic(): + analysis.backend_lock = False + analysis.save() + + analysis.refresh_from_db() + + message = "SUCCESS - Analysis is Public" + ok = True + + except Exception as e: + message = f"ERROR - Could not make analysis public due to unexpected error: {e}" + + return {"message": message, "ok": ok} + + +def make_corpus_public(corpus_id: int | str) -> MakePublicReturnType: + """ + Given a corpus ID, make it, its labelset, its docs, human annotations, extracts, + analyses, datacells, fieldsets, analyzers and related objects public. + """ + ok = False + + try: + corpus = Corpus.objects.get(id=corpus_id) + logger.info(f"Retrieved corpus with id {corpus_id}") + + # Lock the corpus while we re-permission + with transaction.atomic(): + corpus.backend_lock = True + corpus.is_public = True + corpus.save() + logger.info(f"Locked and set corpus {corpus_id} as public") + + # Make documents public + docs = corpus.documents.all() + updated_docs = Document.objects.filter(id__in=docs).update(is_public=True) + logger.info(f"Made {updated_docs} documents public") + + # !!DANGER!! - Make ALL labels for this corpus public + if corpus.label_set: + corpus.label_set.is_public = True + corpus.label_set.save() + logger.info(f"Set label_set {corpus.label_set.id} as public") + + # Make labels public + updated_labels = AnnotationLabel.objects.filter( + included_in_labelset=corpus.label_set + ).update(is_public=True) + logger.info(f"Made {updated_labels} annotation labels public") + + # Make human annotations public + updated_annotations = Annotation.objects.filter(corpus=corpus).update( + is_public=True + ) + logger.info(f"Made {updated_annotations} human annotations public") + + # Make extracts public + updated_extracts = Extract.objects.filter(corpus=corpus).update(is_public=True) + logger.info(f"Made {updated_extracts} extracts public") + + # Make analyses public + analyses = Analysis.objects.filter(analyzed_corpus=corpus) + updated_analyses = Analysis.objects.filter(id__in=analyses).update( + is_public=True + ) + logger.info(f"Made {updated_analyses} analyses public") + + # Make datacells public + updated_datacells = Datacell.objects.filter(extract__corpus=corpus).update( + is_public=True + ) + logger.info(f"Made {updated_datacells} datacells public") + + # Make fieldsets public + fieldsets = Fieldset.objects.filter(extracts__corpus=corpus).distinct() + updated_fieldsets = Fieldset.objects.filter(id__in=fieldsets).update( + is_public=True + ) + logger.info(f"Made {updated_fieldsets} fieldsets public") + + # Make analyzers public + analyzers = Analyzer.objects.filter( + Q(analysis__analyzed_corpus=corpus) | Q(corpusaction__corpus=corpus) + ).distinct() + updated_analyzers = Analyzer.objects.filter(id__in=analyzers).update( + is_public=True + ) + logger.info(f"Made {updated_analyzers} analyzers public") + + # Make related objects public + # Relationships + updated_relationships = Relationship.objects.filter(corpus=corpus).update( + is_public=True + ) + logger.info(f"Made {updated_relationships} relationships public") + + # CorpusQueries + updated_queries = CorpusQuery.objects.filter(corpus=corpus).update( + is_public=True + ) + logger.info(f"Made {updated_queries} corpus queries public") + + # DocumentAnalysisRows + updated_rows = DocumentAnalysisRow.objects.filter( + Q(analysis__analyzed_corpus=corpus) | Q(extract__corpus=corpus) + ).update(is_public=True) + logger.info(f"Made {updated_rows} document analysis rows public") + + # Unlock the corpus + with transaction.atomic(): + corpus.backend_lock = False + corpus.save() + logger.info(f"Unlocked corpus {corpus_id}") + + corpus.refresh_from_db() + logger.info(f"Refreshed corpus {corpus_id} from database") + + message = "SUCCESS - Corpus and related objects are now public" + ok = True + + except Exception as e: + message = f"ERROR - Could not make public due to unexpected error: {e}" + logger.error(message, exc_info=True) + + return {"message": message, "ok": ok} diff --git a/requirements/base.txt b/requirements/base.txt index cfd43a48..c008f987 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -75,5 +75,5 @@ django-guardian # GraphQL # ------------------------------------------------------------------------------ -graphene-django==3.2.2 +graphene-django==3.0.0 # TODO - upgrade at some point. Will require some refactoring. django-graphql-jwt From c1913e7dbeda02de4773e764632df4b5823182c7 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Sun, 15 Sep 2024 07:56:46 -0700 Subject: [PATCH 08/18] Good progress wiring up the feedback mechanisms. --- .../annotator/context/AnnotationStore.ts | 11 +++ .../annotator/display/AnnotatorRenderer.tsx | 3 + .../src/components/annotator/display/Page.tsx | 5 +- .../annotator/display/Selection.tsx | 96 ++++++++----------- .../components/annotator/pages/PDFView.tsx | 6 +- .../widgets/modals/EditLabelModal.tsx | 6 ++ frontend/src/graphql/mutations.ts | 56 +++++++++++ frontend/src/graphql/queries.ts | 10 ++ frontend/src/graphql/types.ts | 30 ++++++ frontend/src/utils/transform.ts | 22 ++++- 10 files changed, 183 insertions(+), 62 deletions(-) diff --git a/frontend/src/components/annotator/context/AnnotationStore.ts b/frontend/src/components/annotator/context/AnnotationStore.ts index fb82425b..afcae68f 100644 --- a/frontend/src/components/annotator/context/AnnotationStore.ts +++ b/frontend/src/components/annotator/context/AnnotationStore.ts @@ -73,6 +73,9 @@ export class ServerAnnotation { public readonly structural: boolean, public readonly json: MultipageAnnotationJson, public readonly myPermissions: PermissionTypes[], + public readonly approved: boolean, + public readonly rejected: boolean, + public readonly canComment: boolean = false, id: string | undefined = undefined ) { this.id = id || uuidv4(); @@ -94,6 +97,9 @@ export class ServerAnnotation { delta.structural ?? this.structural, delta.json ?? this.json, delta.myPermissions ?? this.myPermissions, + delta.approved ?? this.approved, + delta.rejected ?? this.rejected, + delta.canComment ?? this.canComment, this.id ); } @@ -106,6 +112,9 @@ export class ServerAnnotation { obj.structural, obj.json, obj.myPermissions, + obj.approved, + obj.rejected, + obj.canComment, obj.id ); } @@ -268,6 +277,7 @@ interface _AnnotationStore { textSearchMatches: TextSearchResult[]; selectedTextSearchMatchIndex: number; searchText: string | undefined; + allowComment: boolean; toggleShowStructuralLabels: () => void; searchForText: (searchText: string) => void; advanceTextSearchMatch: () => void; @@ -333,6 +343,7 @@ export const AnnotationStore = createContext<_AnnotationStore>({ selectedTextSearchMatchIndex: 1, searchText: undefined, hideSidebar: false, + allowComment: false, setHideSidebar: () => { throw new Error("setHideSidebar - not implemented;"); }, diff --git a/frontend/src/components/annotator/display/AnnotatorRenderer.tsx b/frontend/src/components/annotator/display/AnnotatorRenderer.tsx index c635d75a..88d7b4c7 100644 --- a/frontend/src/components/annotator/display/AnnotatorRenderer.tsx +++ b/frontend/src/components/annotator/display/AnnotatorRenderer.tsx @@ -331,6 +331,9 @@ export const AnnotatorRenderer = ({ ? annotationObj.myPermissions : [] ), + false, + false, + false, annotationObj.id ) ); diff --git a/frontend/src/components/annotator/display/Page.tsx b/frontend/src/components/annotator/display/Page.tsx index 81a43d45..e33eb26a 100644 --- a/frontend/src/components/annotator/display/Page.tsx +++ b/frontend/src/components/annotator/display/Page.tsx @@ -285,8 +285,9 @@ export const Page = ({ pageInfo={pageInfo} annotation={annotation} setJumpedToAnnotationOnLoad={setJumpedToAnnotationOnLoad} - approved - // rejected + approved={annotation.approved} + rejected={annotation.rejected} + allowFeedback={annotationStore.allowComment} /> )); }, [ diff --git a/frontend/src/components/annotator/display/Selection.tsx b/frontend/src/components/annotator/display/Selection.tsx index 48478e40..dc3bd2ff 100644 --- a/frontend/src/components/annotator/display/Selection.tsx +++ b/frontend/src/components/annotator/display/Selection.tsx @@ -45,6 +45,7 @@ interface SelectionProps { approved?: boolean; rejected?: boolean; actions?: CloudButtonItem[]; + allowFeedback?: boolean; setJumpedToAnnotationOnLoad: (annot: string) => null | void; } @@ -59,6 +60,7 @@ export const Selection: React.FC = ({ children, approved, rejected, + allowFeedback, showInfo = true, setJumpedToAnnotationOnLoad, }) => { @@ -71,17 +73,34 @@ export const Selection: React.FC = ({ const label = annotation.annotationLabel; const color = label?.color || "#616a6b"; // grey as the default - const actions: CloudButtonItem[] = [ - { - name: "pencil", - color: "blue", - tooltip: "Edit Annotation", - onClick: () => { - console.log("Edit clicked"); - }, - protected_message: "Confirm shit", - }, - { + let actions: CloudButtonItem[] = []; + if (allowFeedback) { + if (!approved) { + actions.push({ + name: "thumbs up", + color: "green", + tooltip: "Upvote Annotation", + onClick: () => { + console.log("Edit clicked"); + }, + }); + } + if (!rejected) { + actions.push({ + name: "thumbs down", + color: "red", + tooltip: "Downvote Annotation", + onClick: () => { + console.log("Edit clicked"); + }, + }); + } + } + if ( + annotation.myPermissions.includes(PermissionTypes.CAN_REMOVE) && + !annotation.annotationLabel.readonly + ) { + actions.push({ name: "trash alternate outline", color: "red", tooltip: "Delete Annotation", @@ -89,57 +108,22 @@ export const Selection: React.FC = ({ console.log("Delete clicked"); }, protected_message: "Are you sure you want to delete this annotation?", - }, - { - name: "pencil", - color: "blue", - tooltip: "Edit Annotation", - onClick: () => { - console.log("Edit clicked"); - }, - }, - { - name: "trash alternate outline", - color: "red", - tooltip: "Delete Annotation", - onClick: () => { - console.log("Delete clicked"); - }, - }, - { - name: "pencil", - color: "blue", - tooltip: "Edit Annotation", - onClick: () => { - console.log("Edit clicked"); - }, - }, - { - name: "trash alternate outline", - color: "red", - tooltip: "Delete Annotation", - onClick: () => { - console.log("Delete clicked"); - }, - }, - { + }); + } + + if ( + annotation.myPermissions.includes(PermissionTypes.CAN_UPDATE) && + !annotation.annotationLabel.readonly + ) { + actions.push({ name: "pencil", color: "blue", tooltip: "Edit Annotation", onClick: () => { console.log("Edit clicked"); }, - }, - { - name: "trash alternate outline", - color: "red", - tooltip: "Delete Annotation", - onClick: () => { - console.log("Delete clicked"); - }, - }, - // Add more buttons as needed - ]; + }); + } const bounds = pageInfo.getScaledBounds( annotation.json[pageInfo.page.pageNumber - 1].bounds diff --git a/frontend/src/components/annotator/pages/PDFView.tsx b/frontend/src/components/annotator/pages/PDFView.tsx index a8e6da86..9562e9c3 100644 --- a/frontend/src/components/annotator/pages/PDFView.tsx +++ b/frontend/src/components/annotator/pages/PDFView.tsx @@ -639,7 +639,10 @@ export const PDFView = ({ PermissionTypes.CAN_CREATE, PermissionTypes.CAN_REMOVE, PermissionTypes.CAN_UPDATE, - ] + ], + false, + false, + true ) ); } @@ -658,6 +661,7 @@ export const PDFView = ({ > ; json?: MultipageAnnotationJson; @@ -219,6 +220,7 @@ export type CorpusType = Node & { __typename?: "CorpusType"; id: Scalars["ID"]; title?: Scalars["String"]; + allowComments?: boolean; appliedAnalyzerIds?: string[]; is_selected?: boolean; is_opened?: boolean; @@ -1357,3 +1359,31 @@ export interface AnalysisRowType extends Node { isPublished: Maybe; objectSharedWith: Maybe; } + +export type FeedbackTypeConnection = { + __typename?: "UserFeedbackTypeConnection"; + pageInfo: PageInfo; + edges: Array>; + totalCount?: Maybe; +}; + +export type FeedbackTypeEdge = { + __typename?: "UserFeedbackTypeEdge"; + node?: Maybe; + cursor: Scalars["String"]; +}; + +export interface FeedbackType extends Node { + id: Scalars["ID"]; + userLock: Maybe; + backendLock: Scalars["Boolean"]; + isPublic: Scalars["Boolean"]; + creator: UserType; + created: Scalars["DateTime"]; + modified: Scalars["DateTime"]; + approved?: Boolean; + rejected?: Boolean; + markdown?: string; + metadata: Record; + commented_annotation?: ServerAnnotationType | null; +} diff --git a/frontend/src/utils/transform.ts b/frontend/src/utils/transform.ts index e016c5f9..d0e7bd1a 100644 --- a/frontend/src/utils/transform.ts +++ b/frontend/src/utils/transform.ts @@ -89,8 +89,18 @@ export function convertToDocTypeAnnotation( } export function convertToServerAnnotation( - annotation: ServerAnnotationType + annotation: ServerAnnotationType, + allowComments?: boolean ): ServerAnnotation { + let approved = false; + let rejected = false; + if (annotation.userFeedback?.edges.length === 1) { + approved = + Boolean(annotation.userFeedback.edges[0]?.node?.approved) ?? false; + rejected = + Boolean(annotation.userFeedback.edges[0]?.node?.rejected) ?? false; + } + return new ServerAnnotation( annotation.page, annotation.annotationLabel, @@ -98,15 +108,21 @@ export function convertToServerAnnotation( annotation.structural ?? false, annotation.json ?? {}, annotation.myPermissions ?? [], + approved, + rejected, + allowComments !== undefined ? allowComments : false, annotation.id ); } // Helper function to convert an array of ServerAnnotationType to ServerAnnotation export function convertToServerAnnotations( - annotations: ServerAnnotationType[] + annotations: ServerAnnotationType[], + allowComments?: boolean ): ServerAnnotation[] { - return annotations.map(convertToServerAnnotation); + return annotations.map((annot) => + convertToServerAnnotation(annot, allowComments) + ); } export function hexToRgb(hex: string) { From b29650675f2c179db55001a74c79bb916700bef0 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Sun, 15 Sep 2024 14:00:29 -0700 Subject: [PATCH 09/18] Feedback controls and features working on frontend. --- config/graphql/permissioning/filters.py | 120 ------- .../graphene_django_filtering/objects.py | 299 ------------------ .../permissioned_connections.py | 149 --------- .../annotator/context/AnnotationStore.ts | 11 +- .../annotator/display/AnnotatorRenderer.tsx | 110 +++++++ .../annotator/display/Selection.tsx | 56 ++-- .../components/annotator/pages/PDFView.tsx | 11 +- .../src/components/widgets/CRUD/CRUDModal.tsx | 4 - frontend/src/graphql/cache.ts | 11 + frontend/src/graphql/mutations.ts | 12 +- opencontractserver/shared/Managers.py | 0 opencontractserver/shared/QuerySets.py | 52 +++ 12 files changed, 232 insertions(+), 603 deletions(-) delete mode 100644 config/graphql/permissioning/filters.py delete mode 100644 config/graphql/permissioning/graphene_django_filtering/objects.py delete mode 100644 config/graphql/permissioning/graphene_django_filtering/permissioned_connections.py create mode 100644 opencontractserver/shared/Managers.py create mode 100644 opencontractserver/shared/QuerySets.py diff --git a/config/graphql/permissioning/filters.py b/config/graphql/permissioning/filters.py deleted file mode 100644 index ba44f9ff..00000000 --- a/config/graphql/permissioning/filters.py +++ /dev/null @@ -1,120 +0,0 @@ -# import django -# from django.apps import apps -# from django.contrib.auth import get_user_model -# from django.contrib.contenttypes.models import ContentType -# from django.db.models import Prefetch, Q -# from guardian.mixins import UserObjectPermission -# from guardian.models import GroupObjectPermission -# from opencontractserver.utils.permissioning import get_users_group_ids, get_permission_id_to_name_map_for_model -# -# User = get_user_model() -# -# -# def filter_queryset_by_user_read_permission( -# queryset: django.db.models.QuerySet, -# user: User, -# include_group_permissions: bool = True -# ) -> django.db.models.QuerySet: -# if not queryset.exists(): -# return queryset.none() -# -# model = queryset.model -# model_name = model._meta.model_name -# app_label = model._meta.app_label -# -# content_type = ContentType.objects.get_for_model(model) -# permission_id_to_name_map = get_permission_id_to_name_map_for_model(model) -# read_permission_id = next( -# (k for k, v in permission_id_to_name_map.items() if v == f"read_{model_name}"), -# None -# ) -# -# if read_permission_id is None: -# return queryset.none() -# -# user_permission_model = apps.get_model(f'{app_label}.{model_name}userobjectpermission') -# -# print(f"Get permissions for content type {content_type}") -# -# user_perms_queryset = user_permission_model.objects.filter( -# content_object=queryset, -# user=user, -# permission_id=read_permission_id -# ) -# -# group_permission_model = -# group_perms_queryset = GroupObjectPermission.objects.none() -# if include_group_permissions: -# user_group_ids = get_users_group_ids(user) -# group_perms_queryset = GroupObjectPermission.objects.filter( -# content_type=content_type, -# group_id__in=user_group_ids, -# permission_id=read_permission_id -# ) -# -# queryset = queryset.prefetch_related( -# Prefetch(f'{model_name}userobjectpermission_set', queryset=user_perms_queryset, to_attr='user_read_perms'), -# Prefetch(f'{model_name}groupobjectpermission_set', queryset=group_perms_queryset, to_attr='group_read_perms') -# ) -# -# return queryset.filter( -# Q(is_public=True) | -# Q(**{f'{model_name}userobjectpermission__in': user_perms_queryset}) | -# Q(**{f'{model_name}groupobjectpermission__in': group_perms_queryset}) -# ).distinct() -# - -from django.db.models.query import QuerySet -from django.db.models import Q, Exists, OuterRef -from django.contrib.contenttypes.models import ContentType -from guardian.models import UserObjectPermission, GroupObjectPermission -from django.core.cache import cache - -class PermissionQuerySet(QuerySet): - def for_user(self, user, perm, extra_conditions=None): - model = self.model - cache_key = f'content_type_{model._meta.app_label}_{model._meta.model_name}' - content_type = cache.get(cache_key) - if content_type is None: - content_type = ContentType.objects.get_for_model(model) - cache.set(cache_key, content_type, 3600) # Cache for 1 hour - - permission_codename = f'{perm}_{model._meta.model_name}' - - # Subqueries for user and group permissions - user_perm_subquery = UserObjectPermission.objects.filter( - user=user, - content_type=content_type, - object_pk=OuterRef('pk'), - permission__codename=permission_codename - ).values('pk') - group_perm_subquery = GroupObjectPermission.objects.filter( - group__user=user, - content_type=content_type, - object_pk=OuterRef('pk'), - permission__codename=permission_codename - ).values('pk') - - # Annotate with permission checks - qs = self.annotate( - has_user_perm=Exists(user_perm_subquery), - has_group_perm=Exists(group_perm_subquery) - ) - - # Base condition: user has the permission - base_condition = Q(has_user_perm=True) | Q(has_group_perm=True) - - # Default extra conditions based on permission - if extra_conditions is None: - if perm == 'read': - extra_conditions = Q(is_public=True) | Q(creator=user) - elif perm == 'publish': - extra_conditions = Q(creator=user) - else: - extra_conditions = Q() - - # Combine conditions - final_condition = base_condition | extra_conditions - - # Apply the filter - return qs.filter(final_condition).distinct() diff --git a/config/graphql/permissioning/graphene_django_filtering/objects.py b/config/graphql/permissioning/graphene_django_filtering/objects.py deleted file mode 100644 index 08d536b7..00000000 --- a/config/graphql/permissioning/graphene_django_filtering/objects.py +++ /dev/null @@ -1,299 +0,0 @@ -import warnings -from collections import OrderedDict -from typing import Type - -import graphene -from django.db.models import Model -from graphene.relay import Connection, Node -from graphene.types.objecttype import ObjectType, ObjectTypeOptions -from graphene.types.utils import yank_fields_from_attrs -from graphene_django.converter import convert_django_field_with_choices - -from graphene_django.registry import Registry, get_global_registry -from graphene_django.settings import graphene_settings -from graphene_django.utils import ( - DJANGO_FILTER_INSTALLED, - camelize, - get_model_fields, - is_valid_django_model, -) - -ALL_FIELDS = "__all__" - - -def construct_fields( - model, registry, only_fields, exclude_fields, convert_choices_to_enum -): - _model_fields = get_model_fields(model) - - fields = OrderedDict() - for name, field in _model_fields: - is_not_in_only = ( - only_fields is not None - and only_fields != ALL_FIELDS - and name not in only_fields - ) - # is_already_created = name in options.fields - is_excluded = ( - exclude_fields is not None and name in exclude_fields - ) # or is_already_created - # https://docs.djangoproject.com/en/1.10/ref/models/fields/#django.db.models.ForeignKey.related_query_name - is_no_backref = str(name).endswith("+") - if is_not_in_only or is_excluded or is_no_backref: - # We skip this field if we specify only_fields and is not - # in there. Or when we exclude this field in exclude_fields. - # Or when there is no back reference. - continue - - _convert_choices_to_enum = convert_choices_to_enum - if not isinstance(_convert_choices_to_enum, bool): - # then `convert_choices_to_enum` is a list of field names to convert - if name in _convert_choices_to_enum: - _convert_choices_to_enum = True - else: - _convert_choices_to_enum = False - - converted = convert_django_field_with_choices( - field, registry, convert_choices_to_enum=_convert_choices_to_enum - ) - fields[name] = converted - - return fields - - -def validate_fields(type_, model, fields, only_fields, exclude_fields): - # Validate the given fields against the model's fields and custom fields - all_field_names = set(fields.keys()) - only_fields = only_fields if only_fields is not ALL_FIELDS else () - for name in only_fields or (): - if name in all_field_names: - continue - - if hasattr(model, name): - warnings.warn( - ( - 'Field name "{field_name}" matches an attribute on Django model "{app_label}.{object_name}" ' - "but it's not a model field so Graphene cannot determine what type it should be. " - 'Either define the type of the field on DjangoObjectType "{type_}" or remove it from the "fields" list.' - ).format( - field_name=name, - app_label=model._meta.app_label, - object_name=model._meta.object_name, - type_=type_, - ) - ) - - else: - warnings.warn( - ( - 'Field name "{field_name}" doesn\'t exist on Django model "{app_label}.{object_name}". ' - 'Consider removing the field from the "fields" list of DjangoObjectType "{type_}" because it has no effect.' - ).format( - field_name=name, - app_label=model._meta.app_label, - object_name=model._meta.object_name, - type_=type_, - ) - ) - - # Validate exclude fields - for name in exclude_fields or (): - if name in all_field_names: - # Field is a custom field - warnings.warn( - ( - 'Excluding the custom field "{field_name}" on DjangoObjectType "{type_}" has no effect. ' - 'Either remove the custom field or remove the field from the "exclude" list.' - ).format(field_name=name, type_=type_) - ) - else: - if not hasattr(model, name): - warnings.warn( - ( - 'Django model "{app_label}.{object_name}" does not have a field or attribute named "{field_name}". ' - 'Consider removing the field from the "exclude" list of DjangoObjectType "{type_}" because it has no effect' - ).format( - field_name=name, - app_label=model._meta.app_label, - object_name=model._meta.object_name, - type_=type_, - ) - ) - - -class DjangoObjectTypeOptions(ObjectTypeOptions): - model = None # type: Type[Model] - registry = None # type: Registry - connection = None # type: Type[Connection] - - filter_fields = () - filterset_class = None - -# TODO - confirm we actually may not need this... since I *think* we hooked into graphene's conversions and are -# replacing ConnectionFields - -class DjangoObjectType(ObjectType): - @classmethod - def __init_subclass_with_meta__( - cls, - model=None, - registry=None, - skip_registry=False, - only_fields=None, # deprecated in favour of `fields` - fields=None, - exclude_fields=None, # deprecated in favour of `exclude` - exclude=None, - filter_fields=None, - filterset_class=None, - connection=None, - connection_class=None, - use_connection=None, - interfaces=(), - convert_choices_to_enum=True, - _meta=None, - **options - ): - assert is_valid_django_model(model), ( - 'You need to pass a valid Django Model in {}.Meta, received "{}".' - ).format(cls.__name__, model) - - if not registry: - registry = get_global_registry() - - assert isinstance(registry, Registry), ( - "The attribute registry in {} needs to be an instance of " - 'Registry, received "{}".' - ).format(cls.__name__, registry) - - if filter_fields and filterset_class: - raise Exception("Can't set both filter_fields and filterset_class") - - if not DJANGO_FILTER_INSTALLED and (filter_fields or filterset_class): - raise Exception( - ( - "Can only set filter_fields or filterset_class if " - "Django-Filter is installed" - ) - ) - - assert not (fields and exclude), ( - "Cannot set both 'fields' and 'exclude' options on " - "DjangoObjectType {class_name}.".format(class_name=cls.__name__) - ) - - # Alias only_fields -> fields - if only_fields and fields: - raise Exception("Can't set both only_fields and fields") - if only_fields: - warnings.warn( - "Defining `only_fields` is deprecated in favour of `fields`.", - DeprecationWarning, - stacklevel=2, - ) - fields = only_fields - if fields and fields != ALL_FIELDS and not isinstance(fields, (list, tuple)): - raise TypeError( - 'The `fields` option must be a list or tuple or "__all__". ' - "Got %s." % type(fields).__name__ - ) - - # Alias exclude_fields -> exclude - if exclude_fields and exclude: - raise Exception("Can't set both exclude_fields and exclude") - if exclude_fields: - warnings.warn( - "Defining `exclude_fields` is deprecated in favour of `exclude`.", - DeprecationWarning, - stacklevel=2, - ) - exclude = exclude_fields - if exclude and not isinstance(exclude, (list, tuple)): - raise TypeError( - "The `exclude` option must be a list or tuple. Got %s." - % type(exclude).__name__ - ) - - if fields is None and exclude is None: - warnings.warn( - "Creating a DjangoObjectType without either the `fields` " - "or the `exclude` option is deprecated. Add an explicit `fields " - "= '__all__'` option on DjangoObjectType {class_name} to use all " - "fields".format(class_name=cls.__name__), - DeprecationWarning, - stacklevel=2, - ) - - # TODO - this chain needs modification - django_fields = yank_fields_from_attrs( - construct_fields(model, registry, fields, exclude, convert_choices_to_enum), - _as=graphene.Field, - ) - print(f"django fields: {django_fields}") - - if use_connection is None and interfaces: - use_connection = any( - (issubclass(interface, Node) for interface in interfaces) - ) - - if use_connection and not connection: - # We create the connection automatically - if not connection_class: - connection_class = Connection - - connection = connection_class.create_type( - "{}Connection".format(options.get("name") or cls.__name__), node=cls - ) - - if connection is not None: - assert issubclass(connection, Connection), ( - "The connection must be a Connection. Received {}" - ).format(connection.__name__) - - if not _meta: - _meta = DjangoObjectTypeOptions(cls) - - _meta.model = model - _meta.registry = registry - _meta.filter_fields = filter_fields - _meta.filterset_class = filterset_class - _meta.fields = django_fields - _meta.connection = connection - - super(DjangoObjectType, cls).__init_subclass_with_meta__( - _meta=_meta, interfaces=interfaces, **options - ) - - # Validate fields - validate_fields(cls, model, _meta.fields, fields, exclude) - - if not skip_registry: - registry.register(cls) - - def resolve_id(self, info): - return self.pk - - @classmethod - def is_type_of(cls, root, info): - if isinstance(root, cls): - return True - if not is_valid_django_model(root.__class__): - raise Exception(('Received incompatible instance "{}".').format(root)) - - if cls._meta.model._meta.proxy: - model = root._meta.model - else: - model = root._meta.model._meta.concrete_model - - return model == cls._meta.model - - @classmethod - def get_queryset(cls, queryset, info): - return queryset - - @classmethod - def get_node(cls, info, id): - queryset = cls.get_queryset(cls._meta.model.objects, info) - try: - return queryset.get(pk=id) - except cls._meta.model.DoesNotExist: - return None diff --git a/config/graphql/permissioning/graphene_django_filtering/permissioned_connections.py b/config/graphql/permissioning/graphene_django_filtering/permissioned_connections.py deleted file mode 100644 index 41fce900..00000000 --- a/config/graphql/permissioning/graphene_django_filtering/permissioned_connections.py +++ /dev/null @@ -1,149 +0,0 @@ -from collections import OrderedDict - -import graphene -from graphene_django import DjangoObjectType -from graphene_django.registry import Registry, get_global_registry -from graphene_django.types import ALL_FIELDS -from graphene_django.utils import is_valid_django_model, DJANGO_FILTER_INSTALLED, get_model_fields -from graphene.types.utils import yank_fields_from_attrs -from graphene.types.objecttype import ObjectType, ObjectTypeOptions -from graphene.relay import Connection, Node -from django.core.exceptions import ImproperlyConfigured - -from .converter import convert_django_field_with_choices - - -class CustomDjangoObjectTypeOptions(ObjectTypeOptions): - model = None - registry = None - connection = None - filter_fields = () - filterset_class = None - - -class CustomDjangoObjectType(DjangoObjectType): - @classmethod - def __init_subclass_with_meta__( - cls, - model=None, - registry=None, - skip_registry=False, - only_fields=None, - fields=None, - exclude_fields=None, - exclude=None, - filter_fields=None, - filterset_class=None, - connection=None, - connection_class=None, - use_connection=None, - interfaces=(), - convert_choices_to_enum=True, - _meta=None, - **options - ): - assert is_valid_django_model(model), ( - 'You need to pass a valid Django Model in {}.Meta, received "{}".' - ).format(cls.__name__, model) - - if not registry: - registry = get_global_registry() - - assert isinstance(registry, Registry), ( - "The attribute registry in {} needs to be an instance of " - 'Registry, received "{}".' - ).format(cls.__name__, registry) - - if filter_fields and filterset_class: - raise ImproperlyConfigured( - "Can't set both filter_fields and filterset_class" - ) - - if not DJANGO_FILTER_INSTALLED and (filter_fields or filterset_class): - raise ImproperlyConfigured( - "Can only set filter_fields or filterset_class if " - "Django-Filter is installed" - ) - - assert not (fields and exclude), ( - "Cannot set both 'fields' and 'exclude' options on " - "DjangoObjectType {}.".format(cls.__name__) - ) - - if fields and fields != ALL_FIELDS and not isinstance(fields, (list, tuple)): - raise TypeError( - 'The `fields` option must be a list or tuple or "__all__". ' - "Got {}".format(type(fields).__name__) - ) - - if exclude and not isinstance(exclude, (list, tuple)): - raise TypeError( - "The `exclude` option must be a list or tuple. Got {}".format( - type(exclude).__name__ - ) - ) - - django_fields = cls.construct_fields(model, registry, fields, exclude, convert_choices_to_enum) - - if use_connection is None and interfaces: - use_connection = any(issubclass(interface, Node) for interface in interfaces) - - if use_connection and not connection: - # We create the connection automatically - if not connection_class: - connection_class = Connection - - connection = connection_class.create_type( - "{}Connection".format(options.get("name") or cls.__name__), - node=cls, - ) - - if connection is not None: - assert issubclass(connection, Connection), ( - "The connection must be a Connection. Received {}" - ).format(connection.__name__) - - _meta = CustomDjangoObjectTypeOptions(cls) - _meta.model = model - _meta.registry = registry - _meta.filter_fields = filter_fields - _meta.filterset_class = filterset_class - _meta.fields = django_fields - _meta.connection = connection - - super(DjangoObjectType, cls).__init_subclass_with_meta__( - _meta=_meta, interfaces=interfaces, **options - ) - - if not skip_registry: - registry.register(cls) - - @classmethod - def construct_fields(cls, model, registry, fields, exclude, convert_choices_to_enum): - _model_fields = get_model_fields(model) - - fields_dict = OrderedDict() - for name, field in _model_fields: - is_not_in_only = ( - fields is not None - and fields != ALL_FIELDS - and name not in fields - ) - is_excluded = exclude is not None and name in exclude - is_no_backref = str(name).endswith("+") - if is_not_in_only or is_excluded or is_no_backref: - continue - - _convert_choices_to_enum = convert_choices_to_enum - if isinstance(_convert_choices_to_enum, (list, tuple)): - if name in _convert_choices_to_enum: - _convert_choices_to_enum = True - else: - _convert_choices_to_enum = False - - converted = convert_django_field_with_choices( - field, registry, convert_choices_to_enum=_convert_choices_to_enum - ) - fields_dict[name] = converted - - return yank_fields_from_attrs(fields_dict, _as=graphene.Field) diff --git a/frontend/src/components/annotator/context/AnnotationStore.ts b/frontend/src/components/annotator/context/AnnotationStore.ts index afcae68f..92a9e18c 100644 --- a/frontend/src/components/annotator/context/AnnotationStore.ts +++ b/frontend/src/components/annotator/context/AnnotationStore.ts @@ -308,7 +308,8 @@ interface _AnnotationStore { createDocTypeAnnotation: (dt: DocTypeAnnotation) => void; deleteDocTypeAnnotation: (doc_annotation_id: string) => void; - + approveAnnotation: (annot_id: string, comment?: string) => void; + rejectAnnotation: (annot_id: string, comment?: string) => void; createRelation: (r: RelationGroup) => void; deleteRelation: (relation_id: string) => void; removeAnnotationFromRelation: ( @@ -344,8 +345,14 @@ export const AnnotationStore = createContext<_AnnotationStore>({ searchText: undefined, hideSidebar: false, allowComment: false, + approveAnnotation: (annot_id: string, comment?: string) => { + throw new Error("approveAnnotation- not implemented"); + }, + rejectAnnotation: (annot_id: string, comment?: string) => { + throw new Error("approveAnnotation- not implemented"); + }, setHideSidebar: () => { - throw new Error("setHideSidebar - not implemented;"); + throw new Error("setHideSidebar - not implemented"); }, toggleShowStructuralLabels: () => { throw new Error("toggleShowStructuralLabels() - not implemented"); diff --git a/frontend/src/components/annotator/display/AnnotatorRenderer.tsx b/frontend/src/components/annotator/display/AnnotatorRenderer.tsx index 88d7b4c7..7a842779 100644 --- a/frontend/src/components/annotator/display/AnnotatorRenderer.tsx +++ b/frontend/src/components/annotator/display/AnnotatorRenderer.tsx @@ -1,12 +1,18 @@ import { useMutation } from "@apollo/client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + APPROVE_ANNOTATION, + ApproveAnnotationInput, + ApproveAnnotationOutput, NewAnnotationInputType, NewAnnotationOutputType, NewDocTypeAnnotationInputType, NewDocTypeAnnotationOutputType, NewRelationshipInputType as NewRelationInputType, NewRelationshipOutputType as NewRelationOutputType, + REJECT_ANNOTATION, + RejectAnnotationInput, + RejectAnnotationOutput, RemoveAnnotationInputType, RemoveAnnotationOutputType, RemoveRelationshipInputType, @@ -726,6 +732,108 @@ export const AnnotatorRenderer = ({ } }; + const [approveAnnotationMutation] = useMutation< + ApproveAnnotationOutput, + ApproveAnnotationInput + >(APPROVE_ANNOTATION); + const approveAnnotation = (annotationId: string, comment?: string) => { + approveAnnotationMutation({ + variables: { annotationId, comment }, + update: (cache, { data }) => { + if (data?.approveAnnotation?.ok) { + const userFeedback = data.approveAnnotation.userFeedback; + + // Update Apollo cache + cache.modify({ + id: cache.identify({ + __typename: "AnnotationType", + id: annotationId, + }), + fields: { + userFeedback(existingFeedback = []) { + return [...existingFeedback.edges, { node: userFeedback }]; + }, + }, + }); + + // Update local PdfAnnotations state + setPdfAnnotations((prevState) => { + const updatedAnnotations = prevState.annotations.map((a) => { + if (a.id === annotationId) { + const updatedServerAnnotation = a.update({ + approved: true, + rejected: false, + }); + return updatedServerAnnotation; + } + return a; + }); + return new PdfAnnotations( + updatedAnnotations, + prevState.relations, + prevState.docTypes, + true + ); + }); + } + }, + }).catch((error) => { + console.error("Error approving annotation:", error); + toast.error("Failed to approve annotation"); + }); + }; + + const [rejectAnnotationMutation] = useMutation< + RejectAnnotationOutput, + RejectAnnotationInput + >(REJECT_ANNOTATION); + const rejectAnnotation = (annotationId: string, comment?: string) => { + rejectAnnotationMutation({ + variables: { annotationId, comment }, + update: (cache, { data }) => { + if (data?.rejectAnnotation?.ok) { + const userFeedback = data.rejectAnnotation.userFeedback; + + // Update Apollo cache + cache.modify({ + id: cache.identify({ + __typename: "AnnotationType", + id: annotationId, + }), + fields: { + userFeedback(existingFeedback = []) { + return [...existingFeedback.edges, { node: userFeedback }]; + }, + }, + }); + + // Update local PdfAnnotations state + setPdfAnnotations((prevState) => { + const updatedAnnotations = prevState.annotations.map((a) => { + if (a.id === annotationId) { + const updatedServerAnnotation = a.update({ + approved: false, + rejected: true, + }); + return updatedServerAnnotation; + } + return a; + }); + return new PdfAnnotations( + updatedAnnotations, + prevState.relations, + prevState.docTypes, + true + ); + }); + } + }, + }).catch((error) => { + console.error("Error rejecting annotation:", error); + toast.error("Failed to reject annotation"); + }); + }; + function removeRelation(relationshipId: string): RelationGroup[] { return pdfAnnotations.relations.filter((rel) => rel.id !== relationshipId); } @@ -891,6 +999,8 @@ export const AnnotatorRenderer = ({ createDocTypeAnnotation={requestCreateDocTypeAnnotation} deleteAnnotation={requestDeleteAnnotation} updateAnnotation={requestUpdateAnnotation} + approveAnnotation={approveAnnotation} + rejectAnnotation={rejectAnnotation} deleteRelation={requestDeleteRelation} removeAnnotationFromRelation={requestRemoveAnnotationFromRelationship} deleteDocTypeAnnotation={requestDeleteDocTypeAnnotation} diff --git a/frontend/src/components/annotator/display/Selection.tsx b/frontend/src/components/annotator/display/Selection.tsx index dc3bd2ff..7554c747 100644 --- a/frontend/src/components/annotator/display/Selection.tsx +++ b/frontend/src/components/annotator/display/Selection.tsx @@ -29,6 +29,8 @@ import RadialButtonCloud, { } from "../../widgets/buttons/RadialButtonCloud"; import { SelectionTokenGroup } from "./SelectionTokenGroup"; import { EditLabelModal } from "../../widgets/modals/EditLabelModal"; +import { useReactiveVar } from "@apollo/client"; +import { authToken } from "../../../graphql/cache"; interface SelectionProps { selectionRef: @@ -64,6 +66,7 @@ export const Selection: React.FC = ({ showInfo = true, setJumpedToAnnotationOnLoad, }) => { + const auth_token = useReactiveVar(authToken); const [hovered, setHovered] = useState(false); const [isEditLabelModalVisible, setIsEditLabelModalVisible] = useState(false); const [cloudVisible, setCloudVisible] = useState(false); @@ -74,28 +77,41 @@ export const Selection: React.FC = ({ const color = label?.color || "#616a6b"; // grey as the default let actions: CloudButtonItem[] = []; - if (allowFeedback) { - if (!approved) { - actions.push({ - name: "thumbs up", - color: "green", - tooltip: "Upvote Annotation", - onClick: () => { - console.log("Edit clicked"); - }, - }); - } - if (!rejected) { - actions.push({ - name: "thumbs down", - color: "red", - tooltip: "Downvote Annotation", - onClick: () => { - console.log("Edit clicked"); - }, - }); + + if (auth_token) { + if (allowFeedback) { + if (!approved) { + actions.push({ + name: "thumbs up", + color: "green", + tooltip: "Upvote Annotation", + onClick: () => { + annotationStore.approveAnnotation(annotation.id); + }, + }); + } + if (!rejected) { + actions.push({ + name: "thumbs down", + color: "red", + tooltip: "Downvote Annotation", + onClick: () => { + annotationStore.rejectAnnotation(annotation.id); + }, + }); + } } + } else { + actions.push({ + name: "question", + color: "red", + tooltip: "Login to see available actions!", + onClick: () => { + window.alert("Login to leave feedback and see other actions!"); + }, + }); } + if ( annotation.myPermissions.includes(PermissionTypes.CAN_REMOVE) && !annotation.annotationLabel.readonly diff --git a/frontend/src/components/annotator/pages/PDFView.tsx b/frontend/src/components/annotator/pages/PDFView.tsx index 9562e9c3..774e20af 100644 --- a/frontend/src/components/annotator/pages/PDFView.tsx +++ b/frontend/src/components/annotator/pages/PDFView.tsx @@ -104,6 +104,8 @@ export const PDFView = ({ selected_extract, editMode, allowInput, + approveAnnotation, + rejectAnnotation, setAllowInput, setEditMode, onSelectAnalysis, @@ -164,6 +166,8 @@ export const PDFView = ({ onSelectExtract: (extract: ExtractType | null) => undefined | null | void; createAnnotation: (added_annotation_obj: ServerAnnotation) => void; updateAnnotation: (updated_annotation: ServerAnnotation) => void; + approveAnnotation: (annot_id: string, comment?: string) => void; + rejectAnnotation: (annot_id: string, comment?: string) => void; createDocTypeAnnotation: (doc_type_annotation_obj: DocTypeAnnotation) => void; deleteAnnotation: (annotation_id: string) => void; deleteRelation: (relation_id: string) => void; @@ -277,11 +281,6 @@ export const PDFView = ({ ]; } - const handleActionSelect = (action: string) => { - // Handle action selection - console.log("Selected action:", action); - }; - const addSpanLabelsToViewSelection = (ls: AnnotationLabelType[]) => { setSpanLabelsToView([...spanLabelsToView, ...ls]); }; @@ -668,6 +667,8 @@ export const PDFView = ({ searchText, hideSidebar, setHideSidebar, + approveAnnotation, + rejectAnnotation, textSearchMatches: textSearchMatches ? textSearchMatches : [], searchForText: setSearchText, selectedTextSearchMatchIndex, diff --git a/frontend/src/components/widgets/CRUD/CRUDModal.tsx b/frontend/src/components/widgets/CRUD/CRUDModal.tsx index b2d9bf09..1d20713b 100644 --- a/frontend/src/components/widgets/CRUD/CRUDModal.tsx +++ b/frontend/src/components/widgets/CRUD/CRUDModal.tsx @@ -40,10 +40,6 @@ export function CRUDModal({ const can_write = mode !== "VIEW" && (mode === "CREATE" || mode === "EDIT"); - // console.log("---- CRUD MODAL ----"); - // console.log("oldInstance", oldInstance); - // console.log("instance_obj", instance_obj); - useEffect(() => { console.log("CRUD updated fields obj", updated_fields_obj); }, [updated_fields_obj]); diff --git a/frontend/src/graphql/cache.ts b/frontend/src/graphql/cache.ts index 55919678..844e2b6b 100644 --- a/frontend/src/graphql/cache.ts +++ b/frontend/src/graphql/cache.ts @@ -122,11 +122,22 @@ export const cache = new InMemoryCache({ }, }, }, + ServerAnnotationType: { + fields: { + userFeedback: mergeArrayByIdFieldPolicy, + }, + }, + UserFeedbackType: { + fields: { + // You can add specific field policies for UserFeedbackType if needed + }, + }, Query: { fields: { annotations: relayStylePagination( ContextAwareRelayStylePaginationKeyArgsFunction ), + userFeedback: relayStylePagination(), pageAnnotations: { keyArgs: [ "pdfPageInfo", diff --git a/frontend/src/graphql/mutations.ts b/frontend/src/graphql/mutations.ts index 789a5832..9d607f39 100644 --- a/frontend/src/graphql/mutations.ts +++ b/frontend/src/graphql/mutations.ts @@ -1610,13 +1610,17 @@ export interface RejectAnnotationInput { } export interface ApproveAnnotationOutput { - ok: boolean; - userFeedback: FeedbackType | null; + approveAnnotation: { + ok: boolean; + userFeedback: FeedbackType | null; + }; } export interface RejectAnnotationOutput { - ok: boolean; - userFeedback: FeedbackType | null; + rejectAnnotation: { + ok: boolean; + userFeedback: FeedbackType | null; + }; } // Mutations diff --git a/opencontractserver/shared/Managers.py b/opencontractserver/shared/Managers.py new file mode 100644 index 00000000..e69de29b diff --git a/opencontractserver/shared/QuerySets.py b/opencontractserver/shared/QuerySets.py new file mode 100644 index 00000000..c724ead9 --- /dev/null +++ b/opencontractserver/shared/QuerySets.py @@ -0,0 +1,52 @@ +from django.db import models +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from guardian.models import UserObjectPermission, GroupObjectPermission +from django.db.models import Q, Exists, OuterRef + +User = get_user_model() + + +class PermissionQuerySet(models.QuerySet): + def for_user(self, user, perm): + model = self.model + content_type = ContentType.objects.get_for_model(model) + + # Determine the permission codename + permission_codename = f'{perm}_{model._meta.model_name}' + + # User permission subquery + user_perm = UserObjectPermission.objects.filter( + content_type=content_type, + user=user, + permission__codename=permission_codename, + object_pk=OuterRef('pk') + ) + + # Group permission subquery + group_perm = GroupObjectPermission.objects.filter( + content_type=content_type, + group__user=user, + permission__codename=permission_codename, + object_pk=OuterRef('pk') + ) + + # Construct the base queryset + queryset = self.annotate( + has_user_perm=Exists(user_perm), + has_group_perm=Exists(group_perm) + ) + + # Filter based on permissions and public status - TODO - make this work for user/obj instance level sharing + # permission_filter = Q(has_user_perm=True) | Q(has_group_perm=True) | Q(is_public=True) + permission_filter = Q(is_public=True) | Q(creator=user) + + # # Add extra conditions based on permission type + # if perm == 'read': + # # For read permission, include objects created by the user + # permission_filter |= Q(creator=user) + # elif perm == 'publish': + # # For publish permission, only include objects created by the user + # permission_filter &= Q(creator=user) + + return queryset.filter(permission_filter).distinct() From bc21fe98fd8cc4fa2c962eaead0715ed59bd412c Mon Sep 17 00:00:00 2001 From: JSv4 Date: Sun, 15 Sep 2024 14:21:20 -0700 Subject: [PATCH 10/18] Found the 'graphene_django preferred' way to do permission filtering. --- config/graphql/custom_connections.py | 51 +++--- config/graphql/graphene_types.py | 5 + config/graphql/mutations.py | 84 ++++++++- .../graphene_django_filtering/converters.py | 15 +- config/settings/base.py | 2 +- .../migrations/0013_corpus_allow_comments.py | 18 ++ .../migrations/0014_alter_corpus_label_set.py | 30 ++++ opencontractserver/corpuses/models.py | 4 +- .../0002_alter_userfeedback_options.py | 26 +++ ...alter_userfeedback_commented_annotation.py | 29 +++ ...ission_userfeedbackuserobjectpermission.py | 20 +++ opencontractserver/feedback/models.py | 28 ++- opencontractserver/shared/Managers.py | 41 +++++ opencontractserver/shared/Models.py | 14 +- opencontractserver/shared/QuerySets.py | 94 +++++++--- .../tests/test_permissioning.py | 165 +++++++++++++++++- 16 files changed, 556 insertions(+), 70 deletions(-) create mode 100644 opencontractserver/corpuses/migrations/0013_corpus_allow_comments.py create mode 100644 opencontractserver/corpuses/migrations/0014_alter_corpus_label_set.py create mode 100644 opencontractserver/feedback/migrations/0002_alter_userfeedback_options.py create mode 100644 opencontractserver/feedback/migrations/0003_alter_userfeedback_commented_annotation.py create mode 100644 opencontractserver/feedback/migrations/0004_rename_userfeedbackobjectpermission_userfeedbackuserobjectpermission.py diff --git a/config/graphql/custom_connections.py b/config/graphql/custom_connections.py index 0a96271c..f9ca3094 100644 --- a/config/graphql/custom_connections.py +++ b/config/graphql/custom_connections.py @@ -64,34 +64,41 @@ def args(self, args): def resolve_queryset( cls, connection, iterable, info, args, filtering_args, filterset_class ): - def filter_kwargs(): - kwargs = {} - for k, v in args.items(): - if k in filtering_args: - if k == "order_by" and v is not None: - v = to_snake_case(v) - kwargs[k] = convert_enum(v) - return kwargs + # def filter_kwargs(): + # kwargs = {} + # for k, v in args.items(): + # if k in filtering_args: + # if k == "order_by" and v is not None: + # v = to_snake_case(v) + # kwargs[k] = convert_enum(v) + # return kwargs qs = super(DjangoFilterConnectionField, cls).resolve_queryset( connection, iterable, info, args ) - filterset = filterset_class( - data=filter_kwargs(), queryset=qs, request=info.context - ) + if hasattr(qs, 'visible_to_user'): + print(f"Custom connection... we are looking at permissioned model with visible_to_user(...) " + f"function... use it.") + qs = qs.visible_to_user(user=info.context.user) + + return qs - if filterset.is_valid(): - qs = filterset.qs - # Apply permission filtering - model = qs.model - user = info.context.user - if hasattr(model, 'get_queryset'): - qs = model.get_queryset(qs, user) - elif hasattr(model, 'objects') and hasattr(model.objects, 'get_queryset'): - qs = model.objects.get_queryset(qs, user) - return qs - raise ValidationError(filterset.form.errors.as_json()) + # filterset = filterset_class( + # data=filter_kwargs(), queryset=qs, request=info.context + # ) + # + # if filterset.is_valid(): + # qs = filterset.qs + # # Apply permission filtering + # model = qs.model + # user = info.context.user + # if hasattr(model, 'get_queryset'): + # qs = model.get_queryset(qs, user) + # elif hasattr(model, 'objects') and hasattr(model.objects, 'get_queryset'): + # qs = model.objects.get_queryset(qs, user) + # return qs + # raise ValidationError(filterset.form.errors.as_json()) def get_queryset_resolver(self): return partial( diff --git a/config/graphql/graphene_types.py b/config/graphql/graphene_types.py index 7afbb62a..3c5cab37 100644 --- a/config/graphql/graphene_types.py +++ b/config/graphql/graphene_types.py @@ -459,3 +459,8 @@ class Meta: model = UserFeedback interfaces = [relay.Node] connection_class = CountableConnection + + # https://docs.graphene-python.org/projects/django/en/latest/queries/#default-queryset + @classmethod + def get_queryset(cls, queryset, info): + return queryset.objects.visible_to_user(info.context.user) diff --git a/config/graphql/mutations.py b/config/graphql/mutations.py index 089b77f9..d5aa05e0 100644 --- a/config/graphql/mutations.py +++ b/config/graphql/mutations.py @@ -7,6 +7,7 @@ import graphql_jwt from celery import chain, chord, group from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist from django.core.files.base import ContentFile from django.db import transaction from django.db.models import Q @@ -33,7 +34,7 @@ RelationInputType, RelationshipType, UserExportType, - UserType, + UserType, UserFeedbackType, ) from config.graphql.serializers import ( AnnotationLabelSerializer, @@ -53,6 +54,7 @@ from opencontractserver.corpuses.models import Corpus, CorpusQuery, TemporaryFileHandle from opencontractserver.documents.models import Document from opencontractserver.extracts.models import Column, Datacell, Extract, Fieldset +from opencontractserver.feedback.models import UserFeedback from opencontractserver.tasks import ( build_label_lookups_task, burn_doc_annotations, @@ -955,6 +957,84 @@ def mutate(root, info, annotation_id): return RemoveAnnotation(ok=True) +class RejectAnnotation(graphene.Mutation): + class Arguments: + annotation_id = graphene.ID(required=True, description="ID of the annotation to reject") + comment = graphene.String(description="Optional comment for the rejection") + + ok = graphene.Boolean() + user_feedback = graphene.Field(UserFeedbackType) + + @login_required + @transaction.atomic + def mutate(root, info, annotation_id, comment=None): + user = info.context.user + annotation_pk = from_global_id(annotation_id)[1] + + try: + annotation = Annotation.objects.get(pk=annotation_pk) + except ObjectDoesNotExist: + return RejectAnnotation(ok=False, user_feedback=None) + + user_feedback, created = UserFeedback.objects.get_or_create( + commented_annotation=annotation, + defaults={ + 'creator': user, + 'approved': False, + 'rejected': True, + 'comment': comment or "" + } + ) + + if not created: + user_feedback.approved = False + user_feedback.rejected = True + user_feedback.comment = comment or user_feedback.comment + user_feedback.save() + + set_permissions_for_obj_to_user(user, user_feedback, [PermissionTypes.CRUD]) + + return RejectAnnotation(ok=True, user_feedback=user_feedback) + +class ApproveAnnotation(graphene.Mutation): + class Arguments: + annotation_id = graphene.ID(required=True, description="ID of the annotation to approve") + comment = graphene.String(description="Optional comment for the approval") + + ok = graphene.Boolean() + user_feedback = graphene.Field(UserFeedbackType) + + @login_required + @transaction.atomic + def mutate(root, info, annotation_id, comment=None): + user = info.context.user + annotation_pk = from_global_id(annotation_id)[1] + + try: + annotation = Annotation.objects.get(pk=annotation_pk) + except ObjectDoesNotExist: + return ApproveAnnotation(ok=False, user_feedback=None) + + user_feedback, created = UserFeedback.objects.get_or_create( + commented_annotation=annotation, + defaults={ + 'creator': user, + 'approved': True, + 'rejected': False, + 'comment': comment or "" + } + ) + + if not created: + user_feedback.approved = True + user_feedback.rejected = False + user_feedback.comment = comment or user_feedback.comment + user_feedback.save() + + set_permissions_for_obj_to_user(user, user_feedback, [PermissionTypes.CRUD]) + + return ApproveAnnotation(ok=True, user_feedback=user_feedback) + class AddAnnotation(graphene.Mutation): class Arguments: json = GenericScalar( @@ -1987,6 +2067,8 @@ class Mutation(graphene.ObjectType): update_annotation = UpdateAnnotation.Field() add_doc_type_annotation = AddDocTypeAnnotation.Field() remove_doc_type_annotation = RemoveAnnotation.Field() + approve_annotation = ApproveAnnotation.Field() + reject_annotation = RejectAnnotation.Field() # RELATIONSHIP MUTATIONS ##################################################### add_relationship = AddRelationship.Field() diff --git a/config/graphql/permissioning/graphene_django_filtering/converters.py b/config/graphql/permissioning/graphene_django_filtering/converters.py index 065e6841..b0c13f7e 100644 --- a/config/graphql/permissioning/graphene_django_filtering/converters.py +++ b/config/graphql/permissioning/graphene_django_filtering/converters.py @@ -34,6 +34,9 @@ def convert_field_to_list_or_connection(field, registry=None): model = field.related_model def dynamic_type(): + + print("Dynamic type on convert_field_to_list_or_connection...") + _type = registry.get_type_for_model(model) if not _type: return @@ -49,12 +52,12 @@ def dynamic_type(): # Use a DjangoFilterConnectionField if there are # defined filter_fields or a filterset_class in the # DjangoObjectType Meta - if _type._meta.filter_fields or _type._meta.filterset_class: - from graphene_django.filter.fields import DjangoFilterConnectionField - - return DjangoFilterConnectionField( - _type, required=True, description=description - ) + # if _type._meta.filter_fields or _type._meta.filterset_class: + # from graphene_django.filter.fields import DjangoFilterConnectionField + # + # return DjangoFilterConnectionField( + # _type, required=True, description=description + # ) return CustomDjangoFilterConnectionField(_type, required=True, description=description) diff --git a/config/settings/base.py b/config/settings/base.py index df9a57cf..fdf33e93 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -440,7 +440,7 @@ GRAPHENE = { "SCHEMA": "config.graphql.schema.schema", "MIDDLEWARE": [ - "config.graphql.permission_annotator.middleware.PermissionAnnotatingMiddleware", + "config.graphql.permissioning.permission_annotator.middleware.PermissionAnnotatingMiddleware", "graphql_jwt.middleware.JSONWebTokenMiddleware", "config.graphql_api_key_auth.middleware.ApiKeyTokenMiddleware", ], diff --git a/opencontractserver/corpuses/migrations/0013_corpus_allow_comments.py b/opencontractserver/corpuses/migrations/0013_corpus_allow_comments.py new file mode 100644 index 00000000..a1acda6e --- /dev/null +++ b/opencontractserver/corpuses/migrations/0013_corpus_allow_comments.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-09-15 15:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("corpuses", "0012_corpusaction_disabled_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="corpus", + name="allow_comments", + field=models.BooleanField(default=False), + ), + ] diff --git a/opencontractserver/corpuses/migrations/0014_alter_corpus_label_set.py b/opencontractserver/corpuses/migrations/0014_alter_corpus_label_set.py new file mode 100644 index 00000000..e1e842e0 --- /dev/null +++ b/opencontractserver/corpuses/migrations/0014_alter_corpus_label_set.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.16 on 2024-09-15 20:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "annotations", + "0017_remove_annotationlabel_only_install_one_label_of_given_name_for_each_analyzer_id_no_duplicates__and_", + ), + ("corpuses", "0013_corpus_allow_comments"), + ] + + operations = [ + migrations.AlterField( + model_name="corpus", + name="label_set", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="used_by_corpuses", + related_query_name="used_by_corpus", + to="annotations.labelset", + ), + ), + ] diff --git a/opencontractserver/corpuses/models.py b/opencontractserver/corpuses/models.py index cec83512..a66fab47 100644 --- a/opencontractserver/corpuses/models.py +++ b/opencontractserver/corpuses/models.py @@ -9,7 +9,7 @@ from tree_queries.query import TreeQuerySet from opencontractserver.annotations.models import Annotation -from opencontractserver.shared.Models import BaseOCModel +from opencontractserver.shared.Models import BaseOCModel, PermissionedModel from opencontractserver.shared.utils import calc_oc_file_path @@ -61,12 +61,14 @@ class Corpus(TreeNode): label_set = django.db.models.ForeignKey( "annotations.LabelSet", null=True, + blank=True, on_delete=django.db.models.SET_NULL, related_name="used_by_corpuses", related_query_name="used_by_corpus", ) # Sharing + allow_comments = django.db.models.BooleanField(default=False) is_public = django.db.models.BooleanField(default=False) creator = django.db.models.ForeignKey( get_user_model(), diff --git a/opencontractserver/feedback/migrations/0002_alter_userfeedback_options.py b/opencontractserver/feedback/migrations/0002_alter_userfeedback_options.py new file mode 100644 index 00000000..24ea2876 --- /dev/null +++ b/opencontractserver/feedback/migrations/0002_alter_userfeedback_options.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.16 on 2024-09-15 04:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("feedback", "0001_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="userfeedback", + options={ + "permissions": ( + ("permission_userfeedback", "permission UserFeedback"), + ("publish_userfeedback", "publish UserFeedback"), + ("create_userfeedback", "create UserFeedback"), + ("read_userfeedback", "read UserFeedback"), + ("update_userfeedback", "update UserFeedback"), + ("remove_userfeedback", "delete UserFeedback"), + ) + }, + ), + ] diff --git a/opencontractserver/feedback/migrations/0003_alter_userfeedback_commented_annotation.py b/opencontractserver/feedback/migrations/0003_alter_userfeedback_commented_annotation.py new file mode 100644 index 00000000..d7549dc8 --- /dev/null +++ b/opencontractserver/feedback/migrations/0003_alter_userfeedback_commented_annotation.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.16 on 2024-09-15 13:42 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "annotations", + "0017_remove_annotationlabel_only_install_one_label_of_given_name_for_each_analyzer_id_no_duplicates__and_", + ), + ("feedback", "0002_alter_userfeedback_options"), + ] + + operations = [ + migrations.AlterField( + model_name="userfeedback", + name="commented_annotation", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="user_feedback", + to="annotations.annotation", + ), + ), + ] diff --git a/opencontractserver/feedback/migrations/0004_rename_userfeedbackobjectpermission_userfeedbackuserobjectpermission.py b/opencontractserver/feedback/migrations/0004_rename_userfeedbackobjectpermission_userfeedbackuserobjectpermission.py new file mode 100644 index 00000000..8cfe288f --- /dev/null +++ b/opencontractserver/feedback/migrations/0004_rename_userfeedbackobjectpermission_userfeedbackuserobjectpermission.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.16 on 2024-09-15 20:50 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("feedback", "0003_alter_userfeedback_commented_annotation"), + ] + + operations = [ + migrations.RenameModel( + old_name="UserFeedbackObjectPermission", + new_name="UserFeedbackUserObjectPermission", + ), + ] diff --git a/opencontractserver/feedback/models.py b/opencontractserver/feedback/models.py index d3a6bf63..1c131a7f 100644 --- a/opencontractserver/feedback/models.py +++ b/opencontractserver/feedback/models.py @@ -1,13 +1,17 @@ +from django.core.exceptions import ValidationError from django.db import models from guardian.models import GroupObjectPermissionBase, UserObjectPermissionBase from opencontractserver.annotations.models import Annotation +from opencontractserver.shared.Managers import UserFeedbackManager from opencontractserver.shared.defaults import jsonfield_default_value from opencontractserver.shared.fields import NullableJSONField from opencontractserver.shared.Models import BaseOCModel class UserFeedback(BaseOCModel): + objects = UserFeedbackManager() + approved = models.BooleanField(default=False) rejected = models.BooleanField(default=False) comment = models.TextField(blank=True, default="", null=False) @@ -15,9 +19,9 @@ class UserFeedback(BaseOCModel): metadata = NullableJSONField(default=jsonfield_default_value, null=True, blank=True) commented_annotation = models.ForeignKey( Annotation, - on_delete=models.CASCADE, - blank=False, - null=False, + on_delete=models.SET_NULL, + blank=True, + null=True, related_name="user_feedback", ) @@ -31,9 +35,25 @@ class Meta: ("remove_userfeedback", "delete UserFeedback"), ) + def clean(self): + if self.approved and self.rejected: + if self._state.adding: + raise ValidationError("Both approved and rejected cannot be True.") + else: + # If updating, set the original value to False + original = UserFeedback.objects.get(pk=self.pk) + if original.approved != self.approved: + self.rejected = False + else: + self.approved = False + + def save(self, *args, **kwargs): + self.clean() + super().save(*args, **kwargs) + # Model for Django Guardian permissions... trying to improve performance... -class UserFeedbackObjectPermission(UserObjectPermissionBase): +class UserFeedbackUserObjectPermission(UserObjectPermissionBase): content_object = models.ForeignKey("UserFeedback", on_delete=models.CASCADE) # enabled = False diff --git a/opencontractserver/shared/Managers.py b/opencontractserver/shared/Managers.py index e69de29b..e3865013 100644 --- a/opencontractserver/shared/Managers.py +++ b/opencontractserver/shared/Managers.py @@ -0,0 +1,41 @@ +from django.db import models +from django.db.models import Q + +from opencontractserver.shared.QuerySets import UserFeedbackQuerySet + + +class UserFeedbackManager(models.Manager): + def get_queryset(self): + return UserFeedbackQuerySet(self.model, using=self._db) + + def get_or_none(self, *args, **kwargs): + try: + return self.get(*args, **kwargs) + except self.model.DoesNotExist: + return None + + def approved(self): + return self.get_queryset().approved() + + def rejected(self): + return self.get_queryset().rejected() + + def pending(self): + return self.get_queryset().pending() + + def recent(self, days=30): + return self.get_queryset().recent(days) + + def with_comments(self): + return self.get_queryset().with_comments() + + def by_creator(self, creator): + return self.get_queryset().by_creator(creator) + + def search(self, query): + return self.get_queryset().filter( + Q(comment__icontains=query) | Q(markdown__icontains=query) + ) + + def visible_to_user(self, user): + return self.get_queryset().visible_to_user(user) diff --git a/opencontractserver/shared/Models.py b/opencontractserver/shared/Models.py index 326897ce..74c883c9 100644 --- a/opencontractserver/shared/Models.py +++ b/opencontractserver/shared/Models.py @@ -5,7 +5,7 @@ from django.utils import timezone from django.db import models -from config.graphql.permissioning.filters import PermissionQuerySet +from opencontractserver.shared.QuerySets import PermissionQuerySet # from config.graphql.permissioning.filters import filter_queryset_by_user_read_permission @@ -16,6 +16,8 @@ # return filter_queryset_by_user_read_permission(self, user) + + class PermissionManager(Manager): def get_queryset(self): return PermissionQuerySet(self.model, using=self._db) @@ -24,6 +26,16 @@ def for_user(self, user, perm, extra_conditions=None): return self.get_queryset().for_user(user, perm, extra_conditions) +class PermissionedModel(models.Model): + # We have some models where we want both default model manager and PermissionManager + # https://docs.djangoproject.com/en/5.1/topics/db/managers/#custom-managers-and-model-inheritance + # permissioned_objects = PermissionManager() + permissioned_objects = PermissionQuerySet.as_manager() + + class Meta: + abstract = True + + class BaseOCModel(models.Model): """ diff --git a/opencontractserver/shared/QuerySets.py b/opencontractserver/shared/QuerySets.py index c724ead9..d665b1d3 100644 --- a/opencontractserver/shared/QuerySets.py +++ b/opencontractserver/shared/QuerySets.py @@ -1,41 +1,79 @@ from django.db import models from django.contrib.auth import get_user_model -from django.contrib.contenttypes.models import ContentType -from guardian.models import UserObjectPermission, GroupObjectPermission +# from django.contrib.contenttypes.models import ContentType +from django.utils import timezone +# from guardian.models import UserObjectPermission, GroupObjectPermission from django.db.models import Q, Exists, OuterRef User = get_user_model() +class UserFeedbackQuerySet(models.QuerySet): + def approved(self): + return self.filter(approved=True) + + def rejected(self): + return self.filter(rejected=True) + + def pending(self): + return self.filter(approved=False, rejected=False) + + def recent(self, days=30): + recent_date = timezone.now() - timezone.timedelta(days=days) + return self.filter(created__gte=recent_date) + + def with_comments(self): + return self.exclude(comment='') + + def by_creator(self, creator): + return self.filter(creator=creator) + + def visible_to_user(self, user): + from opencontractserver.annotations.models import Annotation # Import here to avoid circular imports + + if user.is_superuser: + return self.all() + + return self.filter( + Q(creator=user) | + Q(is_public=True) | + Q(commented_annotation__isnull=False) & + Exists(Annotation.objects.filter( + id=OuterRef('commented_annotation'), + is_public=True + )) + ).distinct() + + class PermissionQuerySet(models.QuerySet): def for_user(self, user, perm): - model = self.model - content_type = ContentType.objects.get_for_model(model) - - # Determine the permission codename - permission_codename = f'{perm}_{model._meta.model_name}' - - # User permission subquery - user_perm = UserObjectPermission.objects.filter( - content_type=content_type, - user=user, - permission__codename=permission_codename, - object_pk=OuterRef('pk') - ) - - # Group permission subquery - group_perm = GroupObjectPermission.objects.filter( - content_type=content_type, - group__user=user, - permission__codename=permission_codename, - object_pk=OuterRef('pk') - ) + # model = self.model + # content_type = ContentType.objects.get_for_model(model) + # + # # Determine the permission codename + # permission_codename = f'{perm}_{model._meta.model_name}' + # + # # User permission subquery + # user_perm = UserObjectPermission.objects.filter( + # content_type=content_type, + # user=user, + # permission__codename=permission_codename, + # object_pk=OuterRef('pk') + # ) + # + # # Group permission subquery + # group_perm = GroupObjectPermission.objects.filter( + # content_type=content_type, + # group__user=user, + # permission__codename=permission_codename, + # object_pk=OuterRef('pk') + # ) # Construct the base queryset - queryset = self.annotate( - has_user_perm=Exists(user_perm), - has_group_perm=Exists(group_perm) - ) + # queryset = self.annotate( + # has_user_perm=Exists(user_perm), + # has_group_perm=Exists(group_perm) + # ) # Filter based on permissions and public status - TODO - make this work for user/obj instance level sharing # permission_filter = Q(has_user_perm=True) | Q(has_group_perm=True) | Q(is_public=True) @@ -49,4 +87,4 @@ def for_user(self, user, perm): # # For publish permission, only include objects created by the user # permission_filter &= Q(creator=user) - return queryset.filter(permission_filter).distinct() + return self.filter(permission_filter).distinct() diff --git a/opencontractserver/tests/test_permissioning.py b/opencontractserver/tests/test_permissioning.py index cfa35ce8..ed95368b 100644 --- a/opencontractserver/tests/test_permissioning.py +++ b/opencontractserver/tests/test_permissioning.py @@ -26,12 +26,12 @@ make_corpus_public_task, ) from opencontractserver.types.enums import PermissionTypes -# from config.graphql.permissioning.filters import ( -# filter_queryset_by_user_read_permission, -# ) -from opencontractserver.utils.permissioning import (get_users_permissions_for_obj, + +from opencontractserver.utils.permissioning import ( + get_users_permissions_for_obj, set_permissions_for_obj_to_user, - user_has_permission_for_obj) + user_has_permission_for_obj +) from .fixtures import SAMPLE_PDF_FILE_ONE_PATH @@ -811,4 +811,157 @@ def test_permissions(self): self.__test_make_analysis_public_mutation() self.__test_make_analysis_public_task() self.__test_actual_analysis_deletion() - self.__test_query_efficient_filtering() + # self.__test_query_efficient_filtering() + + def test_user_feedback_visibility(self): + logger.info("----- TEST USER FEEDBACK VISIBILITY -----") + + from opencontractserver.feedback.models import UserFeedback + from opencontractserver.annotations.models import Annotation + + # Create UserFeedback objects with different visibility settings + with transaction.atomic(): + # Feedback created by user1, not public + feedback1 = UserFeedback.objects.create( + creator=self.user, + comment="Feedback 1", + is_public=False + ) + + # Feedback created by user2, public + feedback2 = UserFeedback.objects.create( + creator=self.user_2, + comment="Feedback 2", + is_public=True + ) + + # Feedback with public annotation + public_annotation = Annotation.objects.create( + creator=self.superuser, + document=self.corpus.documents.first(), + is_public=True + ) + feedback3 = UserFeedback.objects.create( + creator=self.superuser, + comment="Feedback 3", + is_public=False, + commented_annotation=public_annotation + ) + + # Feedback with private annotation + private_annotation = Annotation.objects.create( + creator=self.superuser, + document=self.corpus.documents.first(), + is_public=False + ) + feedback4 = UserFeedback.objects.create( + creator=self.superuser, + comment="Feedback 4", + is_public=False, + commented_annotation=private_annotation + ) + + # Test visibility for user1 + visible_feedback_user1 = UserFeedback.objects.visible_to_user(self.user) + self.assertIn(feedback1, visible_feedback_user1) + self.assertIn(feedback2, visible_feedback_user1) + self.assertIn(feedback3, visible_feedback_user1) + self.assertNotIn(feedback4, visible_feedback_user1) + logger.info(f"User1 can see {visible_feedback_user1.count()} feedback items") + + # Test visibility for user2 + visible_feedback_user2 = UserFeedback.objects.visible_to_user(self.user_2) + self.assertNotIn(feedback1, visible_feedback_user2) + self.assertIn(feedback2, visible_feedback_user2) + self.assertIn(feedback3, visible_feedback_user2) + self.assertNotIn(feedback4, visible_feedback_user2) + logger.info(f"User2 can see {visible_feedback_user2.count()} feedback items") + + # Test visibility for superuser + visible_feedback_superuser = UserFeedback.objects.visible_to_user(self.superuser) + self.assertIn(feedback1, visible_feedback_superuser) + self.assertIn(feedback2, visible_feedback_superuser) + self.assertIn(feedback3, visible_feedback_superuser) + self.assertIn(feedback4, visible_feedback_superuser) + logger.info(f"Superuser can see {visible_feedback_superuser.count()} feedback items") + + # Test that the filtered querysets are different for different users + self.assertNotEqual(set(visible_feedback_user1), set(visible_feedback_user2)) + + # Test performance + import time + + # Measure time for the efficient filtering using 'visible_to_user' method + start_time = time.time() + UserFeedback.objects.visible_to_user(self.user) + end_time = time.time() + + logger.info(f"Time taken for efficient filtering: {end_time - start_time} seconds") + + # Compare with a naive approach + start_time = time.time() + all_feedback = UserFeedback.objects.all() + naive_filtered = [ + feedback for feedback in all_feedback + if feedback.creator == self.user or feedback.is_public or + (feedback.commented_annotation and feedback.commented_annotation.is_public) + ] + end_time = time.time() + + logger.info(f"Time taken for naive filtering: {end_time - start_time} seconds") + + # Assert that both methods return the same results + self.assertEqual(set(visible_feedback_user1), set(naive_filtered)) + + # def test_direct_user_permissions(self): + # logger.info("----- TEST DIRECT USER PERMISSIONS -----") + # + # # Create a corpus + # with transaction.atomic(): + # corpus = Corpus.objects.create(title="Direct Permission Corpus", creator=self.superuser) + # + # # Grant read permission directly to user1 + # set_permissions_for_obj_to_user(self.user, corpus, [PermissionTypes.READ]) + # + # # Ensure user2 has no permissions + # # No action needed as user2 has no permissions by default + # + # # Verify that user1 can access the object + # accessible_corpuses_user1 = Corpus.permissioned_objects.for_user(self.user, perm='read') + # print("Access dis: ") + # print(accessible_corpuses_user1) + # print(accessible_corpuses_user1[0].title) + # self.assertIn(corpus, accessible_corpuses_user1) + # logger.info("User1 can access the corpus via direct permission.") + # + # # Verify that user2 cannot access the object + # accessible_corpuses_user2 = Corpus.permissioned_objects.for_user(self.user_2, perm='read') + # self.assertNotIn(corpus, accessible_corpuses_user2) + # logger.info("User2 cannot access the corpus without permissions.") + # + # def test_group_permissions(self): + # logger.info("----- TEST GROUP PERMISSIONS -----") + # + # # Create a corpus + # with transaction.atomic(): + # corpus = Corpus.objects.create(title="Group Permission Corpus", creator=self.superuser) + # + # # Create a group and add user1 to it + # group = Group.objects.create(name="Test Group") + # self.user.groups.add(group) + # + # # Grant read permission to the group + # assign_perm('read_corpus', group, corpus) + # + # # Ensure user2 is not in the group + # # No action needed as user2 is not added to any group + # + # # Verify that user1 can access the object via group permission + # accessible_corpuses_user1 = Corpus.permissioned_objects.for_user(self.user, perm='read') + # self.assertIn(corpus, accessible_corpuses_user1) + # logger.info("User1 can access the corpus via group permission.") + # + # # Verify that user2 cannot access the object + # accessible_corpuses_user2 = Corpus.permissioned_objects.for_user(self.user_2, perm='read') + # self.assertNotIn(corpus, accessible_corpuses_user2) + # logger.info("User2 cannot access the corpus without permissions.") From 71a571714d9c68e9874739e93286b626329ee14e Mon Sep 17 00:00:00 2001 From: JSv4 Date: Sun, 15 Sep 2024 21:47:42 -0700 Subject: [PATCH 11/18] Added fancy dashboard for feedback & comments. --- .../graphene_django_filtering/__init__.py | 0 .../graphene_django_filtering/converters.py | 70 ------ frontend/package.json | 1 + .../src/assets/configurations/constants.ts | 2 +- .../annotator/display/Selection.tsx | 2 +- .../components/corpuses/CorpusDashboard.tsx | 204 ++++++++++++++++++ .../src/components/queries/NewQuerySearch.tsx | 103 --------- frontend/src/graphql/queries.ts | 28 +++ frontend/src/views/Corpuses.tsx | 29 ++- frontend/yarn.lock | 12 ++ 10 files changed, 266 insertions(+), 185 deletions(-) delete mode 100644 config/graphql/permissioning/graphene_django_filtering/__init__.py delete mode 100644 config/graphql/permissioning/graphene_django_filtering/converters.py create mode 100644 frontend/src/components/corpuses/CorpusDashboard.tsx delete mode 100644 frontend/src/components/queries/NewQuerySearch.tsx diff --git a/config/graphql/permissioning/graphene_django_filtering/__init__.py b/config/graphql/permissioning/graphene_django_filtering/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/config/graphql/permissioning/graphene_django_filtering/converters.py b/config/graphql/permissioning/graphene_django_filtering/converters.py deleted file mode 100644 index b0c13f7e..00000000 --- a/config/graphql/permissioning/graphene_django_filtering/converters.py +++ /dev/null @@ -1,70 +0,0 @@ -from django.db import models - -from graphene import ( - Dynamic, - Field, -) -from graphene_django.converter import get_django_field_description, convert_django_field -from graphene_django.fields import DjangoListField - -from config.graphql.custom_connections import CustomDjangoFilterConnectionField - - -@convert_django_field.register(models.OneToOneRel) -def convert_onetoone_field_to_djangomodel(field, registry=None): - model = field.related_model - - def dynamic_type(): - _type = registry.get_type_for_model(model) - if not _type: - return - - return Field(_type, required=not field.null) - - return Dynamic(dynamic_type) - - -@convert_django_field.register(models.ManyToManyField) -@convert_django_field.register(models.ManyToManyRel) -@convert_django_field.register(models.ManyToOneRel) -def convert_field_to_list_or_connection(field, registry=None): - - print(f"Custom convert_field_to_list_or_connection running...") - - model = field.related_model - - def dynamic_type(): - - print("Dynamic type on convert_field_to_list_or_connection...") - - _type = registry.get_type_for_model(model) - if not _type: - return - - if isinstance(field, models.ManyToManyField): - description = get_django_field_description(field) - else: - description = get_django_field_description(field.field) - - # If there is a connection, we should transform the field - # into a DjangoConnectionField - if _type._meta.connection: - # Use a DjangoFilterConnectionField if there are - # defined filter_fields or a filterset_class in the - # DjangoObjectType Meta - # if _type._meta.filter_fields or _type._meta.filterset_class: - # from graphene_django.filter.fields import DjangoFilterConnectionField - # - # return DjangoFilterConnectionField( - # _type, required=True, description=description - # ) - - return CustomDjangoFilterConnectionField(_type, required=True, description=description) - - return DjangoListField( - _type, - required=True, # A Set is always returned, never None. - description=description, - ) - - return Dynamic(dynamic_type) diff --git a/frontend/package.json b/frontend/package.json index ee92daea..9e3b6dee 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,6 +34,7 @@ "react-beautiful-dnd": "^13.1.0", "react-color": "^2.19.3", "react-cool-inview": "^3.0.1", + "react-countup": "^6.5.3", "react-dom": "^17.0.2", "react-dropzone": "^11.4.2", "react-infinite-scroll-component": "^6.1.0", diff --git a/frontend/src/assets/configurations/constants.ts b/frontend/src/assets/configurations/constants.ts index 462e6c1b..35a52e8e 100644 --- a/frontend/src/assets/configurations/constants.ts +++ b/frontend/src/assets/configurations/constants.ts @@ -1,2 +1,2 @@ -export const VERSION_TAG = "v2.5.0"; +export const VERSION_TAG = "v2.3.0"; export const MOBILE_VIEW_BREAKPOINT = 600; diff --git a/frontend/src/components/annotator/display/Selection.tsx b/frontend/src/components/annotator/display/Selection.tsx index 7554c747..dfbca123 100644 --- a/frontend/src/components/annotator/display/Selection.tsx +++ b/frontend/src/components/annotator/display/Selection.tsx @@ -104,7 +104,7 @@ export const Selection: React.FC = ({ } else { actions.push({ name: "question", - color: "red", + color: "blue", tooltip: "Login to see available actions!", onClick: () => { window.alert("Login to leave feedback and see other actions!"); diff --git a/frontend/src/components/corpuses/CorpusDashboard.tsx b/frontend/src/components/corpuses/CorpusDashboard.tsx new file mode 100644 index 00000000..027b5353 --- /dev/null +++ b/frontend/src/components/corpuses/CorpusDashboard.tsx @@ -0,0 +1,204 @@ +import React, { useState } from "react"; +import { useMutation, useQuery } from "@apollo/client"; +import { + Container, + Header, + Icon, + Input, + Grid, + Statistic, + SemanticICONS, +} from "semantic-ui-react"; +import { toast } from "react-toastify"; +import { openedQueryObj } from "../../graphql/cache"; +import { + ASK_QUERY_OF_CORPUS, + AskQueryOfCorpusInputType, + AskQueryOfCorpusOutputType, +} from "../../graphql/mutations"; +import { + CorpusStats, + GET_CORPUS_STATS, + GetCorpusStatsInputType, + GetCorpusStatsOutputType, +} from "../../graphql/queries"; +import CountUp from "react-countup"; +import { CorpusType } from "../../graphql/types"; +import useWindowDimensions from "../hooks/WindowDimensionHook"; +import { MOBILE_VIEW_BREAKPOINT } from "../../assets/configurations/constants"; + +interface NewQuerySearchProps { + corpus: CorpusType; +} + +const StatisticWithAnimation = ({ + value, + label, + icon, +}: { + value: number; + label: string; + icon: SemanticICONS; +}) => ( + + + + + + {label} + +); + +export const CorpusDashboard: React.FC = ({ + corpus, +}: { + corpus: CorpusType; +}) => { + const { width } = useWindowDimensions(); + let use_mobile_layout = width <= MOBILE_VIEW_BREAKPOINT; + const [query, setQuery] = useState(""); + const [stats, setStats] = useState({ + totalDocs: 0, + totalAnalyses: 0, + totalAnnotations: 0, + totalExtracts: 0, + totalComments: 0, + }); + + const { loading, error, data } = useQuery< + GetCorpusStatsOutputType, + GetCorpusStatsInputType + >(GET_CORPUS_STATS, { + variables: { corpusId: corpus.id }, + onCompleted: (data) => { + setStats(data.corpusStats); + }, + }); + + const [sendQuery] = useMutation< + AskQueryOfCorpusOutputType, + AskQueryOfCorpusInputType + >(ASK_QUERY_OF_CORPUS, { + onCompleted: (data) => { + toast.success("SUCCESS! Question Submitted."); + openedQueryObj(data.askQuery.obj); + }, + onError: (err) => { + toast.error("ERROR! Failed submitting question."); + }, + }); + + const handleSubmit = () => { + sendQuery({ + variables: { + corpusId: corpus.id, + query, + }, + }); + }; + + return ( + +
+ Corpus Dashboard +
+ + + + + + + + + + + + +
+
+
+
+ + + {use_mobile_layout ? "" : "Corpus Query"} + + Query your document collection + + +
+
+
+ + setQuery(e.target.value)} + onKeyPress={(e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSubmit(); + } + }} + /> +
+
+
+ ); +}; diff --git a/frontend/src/components/queries/NewQuerySearch.tsx b/frontend/src/components/queries/NewQuerySearch.tsx deleted file mode 100644 index 9c127ec4..00000000 --- a/frontend/src/components/queries/NewQuerySearch.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useMutation } from "@apollo/client"; -import React from "react"; -import { - Button, - Container, - Header, - Icon, - Image, - Input, -} from "semantic-ui-react"; -import { - ASK_QUERY_OF_CORPUS, - AskQueryOfCorpusInputType, - AskQueryOfCorpusOutputType, -} from "../../graphql/mutations"; -import { toast } from "react-toastify"; -import { openedQueryObj } from "../../graphql/cache"; - -interface NewQuerySearchProps { - corpus_id: string; -} - -export const NewQuerySearch: React.FC = ({ - corpus_id, -}) => { - const [query, setQuery] = React.useState(""); - - const [sendQuery] = useMutation< - AskQueryOfCorpusOutputType, - AskQueryOfCorpusInputType - >(ASK_QUERY_OF_CORPUS, { - onCompleted: (data) => { - toast.success("SUCCESS! Question Submitted."); - openedQueryObj(data.askQuery.obj); - }, - onError: (err) => { - toast.error("ERROR! Failed submitting question."); - }, - }); - - const handleSubmit = () => { - sendQuery({ - variables: { - corpusId: corpus_id, - query, - }, - }); - }; - - return ( -
- -
- -
- Corpus Query - Query your document collection -
-
-
- setQuery(e.target.value)} - onKeyPress={(e: { key: string }) => { - if (e.key === "Enter") { - handleSubmit(); - } - }} - style={{ width: "400px" }} - /> - -
-
-
- ); -}; diff --git a/frontend/src/graphql/queries.ts b/frontend/src/graphql/queries.ts index 3ce04bef..eaa3be11 100644 --- a/frontend/src/graphql/queries.ts +++ b/frontend/src/graphql/queries.ts @@ -243,6 +243,34 @@ export const GET_CORPUS_QUERY_DETAILS = gql` } `; +export interface GetCorpusStatsInputType { + corpusId: string; +} + +export interface CorpusStats { + totalDocs: number; + totalComments: number; + totalAnalyses: number; + totalExtracts: number; + totalAnnotations: number; +} + +export interface GetCorpusStatsOutputType { + corpusStats: CorpusStats; +} + +export const GET_CORPUS_STATS = gql` + query corpusStats($corpusId: ID!) { + corpusStats(corpusId: $corpusId) { + totalDocs + totalComments + totalAnalyses + totalExtracts + totalAnnotations + } + } +`; + export interface GetCorpusQueriesInput { corpusId: string; } diff --git a/frontend/src/views/Corpuses.tsx b/frontend/src/views/Corpuses.tsx index bc446ac4..f4334c0e 100644 --- a/frontend/src/views/Corpuses.tsx +++ b/frontend/src/views/Corpuses.tsx @@ -87,12 +87,12 @@ import { FilterToAnalysesSelector } from "../components/widgets/model-filters/Fi import useWindowDimensions from "../components/hooks/WindowDimensionHook"; import { SelectExportTypeModal } from "../components/widgets/modals/SelectExportTypeModal"; import { CorpusQueryList } from "../components/queries/CorpusQueryList"; -import { NewQuerySearch } from "../components/queries/NewQuerySearch"; import { ViewQueryResultsModal } from "../components/widgets/modals/ViewQueryResultsModal"; import { FilterToCorpusActionOutputs } from "../components/widgets/model-filters/FilterToCorpusActionOutputs"; import { CorpusExtractCards } from "../components/extracts/CorpusExtractCards"; import { getPermissions } from "../utils/transform"; import { MOBILE_VIEW_BREAKPOINT } from "../assets/configurations/constants"; +import { CorpusDashboard } from "../components/corpuses/CorpusDashboard"; export const Corpuses = () => { const { width } = useWindowDimensions(); @@ -581,16 +581,25 @@ export const Corpuses = () => { query_view = ( <>
-
- ; + {opened_corpus ? : <>} ); } else { diff --git a/frontend/yarn.lock b/frontend/yarn.lock index f0207f90..dde3f870 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -5256,6 +5256,11 @@ cosmiconfig@^9.0.0: js-yaml "^4.1.0" parse-json "^5.2.0" +countup.js@^2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/countup.js/-/countup.js-2.8.0.tgz#64951f2df3ede28839413d654d8fef28251c32a8" + integrity sha512-f7xEhX0awl4NOElHulrl4XRfKoNH3rB+qfNSZZyjSZhaAoUk6elvhH+MNxMmlmuUJ2/QNTWPSA7U4mNtIAKljQ== + create-jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/create-jest/-/create-jest-29.7.0.tgz#a355c5b3cb1e1af02ba177fe7afd7feee49a5320" @@ -11132,6 +11137,13 @@ react-cool-inview@^3.0.1: resolved "https://registry.yarnpkg.com/react-cool-inview/-/react-cool-inview-3.0.1.tgz#345b0a36d124d9da4d8dfc354397d54c40bfd165" integrity sha512-ly6i3Pv5p0fvm12NmJGfKS34eOhA+iU43Th+gZ6t3G6UwsxQsWoITHTHzA9pdkOc/3VmnReqvC/hJkQUDGhQFA== +react-countup@^6.5.3: + version "6.5.3" + resolved "https://registry.yarnpkg.com/react-countup/-/react-countup-6.5.3.tgz#e892aa3eab2d6ba9c3cdba30bf4ed6764826d848" + integrity sha512-udnqVQitxC7QWADSPDOxVWULkLvKUWrDapn5i53HE4DPRVgs+Y5rr4bo25qEl8jSh+0l2cToJgGMx+clxPM3+w== + dependencies: + countup.js "^2.8.0" + react-dev-utils@^12.0.0: version "12.0.0" resolved "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.0.tgz" From 20ef5fc7191b60d49a8262adf4afa4c88e818f6a Mon Sep 17 00:00:00 2001 From: JSv4 Date: Sun, 15 Sep 2024 21:49:09 -0700 Subject: [PATCH 12/18] Added in basic filtering on permissions / visibility at graphene DjangoObjectType level. --- config/graphql/custom_connections.py | 70 -------------------------- config/graphql/graphene_types.py | 37 +++++++++++++- config/graphql/queries.py | 38 +++++++++++++- opencontractserver/corpuses/models.py | 6 +-- opencontractserver/shared/Models.py | 30 ----------- opencontractserver/shared/QuerySets.py | 61 +++++++++++++++++++++- 6 files changed, 135 insertions(+), 107 deletions(-) diff --git a/config/graphql/custom_connections.py b/config/graphql/custom_connections.py index f9ca3094..1464a13d 100644 --- a/config/graphql/custom_connections.py +++ b/config/graphql/custom_connections.py @@ -36,73 +36,3 @@ def resolve_page_count(root, info, **kwargs): # print(f"PdfPageAwareConnection - resolve_edge_count kwargs: {kwargs}") return largest_page_number - - -class CustomDjangoFilterConnectionField(DjangoFilterConnectionField): - def __init__( - self, - type_, - fields=None, - order_by=None, - extra_filter_meta=None, - filterset_class=None, - *args, - **kwargs - ): - print(F"CustomDjangoFilterConnectionField - kwargs: {kwargs}") - super().__init__(type_, fields, order_by, extra_filter_meta, filterset_class, *args, **kwargs) - - @property - def args(self): - return to_arguments(self._base_args or OrderedDict(), self.filtering_args) - - @args.setter - def args(self, args): - self._base_args = args - - @classmethod - def resolve_queryset( - cls, connection, iterable, info, args, filtering_args, filterset_class - ): - # def filter_kwargs(): - # kwargs = {} - # for k, v in args.items(): - # if k in filtering_args: - # if k == "order_by" and v is not None: - # v = to_snake_case(v) - # kwargs[k] = convert_enum(v) - # return kwargs - - qs = super(DjangoFilterConnectionField, cls).resolve_queryset( - connection, iterable, info, args - ) - - if hasattr(qs, 'visible_to_user'): - print(f"Custom connection... we are looking at permissioned model with visible_to_user(...) " - f"function... use it.") - qs = qs.visible_to_user(user=info.context.user) - - return qs - - # filterset = filterset_class( - # data=filter_kwargs(), queryset=qs, request=info.context - # ) - # - # if filterset.is_valid(): - # qs = filterset.qs - # # Apply permission filtering - # model = qs.model - # user = info.context.user - # if hasattr(model, 'get_queryset'): - # qs = model.get_queryset(qs, user) - # elif hasattr(model, 'objects') and hasattr(model.objects, 'get_queryset'): - # qs = model.objects.get_queryset(qs, user) - # return qs - # raise ValidationError(filterset.form.errors.as_json()) - - def get_queryset_resolver(self): - return partial( - self.resolve_queryset, - filterset_class=self.filterset_class, - filtering_args=self.filtering_args, - ) diff --git a/config/graphql/graphene_types.py b/config/graphql/graphene_types.py index 3c5cab37..8ed0f8f1 100644 --- a/config/graphql/graphene_types.py +++ b/config/graphql/graphene_types.py @@ -2,6 +2,7 @@ import graphene from django.contrib.auth import get_user_model +from django.db.models import QuerySet from graphene import relay from graphene.types.generic import GenericScalar from graphene_django import DjangoObjectType @@ -245,6 +246,16 @@ class Meta: exclude = ("embedding",) connection_class = CountableConnection + @classmethod + def get_queryset(cls, queryset, info): + if issubclass(type(queryset), QuerySet): + return queryset.visible_to_user(info.context.user) + elif 'RelatedManager' in str(type(queryset)): + # https://stackoverflow.com/questions/11320702/import-relatedmanager-from-django-db-models-fields-related + return queryset.all().visible_to_user(info.context.user) + else: + return queryset + class CorpusType(AnnotatePermissionsForReadMixin, DjangoObjectType): all_annotation_summaries = graphene.List( @@ -295,6 +306,16 @@ class Meta: interfaces = [relay.Node] connection_class = CountableConnection + @classmethod + def get_queryset(cls, queryset, info): + if issubclass(type(queryset), QuerySet): + return queryset.visible_to_user(info.context.user) + elif 'RelatedManager' in str(type(queryset)): + # https://stackoverflow.com/questions/11320702/import-relatedmanager-from-django-db-models-fields-related + return queryset.all().visible_to_user(info.context.user) + else: + return queryset + class CorpusActionType(AnnotatePermissionsForReadMixin, DjangoObjectType): class Meta: @@ -454,6 +475,14 @@ class DocumentCorpusActionsType(graphene.ObjectType): analysis_rows = graphene.List(DocumentAnalysisRowType) +class CorpusStatsType(graphene.ObjectType): + total_docs = graphene.Int() + total_annotations = graphene.Int() + total_comments = graphene.Int() + total_analyses = graphene.Int() + total_extracts = graphene.Int() + + class UserFeedbackType(AnnotatePermissionsForReadMixin, DjangoObjectType): class Meta: model = UserFeedback @@ -463,4 +492,10 @@ class Meta: # https://docs.graphene-python.org/projects/django/en/latest/queries/#default-queryset @classmethod def get_queryset(cls, queryset, info): - return queryset.objects.visible_to_user(info.context.user) + if issubclass(type(queryset), QuerySet): + return queryset.visible_to_user(info.context.user) + elif 'RelatedManager' in str(type(queryset)): + # https://stackoverflow.com/questions/11320702/import-relatedmanager-from-django-db-models-fields-related + return queryset.all().visible_to_user(info.context.user) + else: + return queryset diff --git a/config/graphql/queries.py b/config/graphql/queries.py index ca51ba5b..cc0ed734 100644 --- a/config/graphql/queries.py +++ b/config/graphql/queries.py @@ -50,7 +50,7 @@ PdfPageInfoType, RelationshipType, UserExportType, - UserImportType, + UserImportType, CorpusStatsType, ) from opencontractserver.analyzer.models import Analysis, Analyzer, GremlinEngine from opencontractserver.annotations.models import ( @@ -62,6 +62,7 @@ from opencontractserver.corpuses.models import Corpus, CorpusAction, CorpusQuery from opencontractserver.documents.models import Document from opencontractserver.extracts.models import Column, Datacell, Extract, Fieldset +from opencontractserver.feedback.models import UserFeedback from opencontractserver.shared.resolvers import resolve_oc_model_queryset from opencontractserver.types.enums import LabelType from opencontractserver.users.models import Assignment, UserExport, UserImport @@ -951,6 +952,41 @@ def resolve_registered_extract_tasks(self, info, **kwargs): if task.startswith("opencontractserver.tasks.data_extract_tasks") } + corpus_stats = graphene.Field( + CorpusStatsType, + corpus_id=graphene.ID(required=True) + ) + + def resolve_corpus_stats(self, info, corpus_id): + + total_docs = 0 + total_annotations = 0 + total_comments = 0 + total_analyses = 0 + total_extracts = 0 + + corpus_pk = from_global_id(corpus_id)[1] + corpuses = Corpus.objects.visible_to_user(info.context.user).filter(id=corpus_pk) + + if corpuses.count() == 1: + corpus = corpuses[0] + total_docs = corpus.documents.all().count() + total_annotations = corpus.annotations.all().count() + total_comments = UserFeedback.objects.filter( + commented_annotation__corpus=corpus + ).count() + total_analyses = corpus.analyses.all().count() + total_extracts = corpus.extracts.all().count() + + return CorpusStatsType( + total_docs=total_docs, + total_annotations = total_annotations, + total_comments = total_comments, + total_analyses = total_analyses, + total_extracts = total_extracts + ) + + document_corpus_actions = graphene.Field( DocumentCorpusActionsType, document_id=graphene.ID(required=True), diff --git a/opencontractserver/corpuses/models.py b/opencontractserver/corpuses/models.py index a66fab47..4c80bcbc 100644 --- a/opencontractserver/corpuses/models.py +++ b/opencontractserver/corpuses/models.py @@ -6,10 +6,10 @@ from django.utils import timezone from guardian.models import GroupObjectPermissionBase, UserObjectPermissionBase from tree_queries.models import TreeNode -from tree_queries.query import TreeQuerySet from opencontractserver.annotations.models import Annotation -from opencontractserver.shared.Models import BaseOCModel, PermissionedModel +from opencontractserver.shared.Models import BaseOCModel +from opencontractserver.shared.QuerySets import PermissionedTreeQuerySet from opencontractserver.shared.utils import calc_oc_file_path @@ -95,7 +95,7 @@ class Corpus(TreeNode): created = django.db.models.DateTimeField(default=timezone.now) modified = django.db.models.DateTimeField(default=timezone.now, blank=True) - objects = TreeQuerySet.as_manager(with_tree_fields=True) + objects = PermissionedTreeQuerySet.as_manager(with_tree_fields=True) class Meta: permissions = ( diff --git a/opencontractserver/shared/Models.py b/opencontractserver/shared/Models.py index 74c883c9..864c9d9c 100644 --- a/opencontractserver/shared/Models.py +++ b/opencontractserver/shared/Models.py @@ -2,22 +2,11 @@ from django.contrib.auth import get_user_model from django.db import models from django.db.models import Manager -from django.utils import timezone from django.db import models from opencontractserver.shared.QuerySets import PermissionQuerySet -# from config.graphql.permissioning.filters import filter_queryset_by_user_read_permission - - -# class PermissionQuerySet(models.QuerySet): -# def readable_by_user(self, user): -# return filter_queryset_by_user_read_permission(self, user) - - - - class PermissionManager(Manager): def get_queryset(self): return PermissionQuerySet(self.model, using=self._db) @@ -26,16 +15,6 @@ def for_user(self, user, perm, extra_conditions=None): return self.get_queryset().for_user(user, perm, extra_conditions) -class PermissionedModel(models.Model): - # We have some models where we want both default model manager and PermissionManager - # https://docs.djangoproject.com/en/5.1/topics/db/managers/#custom-managers-and-model-inheritance - # permissioned_objects = PermissionManager() - permissioned_objects = PermissionQuerySet.as_manager() - - class Meta: - abstract = True - - class BaseOCModel(models.Model): """ @@ -76,12 +55,3 @@ class Meta: # Timing variables created = django.db.models.DateTimeField(auto_now_add=True, blank=False, null=False) modified = django.db.models.DateTimeField(auto_now=True, blank=False, null=False) - - # Override save to update modified on save - def save(self, *args, **kwargs): - """On save, update timestamps""" - if not self.pk: - self.created = timezone.now() - self.modified = timezone.now() - - return super().save(*args, **kwargs) diff --git a/opencontractserver/shared/QuerySets.py b/opencontractserver/shared/QuerySets.py index d665b1d3..1c30f409 100644 --- a/opencontractserver/shared/QuerySets.py +++ b/opencontractserver/shared/QuerySets.py @@ -4,10 +4,56 @@ from django.utils import timezone # from guardian.models import UserObjectPermission, GroupObjectPermission from django.db.models import Q, Exists, OuterRef +from tree_queries.query import TreeQuerySet User = get_user_model() +class PermissionedTreeQuerySet(TreeQuerySet): + def approved(self): + return self.filter(approved=True) + + def rejected(self): + return self.filter(rejected=True) + + def pending(self): + return self.filter(approved=False, rejected=False) + + def recent(self, days=30): + recent_date = timezone.now() - timezone.timedelta(days=days) + return self.filter(created__gte=recent_date) + + def with_comments(self): + return self.exclude(comment='') + + def by_creator(self, creator): + return self.filter(creator=creator) + + def visible_to_user(self, user): + """ + Gets queryset with_tree_fields that is visible to user. At moment, we're JUST filtering + on creator and is_public, BUT this will filter on per-obj permissions later. + """ + + if user.is_superuser: + return self.all() + + if user.is_anonymous: + queryset = self.filter( + Q(is_public=True) + ).distinct() + else: + queryset = self.filter( + Q(creator=user) | + Q(is_public=True) + ).distinct() + + return queryset.with_tree_fields() + + def with_tree_fields(self): + return super().with_tree_fields() + + class UserFeedbackQuerySet(models.QuerySet): def approved(self): return self.filter(approved=True) @@ -34,6 +80,11 @@ def visible_to_user(self, user): if user.is_superuser: return self.all() + if user.is_anonymous: + return self.filter( + Q(is_public=True) + ).distinct() + return self.filter( Q(creator=user) | Q(is_public=True) | @@ -46,7 +97,11 @@ def visible_to_user(self, user): class PermissionQuerySet(models.QuerySet): - def for_user(self, user, perm): + def visible_to_user(self, user, perm = None): + + if user.is_superuser: + return self.all() + # model = self.model # content_type = ContentType.objects.get_for_model(model) # @@ -77,7 +132,9 @@ def for_user(self, user, perm): # Filter based on permissions and public status - TODO - make this work for user/obj instance level sharing # permission_filter = Q(has_user_perm=True) | Q(has_group_perm=True) | Q(is_public=True) - permission_filter = Q(is_public=True) | Q(creator=user) + permission_filter = Q(is_public=True) + if not user.is_anonymous: + permission_filter |= Q(creator=user) # # Add extra conditions based on permission type # if perm == 'read': From a190ae87bef069ecdb0ad877b09110fb65f0a5fc Mon Sep 17 00:00:00 2001 From: JSv4 Date: Sun, 15 Sep 2024 21:50:27 -0700 Subject: [PATCH 13/18] Ran linter + cleanup. --- config/graphql/base.py | 1 - config/graphql/custom_connections.py | 8 -- config/graphql/graphene_types.py | 11 +- config/graphql/mutations.py | 33 +++--- config/graphql/queries.py | 21 ++-- opencontractserver/feedback/models.py | 2 +- opencontractserver/shared/Models.py | 5 +- opencontractserver/shared/QuerySets.py | 48 ++++---- .../tasks/permissioning_tasks.py | 6 +- .../tests/test_permissioning.py | 107 +++++++++++------- opencontractserver/utils/permissioning.py | 3 - opencontractserver/utils/sharing.py | 8 +- 12 files changed, 142 insertions(+), 111 deletions(-) diff --git a/config/graphql/base.py b/config/graphql/base.py index 204df516..c4f233a5 100644 --- a/config/graphql/base.py +++ b/config/graphql/base.py @@ -5,7 +5,6 @@ import django.db.models import graphene -from graphene import Int from graphene.relay import Node from graphene_django import DjangoObjectType from graphql_jwt.decorators import login_required diff --git a/config/graphql/custom_connections.py b/config/graphql/custom_connections.py index 1464a13d..e8f7f862 100644 --- a/config/graphql/custom_connections.py +++ b/config/graphql/custom_connections.py @@ -1,14 +1,6 @@ import logging -from collections import OrderedDict -from functools import partial - -from django.core.exceptions import ValidationError - from graphene import Connection, Int -from graphene.types.argument import to_arguments -from graphene.utils.str_converters import to_snake_case -from graphene_django.filter.fields import convert_enum, DjangoFilterConnectionField logger = logging.getLogger(__name__) diff --git a/config/graphql/graphene_types.py b/config/graphql/graphene_types.py index 8ed0f8f1..d41adb65 100644 --- a/config/graphql/graphene_types.py +++ b/config/graphql/graphene_types.py @@ -10,9 +10,10 @@ from graphql_relay import from_global_id from config.graphql.base import CountableConnection - from config.graphql.filters import AnnotationFilter, LabelFilter -from config.graphql.permissioning.permission_annotator.mixins import AnnotatePermissionsForReadMixin +from config.graphql.permissioning.permission_annotator.mixins import ( + AnnotatePermissionsForReadMixin, +) from opencontractserver.analyzer.models import Analysis, Analyzer, GremlinEngine from opencontractserver.annotations.models import ( Annotation, @@ -250,7 +251,7 @@ class Meta: def get_queryset(cls, queryset, info): if issubclass(type(queryset), QuerySet): return queryset.visible_to_user(info.context.user) - elif 'RelatedManager' in str(type(queryset)): + elif "RelatedManager" in str(type(queryset)): # https://stackoverflow.com/questions/11320702/import-relatedmanager-from-django-db-models-fields-related return queryset.all().visible_to_user(info.context.user) else: @@ -310,7 +311,7 @@ class Meta: def get_queryset(cls, queryset, info): if issubclass(type(queryset), QuerySet): return queryset.visible_to_user(info.context.user) - elif 'RelatedManager' in str(type(queryset)): + elif "RelatedManager" in str(type(queryset)): # https://stackoverflow.com/questions/11320702/import-relatedmanager-from-django-db-models-fields-related return queryset.all().visible_to_user(info.context.user) else: @@ -494,7 +495,7 @@ class Meta: def get_queryset(cls, queryset, info): if issubclass(type(queryset), QuerySet): return queryset.visible_to_user(info.context.user) - elif 'RelatedManager' in str(type(queryset)): + elif "RelatedManager" in str(type(queryset)): # https://stackoverflow.com/questions/11320702/import-relatedmanager-from-django-db-models-fields-related return queryset.all().visible_to_user(info.context.user) else: diff --git a/config/graphql/mutations.py b/config/graphql/mutations.py index d5aa05e0..80319946 100644 --- a/config/graphql/mutations.py +++ b/config/graphql/mutations.py @@ -34,7 +34,8 @@ RelationInputType, RelationshipType, UserExportType, - UserType, UserFeedbackType, + UserFeedbackType, + UserType, ) from config.graphql.serializers import ( AnnotationLabelSerializer, @@ -959,7 +960,9 @@ def mutate(root, info, annotation_id): class RejectAnnotation(graphene.Mutation): class Arguments: - annotation_id = graphene.ID(required=True, description="ID of the annotation to reject") + annotation_id = graphene.ID( + required=True, description="ID of the annotation to reject" + ) comment = graphene.String(description="Optional comment for the rejection") ok = graphene.Boolean() @@ -979,11 +982,11 @@ def mutate(root, info, annotation_id, comment=None): user_feedback, created = UserFeedback.objects.get_or_create( commented_annotation=annotation, defaults={ - 'creator': user, - 'approved': False, - 'rejected': True, - 'comment': comment or "" - } + "creator": user, + "approved": False, + "rejected": True, + "comment": comment or "", + }, ) if not created: @@ -996,9 +999,12 @@ def mutate(root, info, annotation_id, comment=None): return RejectAnnotation(ok=True, user_feedback=user_feedback) + class ApproveAnnotation(graphene.Mutation): class Arguments: - annotation_id = graphene.ID(required=True, description="ID of the annotation to approve") + annotation_id = graphene.ID( + required=True, description="ID of the annotation to approve" + ) comment = graphene.String(description="Optional comment for the approval") ok = graphene.Boolean() @@ -1018,11 +1024,11 @@ def mutate(root, info, annotation_id, comment=None): user_feedback, created = UserFeedback.objects.get_or_create( commented_annotation=annotation, defaults={ - 'creator': user, - 'approved': True, - 'rejected': False, - 'comment': comment or "" - } + "creator": user, + "approved": True, + "rejected": False, + "comment": comment or "", + }, ) if not created: @@ -1035,6 +1041,7 @@ def mutate(root, info, annotation_id, comment=None): return ApproveAnnotation(ok=True, user_feedback=user_feedback) + class AddAnnotation(graphene.Mutation): class Arguments: json = GenericScalar( diff --git a/config/graphql/queries.py b/config/graphql/queries.py index cc0ed734..99dce56d 100644 --- a/config/graphql/queries.py +++ b/config/graphql/queries.py @@ -38,6 +38,7 @@ AssignmentType, ColumnType, CorpusQueryType, + CorpusStatsType, CorpusType, DatacellType, DocumentCorpusActionsType, @@ -50,7 +51,7 @@ PdfPageInfoType, RelationshipType, UserExportType, - UserImportType, CorpusStatsType, + UserImportType, ) from opencontractserver.analyzer.models import Analysis, Analyzer, GremlinEngine from opencontractserver.annotations.models import ( @@ -952,10 +953,7 @@ def resolve_registered_extract_tasks(self, info, **kwargs): if task.startswith("opencontractserver.tasks.data_extract_tasks") } - corpus_stats = graphene.Field( - CorpusStatsType, - corpus_id=graphene.ID(required=True) - ) + corpus_stats = graphene.Field(CorpusStatsType, corpus_id=graphene.ID(required=True)) def resolve_corpus_stats(self, info, corpus_id): @@ -966,7 +964,9 @@ def resolve_corpus_stats(self, info, corpus_id): total_extracts = 0 corpus_pk = from_global_id(corpus_id)[1] - corpuses = Corpus.objects.visible_to_user(info.context.user).filter(id=corpus_pk) + corpuses = Corpus.objects.visible_to_user(info.context.user).filter( + id=corpus_pk + ) if corpuses.count() == 1: corpus = corpuses[0] @@ -980,13 +980,12 @@ def resolve_corpus_stats(self, info, corpus_id): return CorpusStatsType( total_docs=total_docs, - total_annotations = total_annotations, - total_comments = total_comments, - total_analyses = total_analyses, - total_extracts = total_extracts + total_annotations=total_annotations, + total_comments=total_comments, + total_analyses=total_analyses, + total_extracts=total_extracts, ) - document_corpus_actions = graphene.Field( DocumentCorpusActionsType, document_id=graphene.ID(required=True), diff --git a/opencontractserver/feedback/models.py b/opencontractserver/feedback/models.py index 1c131a7f..cea5f2be 100644 --- a/opencontractserver/feedback/models.py +++ b/opencontractserver/feedback/models.py @@ -3,9 +3,9 @@ from guardian.models import GroupObjectPermissionBase, UserObjectPermissionBase from opencontractserver.annotations.models import Annotation -from opencontractserver.shared.Managers import UserFeedbackManager from opencontractserver.shared.defaults import jsonfield_default_value from opencontractserver.shared.fields import NullableJSONField +from opencontractserver.shared.Managers import UserFeedbackManager from opencontractserver.shared.Models import BaseOCModel diff --git a/opencontractserver/shared/Models.py b/opencontractserver/shared/Models.py index 864c9d9c..9a3c8738 100644 --- a/opencontractserver/shared/Models.py +++ b/opencontractserver/shared/Models.py @@ -2,7 +2,6 @@ from django.contrib.auth import get_user_model from django.db import models from django.db.models import Manager -from django.db import models from opencontractserver.shared.QuerySets import PermissionQuerySet @@ -37,7 +36,7 @@ class Meta: null=True, blank=True, related_name="locked_%(class)s_objects", - db_index=True + db_index=True, ) # This should be set to true if a long-running job is set on a model (e.g. change permissions or delete) backend_lock = django.db.models.BooleanField(default=False) @@ -49,7 +48,7 @@ class Meta: on_delete=django.db.models.CASCADE, null=False, blank=False, - db_index=True + db_index=True, ) # Timing variables diff --git a/opencontractserver/shared/QuerySets.py b/opencontractserver/shared/QuerySets.py index 1c30f409..45882e92 100644 --- a/opencontractserver/shared/QuerySets.py +++ b/opencontractserver/shared/QuerySets.py @@ -1,9 +1,11 @@ -from django.db import models from django.contrib.auth import get_user_model +from django.db import models + +# from guardian.models import UserObjectPermission, GroupObjectPermission +from django.db.models import Exists, OuterRef, Q + # from django.contrib.contenttypes.models import ContentType from django.utils import timezone -# from guardian.models import UserObjectPermission, GroupObjectPermission -from django.db.models import Q, Exists, OuterRef from tree_queries.query import TreeQuerySet User = get_user_model() @@ -24,7 +26,7 @@ def recent(self, days=30): return self.filter(created__gte=recent_date) def with_comments(self): - return self.exclude(comment='') + return self.exclude(comment="") def by_creator(self, creator): return self.filter(creator=creator) @@ -39,14 +41,9 @@ def visible_to_user(self, user): return self.all() if user.is_anonymous: - queryset = self.filter( - Q(is_public=True) - ).distinct() + queryset = self.filter(Q(is_public=True)).distinct() else: - queryset = self.filter( - Q(creator=user) | - Q(is_public=True) - ).distinct() + queryset = self.filter(Q(creator=user) | Q(is_public=True)).distinct() return queryset.with_tree_fields() @@ -69,35 +66,36 @@ def recent(self, days=30): return self.filter(created__gte=recent_date) def with_comments(self): - return self.exclude(comment='') + return self.exclude(comment="") def by_creator(self, creator): return self.filter(creator=creator) def visible_to_user(self, user): - from opencontractserver.annotations.models import Annotation # Import here to avoid circular imports + from opencontractserver.annotations.models import ( # Import here to avoid circular imports + Annotation, + ) if user.is_superuser: return self.all() if user.is_anonymous: - return self.filter( - Q(is_public=True) - ).distinct() + return self.filter(Q(is_public=True)).distinct() return self.filter( - Q(creator=user) | - Q(is_public=True) | - Q(commented_annotation__isnull=False) & - Exists(Annotation.objects.filter( - id=OuterRef('commented_annotation'), - is_public=True - )) + Q(creator=user) + | Q(is_public=True) + | Q(commented_annotation__isnull=False) + & Exists( + Annotation.objects.filter( + id=OuterRef("commented_annotation"), is_public=True + ) + ) ).distinct() class PermissionQuerySet(models.QuerySet): - def visible_to_user(self, user, perm = None): + def visible_to_user(self, user, perm=None): if user.is_superuser: return self.all() @@ -134,7 +132,7 @@ def visible_to_user(self, user, perm = None): # permission_filter = Q(has_user_perm=True) | Q(has_group_perm=True) | Q(is_public=True) permission_filter = Q(is_public=True) if not user.is_anonymous: - permission_filter |= Q(creator=user) + permission_filter |= Q(creator=user) # # Add extra conditions based on permission type # if perm == 'read': diff --git a/opencontractserver/tasks/permissioning_tasks.py b/opencontractserver/tasks/permissioning_tasks.py index 8ba78824..486b5b5b 100644 --- a/opencontractserver/tasks/permissioning_tasks.py +++ b/opencontractserver/tasks/permissioning_tasks.py @@ -1,7 +1,11 @@ # Copyright (C) 2022 John Scrudato from config import celery_app -from opencontractserver.utils.sharing import MakePublicReturnType, make_analysis_public, make_corpus_public +from opencontractserver.utils.sharing import ( + MakePublicReturnType, + make_analysis_public, + make_corpus_public, +) @celery_app.task() diff --git a/opencontractserver/tests/test_permissioning.py b/opencontractserver/tests/test_permissioning.py index ed95368b..43a84ea1 100644 --- a/opencontractserver/tests/test_permissioning.py +++ b/opencontractserver/tests/test_permissioning.py @@ -26,11 +26,10 @@ make_corpus_public_task, ) from opencontractserver.types.enums import PermissionTypes - from opencontractserver.utils.permissioning import ( get_users_permissions_for_obj, set_permissions_for_obj_to_user, - user_has_permission_for_obj + user_has_permission_for_obj, ) from .fixtures import SAMPLE_PDF_FILE_ONE_PATH @@ -184,14 +183,20 @@ def __test_query_efficient_filtering(self): for i in range(5): with transaction.atomic(): corpus = Corpus.objects.create( - title=f"Test Corpus {i}", creator=self.superuser, backend_lock=False + title=f"Test Corpus {i}", + creator=self.superuser, + backend_lock=False, ) # Assign different permissions to different corpuses if i % 3 == 0: - set_permissions_for_obj_to_user(self.user, corpus, [PermissionTypes.READ]) + set_permissions_for_obj_to_user( + self.user, corpus, [PermissionTypes.READ] + ) elif i % 3 == 1: - set_permissions_for_obj_to_user(self.user_2, corpus, [PermissionTypes.READ]) + set_permissions_for_obj_to_user( + self.user_2, corpus, [PermissionTypes.READ] + ) else: corpus.is_public = True corpus.save() @@ -200,54 +205,73 @@ def __test_query_efficient_filtering(self): all_corpuses = Corpus.objects.all() # Use the new 'for_user' method with 'read' permission - user1_readable_corpuses = Corpus.objects.for_user(self.user, perm='read') + user1_readable_corpuses = Corpus.objects.for_user(self.user, perm="read") logger.info(f"User 1 can read {user1_readable_corpuses.count()} corpuses") self.assertTrue(user1_readable_corpuses.count() > 0) for corpus in user1_readable_corpuses: self.assertTrue( - corpus.is_public or - user_has_permission_for_obj(self.user, corpus, PermissionTypes.READ) + corpus.is_public + or user_has_permission_for_obj( + self.user, corpus, PermissionTypes.READ + ) ) # Test filtering for user 2 - user2_readable_corpuses = Corpus.objects.for_user(self.user_2, perm='read') + user2_readable_corpuses = Corpus.objects.for_user(self.user_2, perm="read") logger.info(f"User 2 can read {user2_readable_corpuses.count()} corpuses") self.assertTrue(user2_readable_corpuses.count() > 0) for corpus in user2_readable_corpuses: self.assertTrue( - corpus.is_public or - user_has_permission_for_obj(self.user_2, corpus, PermissionTypes.READ) + corpus.is_public + or user_has_permission_for_obj( + self.user_2, corpus, PermissionTypes.READ + ) ) # Test filtering for superuser - superuser_readable_corpuses = Corpus.objects.for_user(self.superuser, perm='read') + superuser_readable_corpuses = Corpus.objects.for_user( + self.superuser, perm="read" + ) - logger.info(f"Superuser can read {superuser_readable_corpuses.count()} corpuses") - self.assertEqual(superuser_readable_corpuses.count(), Corpus.objects.count()) + logger.info( + f"Superuser can read {superuser_readable_corpuses.count()} corpuses" + ) + self.assertEqual( + superuser_readable_corpuses.count(), Corpus.objects.count() + ) # Test that the filtered querysets are different for different users - self.assertNotEqual(set(user1_readable_corpuses), set(user2_readable_corpuses)) + self.assertNotEqual( + set(user1_readable_corpuses), set(user2_readable_corpuses) + ) # Test performance import time # Measure time for the efficient filtering using 'for_user' method start_time = time.time() - Corpus.objects.for_user(self.user, perm='read') + Corpus.objects.for_user(self.user, perm="read") end_time = time.time() - logger.info(f"Time taken for efficient filtering: {end_time - start_time} seconds") + logger.info( + f"Time taken for efficient filtering: {end_time - start_time} seconds" + ) # Compare with a naive approach start_time = time.time() - naive_filtered = [corpus for corpus in all_corpuses if - corpus.is_public or - user_has_permission_for_obj(self.user, corpus, PermissionTypes.READ)] + naive_filtered = [ + corpus + for corpus in all_corpuses + if corpus.is_public + or user_has_permission_for_obj(self.user, corpus, PermissionTypes.READ) + ] end_time = time.time() - logger.info(f"Time taken for naive filtering: {end_time - start_time} seconds") + logger.info( + f"Time taken for naive filtering: {end_time - start_time} seconds" + ) # Assert that both methods return the same results self.assertEqual(set(user1_readable_corpuses), set(naive_filtered)) @@ -816,49 +840,45 @@ def test_permissions(self): def test_user_feedback_visibility(self): logger.info("----- TEST USER FEEDBACK VISIBILITY -----") - from opencontractserver.feedback.models import UserFeedback from opencontractserver.annotations.models import Annotation + from opencontractserver.feedback.models import UserFeedback # Create UserFeedback objects with different visibility settings with transaction.atomic(): # Feedback created by user1, not public feedback1 = UserFeedback.objects.create( - creator=self.user, - comment="Feedback 1", - is_public=False + creator=self.user, comment="Feedback 1", is_public=False ) # Feedback created by user2, public feedback2 = UserFeedback.objects.create( - creator=self.user_2, - comment="Feedback 2", - is_public=True + creator=self.user_2, comment="Feedback 2", is_public=True ) # Feedback with public annotation public_annotation = Annotation.objects.create( creator=self.superuser, document=self.corpus.documents.first(), - is_public=True + is_public=True, ) feedback3 = UserFeedback.objects.create( creator=self.superuser, comment="Feedback 3", is_public=False, - commented_annotation=public_annotation + commented_annotation=public_annotation, ) # Feedback with private annotation private_annotation = Annotation.objects.create( creator=self.superuser, document=self.corpus.documents.first(), - is_public=False + is_public=False, ) feedback4 = UserFeedback.objects.create( creator=self.superuser, comment="Feedback 4", is_public=False, - commented_annotation=private_annotation + commented_annotation=private_annotation, ) # Test visibility for user1 @@ -878,12 +898,16 @@ def test_user_feedback_visibility(self): logger.info(f"User2 can see {visible_feedback_user2.count()} feedback items") # Test visibility for superuser - visible_feedback_superuser = UserFeedback.objects.visible_to_user(self.superuser) + visible_feedback_superuser = UserFeedback.objects.visible_to_user( + self.superuser + ) self.assertIn(feedback1, visible_feedback_superuser) self.assertIn(feedback2, visible_feedback_superuser) self.assertIn(feedback3, visible_feedback_superuser) self.assertIn(feedback4, visible_feedback_superuser) - logger.info(f"Superuser can see {visible_feedback_superuser.count()} feedback items") + logger.info( + f"Superuser can see {visible_feedback_superuser.count()} feedback items" + ) # Test that the filtered querysets are different for different users self.assertNotEqual(set(visible_feedback_user1), set(visible_feedback_user2)) @@ -896,15 +920,22 @@ def test_user_feedback_visibility(self): UserFeedback.objects.visible_to_user(self.user) end_time = time.time() - logger.info(f"Time taken for efficient filtering: {end_time - start_time} seconds") + logger.info( + f"Time taken for efficient filtering: {end_time - start_time} seconds" + ) # Compare with a naive approach start_time = time.time() all_feedback = UserFeedback.objects.all() naive_filtered = [ - feedback for feedback in all_feedback - if feedback.creator == self.user or feedback.is_public or - (feedback.commented_annotation and feedback.commented_annotation.is_public) + feedback + for feedback in all_feedback + if feedback.creator == self.user + or feedback.is_public + or ( + feedback.commented_annotation + and feedback.commented_annotation.is_public + ) ] end_time = time.time() diff --git a/opencontractserver/utils/permissioning.py b/opencontractserver/utils/permissioning.py index 1c024870..3ff058b1 100644 --- a/opencontractserver/utils/permissioning.py +++ b/opencontractserver/utils/permissioning.py @@ -9,7 +9,6 @@ from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from django.db import transaction -from django.db.models import Q from guardian.shortcuts import assign_perm from config.graphql.permissioning.permission_annotator.middleware import combine @@ -316,5 +315,3 @@ def user_has_permission_for_obj( ) else: return False - - diff --git a/opencontractserver/utils/sharing.py b/opencontractserver/utils/sharing.py index cfb0fbae..079c9e8b 100644 --- a/opencontractserver/utils/sharing.py +++ b/opencontractserver/utils/sharing.py @@ -8,10 +8,14 @@ from django.db.models import Q from opencontractserver.analyzer.models import Analysis, Analyzer -from opencontractserver.annotations.models import AnnotationLabel, Annotation, Relationship +from opencontractserver.annotations.models import ( + Annotation, + AnnotationLabel, + Relationship, +) from opencontractserver.corpuses.models import Corpus, CorpusQuery from opencontractserver.documents.models import Document, DocumentAnalysisRow -from opencontractserver.extracts.models import Extract, Datacell, Fieldset +from opencontractserver.extracts.models import Datacell, Extract, Fieldset User = get_user_model() logger = logging.getLogger(__name__) From 532ccf36b3cc6caaf5091cd694d06e55b6c41564 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Sun, 15 Sep 2024 22:19:20 -0700 Subject: [PATCH 14/18] Get comments for analyzer annotations. Turn off graphiql interface when DEBUG is False. --- config/urls.py | 2 +- frontend/src/graphql/queries.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/config/urls.py b/config/urls.py index 5bf597c1..1e25463d 100644 --- a/config/urls.py +++ b/config/urls.py @@ -14,7 +14,7 @@ urlpatterns = [ path(settings.ADMIN_URL, admin.site.urls), - path("graphql/", csrf_exempt(GraphQLView.as_view(graphiql=True))), + path("graphql/", csrf_exempt(GraphQLView.as_view(graphiql=settings.DEBUG))), *( [] if not settings.USE_ANALYZER diff --git a/frontend/src/graphql/queries.ts b/frontend/src/graphql/queries.ts index eaa3be11..11190bf9 100644 --- a/frontend/src/graphql/queries.ts +++ b/frontend/src/graphql/queries.ts @@ -1558,6 +1558,16 @@ export const GET_ANNOTATIONS_FOR_ANALYSIS = gql` rawText tokensJsons json + userFeedback { + edges { + node { + id + approved + rejected + } + } + totalCount + } allSourceNodeInRelationship { id relationshipLabel { From 17d32622026a26d7dfc723d1ba20f491be171d03 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Sun, 15 Sep 2024 22:24:16 -0700 Subject: [PATCH 15/18] Added a psuedo element to the pulsing dot so it's easier to click on mobile. --- .../components/widgets/buttons/RadialButtonCloud.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx b/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx index b57146ad..ed4eb720 100644 --- a/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx +++ b/frontend/src/components/widgets/buttons/RadialButtonCloud.tsx @@ -35,6 +35,16 @@ const PulsingDot = styled.div` animation: ${pulse} 2s infinite; cursor: pointer; position: relative; + + &::before { + content: ""; + position: absolute; + top: -10px; + left: -10px; + right: -10px; + bottom: -10px; + border-radius: 50%; + } `; const CloudContainer = styled.div` From 6cfb92609f26ed6e3c7a4e4a99a31685df2e0ea9 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Mon, 16 Sep 2024 05:42:45 -0700 Subject: [PATCH 16/18] Added some additional tests. --- config/graphql/graphene_types.py | 10 + config/graphql/mutations.py | 7 +- config/graphql/queries.py | 7 +- config/settings/base.py | 1 - config/settings/production.py | 2 - config/settings/test.py | 5 - .../components/corpuses/CorpusDashboard.tsx | 1 + opencontractserver/annotations/models.py | 9 - opencontractserver/documents/models.py | 10 - .../tasks/data_extract_tasks.py | 1 - .../tasks/extract_orchestrator_tasks.py | 1 - opencontractserver/tasks/query_tasks.py | 3 - .../tests/test_column_mutations.py | 1 - .../tests/test_corpus_import.py | 1 - opencontractserver/tests/test_corpus_query.py | 1 - .../tests/test_custom_permission_filters.py | 4 +- .../tests/test_extract_tasks.py | 1 - .../test_graphql_import_export_mutations.py | 2 - opencontractserver/tests/test_pdf_utils.py | 2 +- .../tests/test_permissioned_querysets.py | 276 ++++++++++++++++++ .../tests/test_query_mutations.py | 1 - opencontractserver/users/tasks.py | 29 +- schema.graphql | 129 ++++++-- 23 files changed, 407 insertions(+), 97 deletions(-) create mode 100644 opencontractserver/tests/test_permissioned_querysets.py diff --git a/config/graphql/graphene_types.py b/config/graphql/graphene_types.py index d41adb65..a7257b42 100644 --- a/config/graphql/graphene_types.py +++ b/config/graphql/graphene_types.py @@ -93,6 +93,16 @@ class Meta: # in the Graphene type filterset_class = AnnotationFilter + @classmethod + def get_queryset(cls, queryset, info): + if issubclass(type(queryset), QuerySet): + return queryset.visible_to_user(info.context.user) + elif "RelatedManager" in str(type(queryset)): + # https://stackoverflow.com/questions/11320702/import-relatedmanager-from-django-db-models-fields-related + return queryset.all().visible_to_user(info.context.user) + else: + return queryset + class PdfPageInfoType(graphene.ObjectType): page_count = graphene.Int() diff --git a/config/graphql/mutations.py b/config/graphql/mutations.py index 80319946..5611d700 100644 --- a/config/graphql/mutations.py +++ b/config/graphql/mutations.py @@ -1512,8 +1512,6 @@ class Arguments: @login_required def mutate(root, info, analyzer_id, document_id=None, corpus_id=None): - print(f"StartDocumentAnalysisMutation - document_id is {document_id}") - user = info.context.user document_pk = from_global_id(document_id)[1] if document_id else None @@ -1889,12 +1887,10 @@ def mutate( ) if fieldset_id is not None: - print(f"Fieldset id is not None: {fieldset_id}") fieldset = Fieldset.objects.get(pk=from_global_id(fieldset_id)[1]) else: if fieldset_name is None: fieldset_name = f"{name} Fieldset" - print(f"Creating new fieldset... name will be: {fieldset_name}") fieldset = Fieldset.objects.create( name=fieldset_name, @@ -1914,13 +1910,12 @@ def mutate( creator=info.context.user, ) extract.save() - print(f"Extract created: {extract}") if corpus is not None: # print(f"Try to add corpus docs: {corpus.documents.all()}") extract.documents.add(*corpus.documents.all()) else: - print("Corpus IS still None... no docs to add.") + logger.info("Corpus IS still None... no docs to add.") set_permissions_for_obj_to_user( info.context.user, extract, [PermissionTypes.CRUD] diff --git a/config/graphql/queries.py b/config/graphql/queries.py index 99dce56d..c181a885 100644 --- a/config/graphql/queries.py +++ b/config/graphql/queries.py @@ -1003,11 +1003,9 @@ def resolve_document_corpus_actions(self, info, document_id, corpus_id=None): if corpus_id is not None: corpus_pk = from_global_id(corpus_id)[1] corpus = Corpus.objects.get(id=corpus_pk) - print(f"Corpus id wasn't none. Retrieved corpus {corpus}") corpus_actions = CorpusAction.objects.filter( Q(corpus=corpus), Q(creator=user) | Q(is_public=True) ) - print(f"Corpus action retrieved: {corpus_actions}") else: corpus = None @@ -1017,18 +1015,15 @@ def resolve_document_corpus_actions(self, info, document_id, corpus_id=None): document = Document.objects.get( Q(id=document_pk), Q(creator=user) | Q(is_public=True) ) - print(f"Document: {document}") extracts = document.extracts.filter( Q(is_public=True) | Q(creator=user), corpus=corpus ) - print(f"Extracts:{extracts}") analysis_rows = document.rows.filter( Q(analysis__is_public=True) | Q(analysis__creator=user) ) - print(f"analysis_rows rows:{analysis_rows}") except Document.DoesNotExist: - print("ERROR!") + logger.error("ERROR!") extracts = [] analysis_rows = [] diff --git a/config/settings/base.py b/config/settings/base.py index fdf33e93..4e63dbcb 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -24,7 +24,6 @@ ALLOWED_HOSTS = env.list( "DJANGO_ALLOWED_HOSTS", default=["localhost", "0.0.0.0", "127.0.0.1"] ) -print(f"Open Contracts allowed hosts: {ALLOWED_HOSTS}") # https://docs.djangoproject.com/en/dev/ref/settings/#debug diff --git a/config/settings/production.py b/config/settings/production.py index c8f183ac..cc2c36ee 100644 --- a/config/settings/production.py +++ b/config/settings/production.py @@ -9,7 +9,6 @@ ALLOWED_HOSTS = env.list( "DJANGO_ALLOWED_HOSTS", default=["opencontracts.opensource.legal"] ) -print(f"Open Contracts Production Allowed Hosts: {ALLOWED_HOSTS}") CSRF_TRUSTED_ORIGINS = [ "https://*.opensource.legal", @@ -17,7 +16,6 @@ "https://opencontracts.opensource.legal", "https://opencontracts.opensource.legal/" "admin/login", ] -print(f"Open Contracts Production CSRF Trusted Origins: {CSRF_TRUSTED_ORIGINS}") # DATABASES # ------------------------------------------------------------------------------ diff --git a/config/settings/test.py b/config/settings/test.py index 1b783feb..9a98fa25 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -44,8 +44,3 @@ CELERY_BROKER_URL = "memory://" CELERY_RESULT_BACKEND = "cache" CELERY_CACHE_BACKEND = "memory" - -# Ensure these settings are applied -print("Test settings loaded. CELERY_TASK_ALWAYS_EAGER:", CELERY_TASK_ALWAYS_EAGER) -print("CELERY_BROKER_URL:", CELERY_BROKER_URL) -print("CELERY_RESULT_BACKEND:", CELERY_RESULT_BACKEND) diff --git a/frontend/src/components/corpuses/CorpusDashboard.tsx b/frontend/src/components/corpuses/CorpusDashboard.tsx index 027b5353..bdd9430a 100644 --- a/frontend/src/components/corpuses/CorpusDashboard.tsx +++ b/frontend/src/components/corpuses/CorpusDashboard.tsx @@ -73,6 +73,7 @@ export const CorpusDashboard: React.FC = ({ onCompleted: (data) => { setStats(data.corpusStats); }, + fetchPolicy: "network-only", }); const [sendQuery] = useMutation< diff --git a/opencontractserver/annotations/models.py b/opencontractserver/annotations/models.py index 46b2a5df..e34f57ff 100644 --- a/opencontractserver/annotations/models.py +++ b/opencontractserver/annotations/models.py @@ -298,15 +298,6 @@ class Meta: django.db.models.Index(fields=["modified"]), ] - # Override save to update modified on save - def save(self, *args, **kwargs): - """On save, update timestamps""" - if not self.pk: - self.created = timezone.now() - self.modified = timezone.now() - - return super().save(*args, **kwargs) - # Model for Django Guardian permissions. class AnnotationUserObjectPermission(UserObjectPermissionBase): diff --git a/opencontractserver/documents/models.py b/opencontractserver/documents/models.py index 95315a03..31876ede 100644 --- a/opencontractserver/documents/models.py +++ b/opencontractserver/documents/models.py @@ -2,7 +2,6 @@ import django from django.core.exceptions import ValidationError -from django.utils import timezone from guardian.models import GroupObjectPermissionBase, UserObjectPermissionBase from pgvector.django import VectorField @@ -77,15 +76,6 @@ class Meta: django.db.models.Index(fields=["modified"]), ] - # Override save to update modified on save - def save(self, *args, **kwargs): - """On save, update timestamps""" - if not self.pk: - self.created = timezone.now() - self.modified = timezone.now() - - return super().save(*args, **kwargs) - def __str__(self): """ String representation method diff --git a/opencontractserver/tasks/data_extract_tasks.py b/opencontractserver/tasks/data_extract_tasks.py index f58d4052..b7de5811 100644 --- a/opencontractserver/tasks/data_extract_tasks.py +++ b/opencontractserver/tasks/data_extract_tasks.py @@ -36,7 +36,6 @@ def oc_llama_index_doc_query(cell_id, similarity_top_k=15, max_token_length: int """ datacell = Datacell.objects.get(id=cell_id) - print(f"Process datacell {datacell}") try: diff --git a/opencontractserver/tasks/extract_orchestrator_tasks.py b/opencontractserver/tasks/extract_orchestrator_tasks.py index c281bac5..9ac95d74 100644 --- a/opencontractserver/tasks/extract_orchestrator_tasks.py +++ b/opencontractserver/tasks/extract_orchestrator_tasks.py @@ -39,7 +39,6 @@ def run_extract(extract_id: Optional[str | int], user_id: str | int): fieldset = extract.fieldset document_ids = extract.documents.all().values_list("id", flat=True) - print(f"Run extract {extract_id} over document ids {document_ids}") tasks = [] for document_id in document_ids: diff --git a/opencontractserver/tasks/query_tasks.py b/opencontractserver/tasks/query_tasks.py index d753eef8..663fc322 100644 --- a/opencontractserver/tasks/query_tasks.py +++ b/opencontractserver/tasks/query_tasks.py @@ -32,13 +32,10 @@ def run_query( llm = OpenAI(model=settings.OPENAI_MODEL, api_key=settings.OPENAI_API_KEY) Settings.llm = llm - print("Setting up vector store...") vector_store = DjangoAnnotationVectorStore.from_params( corpus_id=query.corpus.id ) - print(f"Vector store: {vector_store}") index = VectorStoreIndex.from_vector_store(vector_store=vector_store) - print(f"Index: {index}") query_engine = CitationQueryEngine.from_args( index, diff --git a/opencontractserver/tests/test_column_mutations.py b/opencontractserver/tests/test_column_mutations.py index 15f702e0..d19f9499 100644 --- a/opencontractserver/tests/test_column_mutations.py +++ b/opencontractserver/tests/test_column_mutations.py @@ -68,7 +68,6 @@ def test_update_column_mutation(self): result = self.client.execute(mutation) self.assertIsNone(result.get("errors")) - print(result.get("data")) self.assertTrue(result["data"]["updateColumn"]["ok"]) updated_column = Column.objects.get(id=self.column.id) diff --git a/opencontractserver/tests/test_corpus_import.py b/opencontractserver/tests/test_corpus_import.py index d5f5db62..34bffda3 100644 --- a/opencontractserver/tests/test_corpus_import.py +++ b/opencontractserver/tests/test_corpus_import.py @@ -30,7 +30,6 @@ def test_import(self): "# TEST CORPUS IMPORT PIPELINE #########################################################################" ) - print("1)\tLoad test zip into base64 string") export_zip_base64_file_string = package_zip_into_base64( self.fixtures_path / "Test_Corpus_EXPORT.zip" ) diff --git a/opencontractserver/tests/test_corpus_query.py b/opencontractserver/tests/test_corpus_query.py index 7dfa6a07..8426ca08 100644 --- a/opencontractserver/tests/test_corpus_query.py +++ b/opencontractserver/tests/test_corpus_query.py @@ -71,7 +71,6 @@ def setUp(self): ) def test_run_query(self): - print(self.query) # Call the run_query task run_query.delay(query_id=self.query.id) diff --git a/opencontractserver/tests/test_custom_permission_filters.py b/opencontractserver/tests/test_custom_permission_filters.py index 792fc781..3a3531f0 100644 --- a/opencontractserver/tests/test_custom_permission_filters.py +++ b/opencontractserver/tests/test_custom_permission_filters.py @@ -267,8 +267,10 @@ def test_permission_change(self): ) # Test again for user2 + # NOTE - we are not filtering on per-instance level permissions YET, so preceding permission won't affect + # returned values. result2 = self.client2.execute(query) - self.assertEqual(len(result2["data"]["corpuses"]["edges"]), 2) + self.assertEqual(len(result2["data"]["corpuses"]["edges"]), 1) titles = [ edge["node"]["title"] for edge in result2["data"]["corpuses"]["edges"] ] diff --git a/opencontractserver/tests/test_extract_tasks.py b/opencontractserver/tests/test_extract_tasks.py index 769e73f9..25713e0a 100644 --- a/opencontractserver/tests/test_extract_tasks.py +++ b/opencontractserver/tests/test_extract_tasks.py @@ -145,7 +145,6 @@ def setUp(self): filter_headers=["authorization"], ) def test_run_extract_task(self): - print(f"{self.extract.documents.all()}") # Run this SYNCHRONOUSLY for TESTIN' purposes run_extract.delay(self.extract.id, self.user.id) diff --git a/opencontractserver/tests/test_graphql_import_export_mutations.py b/opencontractserver/tests/test_graphql_import_export_mutations.py index edec0292..d0d1b2e3 100644 --- a/opencontractserver/tests/test_graphql_import_export_mutations.py +++ b/opencontractserver/tests/test_graphql_import_export_mutations.py @@ -184,7 +184,5 @@ def test_import_document_to_corpus_mutation(self): response = client.execute(mutation, variables=variables) - print(f"Test response: {response}") - assert response["data"]["importAnnotatedDocToCorpus"]["ok"] is True assert response["data"]["importAnnotatedDocToCorpus"]["message"] == "SUCCESS" diff --git a/opencontractserver/tests/test_pdf_utils.py b/opencontractserver/tests/test_pdf_utils.py index 62fe2ae1..be22ebaa 100644 --- a/opencontractserver/tests/test_pdf_utils.py +++ b/opencontractserver/tests/test_pdf_utils.py @@ -60,7 +60,7 @@ def test_split_pdf_into_images(self): with tempfile.TemporaryDirectory() as temp_dir: # Call the function result = split_pdf_into_images(self.need_ocr_pdf_content, temp_dir) - print(f"Result: {result}") + # Check the results self.assertEqual(len(result), 1) self.assertTrue(all(path.endswith(".png") for path in result)) diff --git a/opencontractserver/tests/test_permissioned_querysets.py b/opencontractserver/tests/test_permissioned_querysets.py new file mode 100644 index 00000000..8d762740 --- /dev/null +++ b/opencontractserver/tests/test_permissioned_querysets.py @@ -0,0 +1,276 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser +from django.test import TestCase +from graphene.test import Client +from graphql_relay import to_global_id + +from config.graphql.schema import schema +from opencontractserver.annotations.models import Annotation +from opencontractserver.corpuses.models import Corpus +from opencontractserver.documents.models import Document +from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user + +User = get_user_model() + + +class TestContext: + def __init__(self, user): + self.user = user + + +class ComprehensivePermissionTestCase(TestCase): + def setUp(self): + # Create users + self.owner = User.objects.create_user(username="owner", password="password") + self.collaborator = User.objects.create_user( + username="collaborator", password="password" + ) + self.regular_user = User.objects.create_user( + username="regular", password="password" + ) + self.anonymous_user = None + + # Create GraphQL clients + self.owner_client = Client(schema, context_value=TestContext(self.owner)) + self.collaborator_client = Client( + schema, context_value=TestContext(self.collaborator) + ) + self.regular_client = Client( + schema, context_value=TestContext(self.regular_user) + ) + self.anonymous_client = Client( + schema, context_value=TestContext(AnonymousUser()) + ) + + # Create Corpuses + self.public_corpus = Corpus.objects.create( + title="Public Corpus", creator=self.owner, is_public=True + ) + self.private_corpus = Corpus.objects.create( + title="Private Corpus", creator=self.owner, is_public=False + ) + self.shared_corpus = Corpus.objects.create( + title="Shared Corpus", creator=self.owner, is_public=False + ) + + # Set permissions for shared corpus + set_permissions_for_obj_to_user( + self.collaborator, self.shared_corpus, [PermissionTypes.READ] + ) + + # Create Documents + self.public_doc = Document.objects.create( + title="Public Doc", creator=self.owner, is_public=True + ) + self.private_doc = Document.objects.create( + title="Private Doc", creator=self.owner, is_public=False + ) + self.public_corpus.documents.add(self.public_doc, self.private_doc) + + # Create Annotations + self.public_annotation = Annotation.objects.create( + document=self.public_doc, creator=self.owner, is_public=True + ) + self.private_annotation = Annotation.objects.create( + document=self.public_doc, creator=self.owner, is_public=False + ) + + def test_corpus_visibility(self): + query = """ + query { + corpuses { + edges { + node { + id + title + isPublic + } + } + } + } + """ + + # Test for owner + result = self.owner_client.execute(query) + self.assertEqual(len(result["data"]["corpuses"]["edges"]), 3) + + # Test for collaborator - AT THE MOMENT, PER INSTANCE PERMISSIONS ARE NOT USED ON QUERY RETRIEVAL. + # THIS IS FOR SAFETY. We are moving the insetance-leval permission filter into GraphqlObjectType + # get_queryset(...) to ensure safety on nested FKs and M2M. + result = self.collaborator_client.execute(query) + self.assertEqual(len(result["data"]["corpuses"]["edges"]), 1) + + # Test for regular user + result = self.regular_client.execute(query) + self.assertEqual(len(result["data"]["corpuses"]["edges"]), 1) + + # Test for anonymous user + result = self.anonymous_client.execute(query) + self.assertEqual(len(result["data"]["corpuses"]["edges"]), 1) + + def test_nested_document_visibility(self): + query = """ + query($id: ID!) { + corpus(id: $id) { + documents { + edges { + node { + id + title + isPublic + } + } + } + } + } + """ + variables = {"id": to_global_id("CorpusType", self.public_corpus.id)} + + # Test for owner + result = self.owner_client.execute(query, variable_values=variables) + self.assertEqual(len(result["data"]["corpus"]["documents"]["edges"]), 2) + + # Test for regular user + result = self.regular_client.execute(query, variable_values=variables) + self.assertEqual(len(result["data"]["corpus"]["documents"]["edges"]), 1) + + def test_nested_annotation_visibility(self): + query = """ + query($id: String!) { + document(id: $id) { + docAnnotations { + edges { + node { + id + isPublic + } + } + } + } + } + """ + variables = {"id": to_global_id("DocumentType", self.public_doc.id)} + + # Test for owner + result = self.owner_client.execute(query, variable_values=variables) + self.assertEqual(len(result["data"]["document"]["docAnnotations"]["edges"]), 2) + + # Test for regular user + result = self.regular_client.execute(query, variable_values=variables) + self.assertEqual(len(result["data"]["document"]["docAnnotations"]["edges"]), 1) + + def test_mutation_permissions(self): + mutation = """ + mutation($id: String!) { + deleteCorpus(id: $id) { + ok + message + } + } + """ + corpus_to_delete = Corpus.objects.create( + title="Corpus to Delete", creator=self.owner, is_public=True + ) + + # Deletions ARE tied to per instance permissions. + set_permissions_for_obj_to_user( + self.owner.id, corpus_to_delete, [PermissionTypes.CRUD] + ) + variables = {"id": to_global_id("CorpusType", corpus_to_delete.id)} + + # Test for regular user (should fail) + result = self.regular_client.execute(mutation, variable_values=variables) + self.assertIsNone(result["data"]["deleteCorpus"]) + self.assertIn("errors", result) + + # Verify corpus still exists in database + self.assertTrue(Corpus.objects.filter(id=corpus_to_delete.id).exists()) + + # Test for owner (should succeed) + result = self.owner_client.execute(mutation, variable_values=variables) + + # Verify corpus is actually deleted from database + self.assertFalse(Corpus.objects.filter(id=corpus_to_delete.id).exists()) + + def test_mutation_permissions_on_private_object(self): + mutation = """ + mutation($id: String!) { + deleteCorpus(id: $id) { + ok + message + } + } + """ + private_corpus = Corpus.objects.create( + title="Private Corpus to Delete", creator=self.owner, is_public=False + ) + # Deletions ARE tied to per instance permissions. + set_permissions_for_obj_to_user( + self.owner.id, private_corpus, [PermissionTypes.CRUD] + ) + variables = {"id": to_global_id("CorpusType", private_corpus.id)} + + # Test for collaborator (should fail) + result = self.collaborator_client.execute(mutation, variable_values=variables) + self.assertIsNone(result["data"]["deleteCorpus"]) + self.assertIn("errors", result) + + # Verify corpus still exists in database + self.assertTrue(Corpus.objects.filter(id=private_corpus.id).exists()) + + # Test for owner (should succeed) + result = self.owner_client.execute(mutation, variable_values=variables) + self.assertTrue(result["data"]["deleteCorpus"]["ok"]) + + # Verify corpus is actually deleted from database + self.assertFalse(Corpus.objects.filter(id=private_corpus.id).exists()) + + def test_permission_change_effect(self): + query = """ + query($id: ID!) { + corpus(id: $id) { + id + title + } + } + """ + variables = {"id": to_global_id("CorpusType", self.private_corpus.id)} + + # Before granting permission + result = self.collaborator_client.execute(query, variable_values=variables) + self.assertIsNone(result["data"]["corpus"]) + + # Grant permission + set_permissions_for_obj_to_user( + self.collaborator, self.private_corpus, [PermissionTypes.READ] + ) + + # After granting permission + result = self.collaborator_client.execute(query, variable_values=variables) + self.assertIsNotNone(result["data"]["corpus"]) + self.assertEqual(result["data"]["corpus"]["title"], "Private Corpus") + + def test_public_flag_change_effect(self): + query = """ + query($id: ID!) { + corpus(id: $id) { + id + title + } + } + """ + variables = {"id": to_global_id("CorpusType", self.private_corpus.id)} + + # Before making public + result = self.regular_client.execute(query, variable_values=variables) + self.assertIsNone(result["data"]["corpus"]) + + # Make corpus public + self.private_corpus.is_public = True + self.private_corpus.save() + + # After making public + result = self.regular_client.execute(query, variable_values=variables) + self.assertIsNotNone(result["data"]["corpus"]) + self.assertEqual(result["data"]["corpus"]["title"], "Private Corpus") diff --git a/opencontractserver/tests/test_query_mutations.py b/opencontractserver/tests/test_query_mutations.py index 9409ebe1..25a592d0 100644 --- a/opencontractserver/tests/test_query_mutations.py +++ b/opencontractserver/tests/test_query_mutations.py @@ -51,7 +51,6 @@ def test_start_query_for_corpus_mutation(self): ) result = self.client.execute(mutation) - print(f"Test query mutation result: {result}") self.assertIsNone(result.get("errors")) self.assertTrue(result["data"]["askQuery"]["ok"]) diff --git a/opencontractserver/users/tasks.py b/opencontractserver/users/tasks.py index 852297bc..1102a0a4 100644 --- a/opencontractserver/users/tasks.py +++ b/opencontractserver/users/tasks.py @@ -1,5 +1,6 @@ import datetime import json +import logging import pytz import requests @@ -15,6 +16,7 @@ User = get_user_model() +logger = logging.getLogger(__name__) # These tasks are only needed for AUTH0, so we don't define them unless we're using AUTH0 if settings.USE_AUTH0: @@ -60,7 +62,7 @@ def get_new_auth0_token(): return newToken.token else: - print("Error retrieving access token to Auth0.") + logger.error("Error retrieving access token to Auth0.") @celery_app.task() def apply_data_to_user(data, userPk): @@ -94,19 +96,16 @@ def apply_data_to_user(data, userPk): user.save() except Exception as inst: - - print("Error on syncing user:") - print(type(inst)) # the exception instance - print(inst.args) # arguments stored in .args - print(inst) + logger.error( + f"Exception applying data to user - exception: {type(inst)}" + ) + logger.error( + f"Exception applying data to user - exception args; {inst.args}" + ) @celery_app.task() def sync_remote_user(user_pk): - print( - f"Checking server token has not expired... before fetching data for {user_pk}" - ) - refresh = False tokens = Auth0APIToken.objects.all() @@ -143,19 +142,19 @@ def ensure_valid_auth0_token(): # print("No Auth0 Tokens... Request one.") return get_new_auth0_token.delay().get() elif len(tokens) > 1: - print( - "Somehow there was more than 1 token. Going to delete all and refresh" - ) + # print( + # "Somehow there was more than 1 token. Going to delete all and refresh" + # ) for tok in tokens: tok.delete() return get_new_auth0_token.delay().get() else: if tokens[0].expiration_Date < pytz.utc.localize(datetime.datetime.now()): - print("Token has expired. Refetching from Auth0") + # print("Token has expired. Refetching from Auth0") tokens[0].delete() return get_new_auth0_token.delay().get() else: - print("Token is good!") + # print("Token is good!") return tokens[0].token @celery_app.task diff --git a/schema.graphql b/schema.graphql index 9aba1571..b5225d0a 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1,27 +1,5 @@ type Query { - annotations( - offset: Int - before: String - after: String - first: Int - last: Int - rawText_Contains: String - annotationLabelId: ID - annotationLabel_Text: String - annotationLabel_Text_Contains: String - annotationLabel_Description_Contains: String - annotationLabel_LabelType: AnnotationsAnnotationLabelLabelTypeChoices - analysis_Isnull: Boolean - documentId: ID - corpusId: ID - structural: Boolean - usesLabelFromLabelsetId: String - createdByAnalysisIds: String - createdWithAnalyzerId: String - - """Ordering""" - orderBy: String - ): AnnotationTypeConnection + annotations(rawTextContains: String, annotationLabelId: ID, annotationLabel_Text: String, annotationLabel_TextContains: String, annotationLabel_DescriptionContains: String, annotationLabel_LabelType: String, analysisIsnull: Boolean, documentId: ID, corpusId: ID, structural: Boolean, usesLabelFromLabelsetId: ID, createdByAnalysisIds: String, createdWithAnalyzerId: String, orderBy: String, offset: Int, before: String, after: String, first: Int, last: Int): AnnotationTypeConnection bulkDocRelationshipsInCorpus(corpusId: ID!, documentId: ID!): [RelationshipType] bulkDocAnnotationsInCorpus(corpusId: ID!, documentId: ID, forAnalysisIds: String, labelType: LabelType): [AnnotationType] pageAnnotations(currentPage: Int, pageNumberList: String, pageContainingAnnotationWithId: ID, corpusId: ID, documentId: ID!, forAnalysisIds: String, labelType: LabelType): PageAwareAnnotationType @@ -127,6 +105,7 @@ type Query { ): DatacellType datacells(offset: Int, before: String, after: String, first: Int, last: Int, dataDefinition: String, started_Lte: DateTime, started_Gte: DateTime, completed_Lte: DateTime, completed_Gte: DateTime, failed_Lte: DateTime, failed_Gte: DateTime, inCorpusWithId: String, forDocumentWithId: String): DatacellTypeConnection registeredExtractTasks: GenericScalar + corpusStats(corpusId: ID!): CorpusStatsType documentCorpusActions(documentId: ID!, corpusId: ID): DocumentCorpusActionsType } @@ -190,6 +169,7 @@ type AnnotationType implements Node { targetNodeInRelationships(offset: Int, before: String, after: String, first: Int, last: Int): RelationshipTypeConnection! queries(offset: Int, before: String, after: String, first: Int, last: Int): CorpusQueryTypeConnection! referencingCells(offset: Int, before: String, after: String, first: Int, last: Int): DatacellTypeConnection! + userFeedback(offset: Int, before: String, after: String, first: Int, last: Int): UserFeedbackTypeConnection! myPermissions: GenericScalar isPublished: Boolean objectSharedWith: GenericScalar @@ -318,6 +298,8 @@ type UserType implements Node { rejectedCells(offset: Int, before: String, after: String, first: Int, last: Int): DatacellTypeConnection! lockedDatacellObjects(offset: Int, before: String, after: String, first: Int, last: Int): DatacellTypeConnection! datacellSet(offset: Int, before: String, after: String, first: Int, last: Int): DatacellTypeConnection! + lockedUserfeedbackObjects(offset: Int, before: String, after: String, first: Int, last: Int): UserFeedbackTypeConnection! + userfeedbackSet(offset: Int, before: String, after: String, first: Int, last: Int): UserFeedbackTypeConnection! myPermissions: GenericScalar isPublished: Boolean objectSharedWith: GenericScalar @@ -630,6 +612,7 @@ type CorpusType implements Node { icon: String documents(offset: Int, before: String, after: String, first: Int, last: Int): DocumentTypeConnection! labelSet: LabelSetType + allowComments: Boolean! isPublic: Boolean! creator: UserType! backendLock: Boolean! @@ -1113,7 +1096,7 @@ type AnalysisType implements Node { analyzer: AnalyzerType! callbackToken: UUID! receivedCallbackFile: String - analyzedCorpus: CorpusType! + analyzedCorpus: CorpusType corpusAction: CorpusActionType importLog: String analyzedDocuments(offset: Int, before: String, after: String, first: Int, last: Int): DocumentTypeConnection! @@ -1380,6 +1363,44 @@ type FieldsetTypeEdge { cursor: String! } +type UserFeedbackTypeConnection { + """Pagination data for this connection.""" + pageInfo: PageInfo! + + """Contains the nodes in this connection.""" + edges: [UserFeedbackTypeEdge]! + totalCount: Int +} + +"""A Relay edge containing a `UserFeedbackType` and its cursor.""" +type UserFeedbackTypeEdge { + """The item at the end of the edge""" + node: UserFeedbackType + + """A cursor for use in pagination""" + cursor: String! +} + +type UserFeedbackType implements Node { + """The ID of the object""" + id: ID! + userLock: UserType + backendLock: Boolean! + isPublic: Boolean! + creator: UserType! + created: DateTime! + modified: DateTime! + approved: Boolean! + rejected: Boolean! + comment: String! + markdown: String! + metadata: JSONString + commentedAnnotation: AnnotationType + myPermissions: GenericScalar + isPublished: Boolean + objectSharedWith: GenericScalar +} + """An enumeration.""" enum LabelType { DOC_TYPE_LABEL @@ -1441,6 +1462,14 @@ type GremlinEngineType_READEdge { cursor: String! } +type CorpusStatsType { + totalDocs: Int + totalAnnotations: Int + totalComments: Int + totalAnalyses: Int + totalExtracts: Int +} + type DocumentCorpusActionsType { corpusActions: [CorpusActionType] extracts: [ExtractType] @@ -1489,6 +1518,20 @@ type Mutation { """Id of the annotation that is to be deleted.""" annotationId: String! ): RemoveAnnotation + approveAnnotation( + """ID of the annotation to approve""" + annotationId: ID! + + """Optional comment for the approval""" + comment: String + ): ApproveAnnotation + rejectAnnotation( + """ID of the annotation to reject""" + annotationId: ID! + + """Optional comment for the rejection""" + comment: String + ): RejectAnnotation addRelationship( """ID of the corpus for this relationship.""" corpusId: String! @@ -1559,6 +1602,11 @@ type Mutation { labelsetId: String! = "Id of the labelset to delete the labels from" ): RemoveLabelsFromLabelsetMutation uploadDocument( + """ + If provided, successfully uploaded document will be uploaded to corpus with specified id + """ + addToCorpusId: ID + """Base64-encoded file string for the file.""" base64FileString: String! @@ -1571,6 +1619,9 @@ type Mutation { """Filename of the document.""" filename: String! + """If True, document is immediately public. Defaults to False.""" + makePublic: Boolean! + """Title of the document.""" title: String! ): UploadDocument @@ -1623,13 +1674,16 @@ type Mutation { exportFormat: ExportType ): StartCorpusExport deleteExport(id: String!): DeleteExport - startAnalysisOnCorpus( + startAnalysisOnDoc( """Id of the analyzer to use.""" analyzerId: ID! - """Id of the corpus that is to be analyzed.""" - corpusId: ID! - ): StartCorpusAnalysisMutation + """Optional Id of the corpus to associate with the analysis.""" + corpusId: ID + + """Id of the document to be analyzed.""" + documentId: ID + ): StartDocumentAnalysisMutation deleteAnalysis(id: String!): DeleteAnalysisMutation makeAnalysisPublic( """Analysis id to make public (superuser only)""" @@ -1673,6 +1727,7 @@ type Mutation { approveDatacell(datacellId: String!): ApproveDatacell rejectDatacell(datacellId: String!): RejectDatacell editDatacell(datacellId: String!, editedData: GenericScalar!): EditDatacell + startExtractForDoc(documentId: ID!, fieldsetId: ID!): StartDocumentExtract } type ObtainJSONWebTokenWithUser { @@ -1714,6 +1769,16 @@ type AddDocTypeAnnotation { annotation: AnnotationType } +type ApproveAnnotation { + ok: Boolean + userFeedback: UserFeedbackType +} + +type RejectAnnotation { + ok: Boolean + userFeedback: UserFeedbackType +} + type AddRelationship { ok: Boolean relationship: RelationshipType @@ -1889,7 +1954,7 @@ type DeleteExport { message: String } -type StartCorpusAnalysisMutation { +type StartDocumentAnalysisMutation { ok: Boolean message: String obj: AnalysisType @@ -1995,3 +2060,9 @@ type EditDatacell { message: String obj: DatacellType } + +type StartDocumentExtract { + ok: Boolean + message: String + obj: ExtractType +} From 700bc76358a50f42518e0dbb457076cf0af0557f Mon Sep 17 00:00:00 2001 From: JSv4 Date: Mon, 16 Sep 2024 06:46:49 -0700 Subject: [PATCH 17/18] Updated corpus stats test. --- .../tests/test_custom_permission_filters.py | 1 - opencontractserver/tests/test_stats.py | 214 ++++++++++++++++++ 2 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 opencontractserver/tests/test_stats.py diff --git a/opencontractserver/tests/test_custom_permission_filters.py b/opencontractserver/tests/test_custom_permission_filters.py index 3a3531f0..0d1db0d9 100644 --- a/opencontractserver/tests/test_custom_permission_filters.py +++ b/opencontractserver/tests/test_custom_permission_filters.py @@ -274,5 +274,4 @@ def test_permission_change(self): titles = [ edge["node"]["title"] for edge in result2["data"]["corpuses"]["edges"] ] - self.assertIn("Corpus 1", titles) self.assertIn("Corpus 2", titles) diff --git a/opencontractserver/tests/test_stats.py b/opencontractserver/tests/test_stats.py new file mode 100644 index 00000000..cf838eb6 --- /dev/null +++ b/opencontractserver/tests/test_stats.py @@ -0,0 +1,214 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser +from django.test import TestCase +from graphene.test import Client +from graphql_relay import to_global_id + +from config.graphql.schema import schema +from opencontractserver.analyzer.models import Analysis, Analyzer +from opencontractserver.annotations.models import Annotation +from opencontractserver.corpuses.models import Corpus +from opencontractserver.documents.models import Document +from opencontractserver.extracts.models import Extract, Fieldset +from opencontractserver.feedback.models import UserFeedback + +User = get_user_model() + + +class TestContext: + def __init__(self, user): + self.user = user + + +class CorpusStatsTestCase(TestCase): + def setUp(self): + # Create users + self.owner = User.objects.create_user(username="owner", password="password") + self.collaborator = User.objects.create_user( + username="collaborator", password="password" + ) + self.regular_user = User.objects.create_user( + username="regular", password="password" + ) + self.anonymous_user = AnonymousUser() + + # Create GraphQL clients + self.owner_client = Client(schema, context_value=TestContext(self.owner)) + self.collaborator_client = Client( + schema, context_value=TestContext(self.collaborator) + ) + self.regular_client = Client( + schema, context_value=TestContext(self.regular_user) + ) + self.anonymous_client = Client( + schema, context_value=TestContext(self.anonymous_user) + ) + + # Create a public corpus with related public objects + self.public_corpus = Corpus.objects.create( + title="Public Corpus", creator=self.owner, is_public=True + ) + + # Create a private corpus + self.private_corpus = Corpus.objects.create( + title="Private Corpus", creator=self.owner, is_public=False + ) + + # Create public documents + for i in range(5): + doc = Document.objects.create( + title=f"Public Doc {i}", creator=self.owner, is_public=True + ) + self.public_corpus.documents.add(doc) + + # Create public annotations for each document + for j in range(2): + annotation = Annotation.objects.create( + document=doc, + creator=self.owner, + is_public=True, + corpus=self.public_corpus, + ) + + # Create a comment (UserFeedback) for each annotation + UserFeedback.objects.create( + creator=self.owner, commented_annotation=annotation, is_public=True + ) + + # Create analyzer + self.analyzer = Analyzer.objects.create( + id="Task-Based Analyzer", + description="Test Task Analyzer", + task_name="test_task", + creator=self.owner, + manifest={}, + ) + + # Create public analyses + for i in range(3): + Analysis.objects.create( + creator=self.owner, + analyzed_corpus=self.public_corpus, + is_public=True, + analyzer=self.analyzer, + ) + + self.fieldset = Fieldset.objects.create( + name="TestFieldset", + description="Test description", + creator=self.owner, + ) + + # Create public extracts + for i in range(2): + Extract.objects.create( + creator=self.owner, + corpus=self.public_corpus, + is_public=True, + fieldset=self.fieldset, + ) + + def test_public_corpus_stats_query(self): + query = """ + query($id: ID!) { + corpusStats(corpusId: $id) { + totalDocs + totalAnnotations + totalComments + totalAnalyses + totalExtracts + } + } + """ + variables = {"id": to_global_id("CorpusType", self.public_corpus.id)} + + expected_stats = { + "totalDocs": 5, + "totalAnnotations": 10, # 2 annotations per document + "totalComments": 10, # 1 comment per annotation + "totalAnalyses": 3, + "totalExtracts": 2, + } + + # Test for all user types + for client in [ + self.owner_client, + self.collaborator_client, + self.regular_client, + self.anonymous_client, + ]: + result = client.execute(query, variable_values=variables) + self.assertIsNotNone(result.get("data")) + stats = result["data"]["corpusStats"] + self.assertEqual(stats, expected_stats) + + def test_private_corpus_stats_query(self): + query = """ + query($id: ID!) { + corpusStats(corpusId: $id) { + totalDocs + totalAnnotations + totalComments + totalAnalyses + totalExtracts + } + } + """ + variables = {"id": to_global_id("CorpusType", self.private_corpus.id)} + + # Test for owner (should see stats) + result = self.owner_client.execute(query, variable_values=variables) + self.assertIsNotNone(result.get("data")) + stats = result["data"]["corpusStats"] + self.assertIsNotNone(stats) + + # Test for other user types (should not see stats) + for client in [ + self.collaborator_client, + self.regular_client, + self.anonymous_client, + ]: + result = client.execute(query, variable_values=variables) + self.assertEqual( + { + "totalDocs": 0, + "totalAnnotations": 0, + "totalComments": 0, + "totalAnalyses": 0, + "totalExtracts": 0, + }, + result["data"]["corpusStats"], + ) + + def test_nonexistent_corpus_stats_query(self): + query = """ + query($id: ID!) { + corpusStats(corpusId: $id) { + totalDocs + totalAnnotations + totalComments + totalAnalyses + totalExtracts + } + } + """ + variables = {"id": to_global_id("CorpusType", 9999)} # Non-existent ID + + # Test for all user types + for client in [ + self.owner_client, + self.collaborator_client, + self.regular_client, + self.anonymous_client, + ]: + result = client.execute(query, variable_values=variables) + self.assertEqual( + { + "totalDocs": 0, + "totalAnnotations": 0, + "totalComments": 0, + "totalAnalyses": 0, + "totalExtracts": 0, + }, + result["data"]["corpusStats"], + ) From 3dfa96b67c397113eb2e320e99da0da5c8ee7512 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Mon, 16 Sep 2024 18:05:03 -0700 Subject: [PATCH 18/18] Removed unused test code. --- .../tests/test_permissioning.py | 104 ------------------ 1 file changed, 104 deletions(-) diff --git a/opencontractserver/tests/test_permissioning.py b/opencontractserver/tests/test_permissioning.py index 43a84ea1..a3d11267 100644 --- a/opencontractserver/tests/test_permissioning.py +++ b/opencontractserver/tests/test_permissioning.py @@ -173,109 +173,6 @@ def setUp(self): analysis=self.analysis, ) - def __test_query_efficient_filtering(self): - def __test_query_efficient_filtering(self): - logger.info( - "----- TEST QUERY EFFICIENT FILTERING FOR USER READ PERMISSIONS ------------------------------------" - ) - - # Create additional test corpuses - for i in range(5): - with transaction.atomic(): - corpus = Corpus.objects.create( - title=f"Test Corpus {i}", - creator=self.superuser, - backend_lock=False, - ) - - # Assign different permissions to different corpuses - if i % 3 == 0: - set_permissions_for_obj_to_user( - self.user, corpus, [PermissionTypes.READ] - ) - elif i % 3 == 1: - set_permissions_for_obj_to_user( - self.user_2, corpus, [PermissionTypes.READ] - ) - else: - corpus.is_public = True - corpus.save() - - # Test filtering for user 1 using the new PermissionQuerySet - all_corpuses = Corpus.objects.all() - - # Use the new 'for_user' method with 'read' permission - user1_readable_corpuses = Corpus.objects.for_user(self.user, perm="read") - - logger.info(f"User 1 can read {user1_readable_corpuses.count()} corpuses") - self.assertTrue(user1_readable_corpuses.count() > 0) - for corpus in user1_readable_corpuses: - self.assertTrue( - corpus.is_public - or user_has_permission_for_obj( - self.user, corpus, PermissionTypes.READ - ) - ) - - # Test filtering for user 2 - user2_readable_corpuses = Corpus.objects.for_user(self.user_2, perm="read") - - logger.info(f"User 2 can read {user2_readable_corpuses.count()} corpuses") - self.assertTrue(user2_readable_corpuses.count() > 0) - for corpus in user2_readable_corpuses: - self.assertTrue( - corpus.is_public - or user_has_permission_for_obj( - self.user_2, corpus, PermissionTypes.READ - ) - ) - - # Test filtering for superuser - superuser_readable_corpuses = Corpus.objects.for_user( - self.superuser, perm="read" - ) - - logger.info( - f"Superuser can read {superuser_readable_corpuses.count()} corpuses" - ) - self.assertEqual( - superuser_readable_corpuses.count(), Corpus.objects.count() - ) - - # Test that the filtered querysets are different for different users - self.assertNotEqual( - set(user1_readable_corpuses), set(user2_readable_corpuses) - ) - - # Test performance - import time - - # Measure time for the efficient filtering using 'for_user' method - start_time = time.time() - Corpus.objects.for_user(self.user, perm="read") - end_time = time.time() - - logger.info( - f"Time taken for efficient filtering: {end_time - start_time} seconds" - ) - - # Compare with a naive approach - start_time = time.time() - naive_filtered = [ - corpus - for corpus in all_corpuses - if corpus.is_public - or user_has_permission_for_obj(self.user, corpus, PermissionTypes.READ) - ] - end_time = time.time() - - logger.info( - f"Time taken for naive filtering: {end_time - start_time} seconds" - ) - - # Assert that both methods return the same results - self.assertEqual(set(user1_readable_corpuses), set(naive_filtered)) - def __test_user_retrieval_permissions(self): logger.info( @@ -835,7 +732,6 @@ def test_permissions(self): self.__test_make_analysis_public_mutation() self.__test_make_analysis_public_task() self.__test_actual_analysis_deletion() - # self.__test_query_efficient_filtering() def test_user_feedback_visibility(self): logger.info("----- TEST USER FEEDBACK VISIBILITY -----")