diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml
index 6c2a8153d0..eeebf4fdc4 100644
--- a/.github/workflows/continuous.yaml
+++ b/.github/workflows/continuous.yaml
@@ -1,11 +1,7 @@
name: Continuous
on:
push:
- branches:
- - "*"
pull_request:
- branches:
- - "*"
concurrency:
group: ${{ github.ref }}
@@ -93,6 +89,8 @@ jobs:
# cache-to: type=registry,ref=gcr.io/${{ secrets.DEV_PROJECT }}/sefaria-${{ matrix.app }}/cache, mode=max
context: .
push: true
+ build-args: |
+ TYPE=build
file: ./build/${{ matrix.app }}/Dockerfile
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
diff --git a/.github/workflows/production-deploy.yaml b/.github/workflows/production-deploy.yaml
index 31bb7f228b..0966286206 100644
--- a/.github/workflows/production-deploy.yaml
+++ b/.github/workflows/production-deploy.yaml
@@ -97,6 +97,8 @@ jobs:
# cache-to: type=registry,ref=$gcr.io/${{ secrets.PROD_GKE_PROJECT }}/{{ secrets.IMAGE_NAME }}-${{ matrix.app }}/cache,mode=max
context: .
push: true
+ build-args: |
+ TYPE=build-prod
file: ./build/${{ matrix.app }}/Dockerfile
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -218,7 +220,6 @@ jobs:
IMAGE_NAME: "${{ secrets.IMAGE_NAME }}"
CHART_VERSION: "${{ steps.chart_version.outputs.chart_version }}"
- name: Update workflow default chart
- if: github.ref == 'refs/heads/master'
run: >
curl -L
-X PATCH
diff --git a/README.mkd b/README.mkd
index 582de41930..49b49c8eef 100644
--- a/README.mkd
+++ b/README.mkd
@@ -108,7 +108,7 @@ If the server isn't running, you may need to run `docker-compose up` again.
#### Run locally
#### 1) Install Python 3.7
-*We Recommend using the latest Python 3.7 as opposed to later versions of Python (esp 3.10 and up) since it has been known to cause some compatability issues. These are solvable, but for an easier install experience, we currently recommend 3.7*
+*We Recommend using the latest Python 3.7 as opposed to later versions of Python (esp 3.10 and up) since it has been known to cause some compatibility issues. These are solvable, but for an easier install experience, we currently recommend 3.7*
###### Linux and macOS
Most UNIX systems come with a python interpreter pre-installed. However, this is generally still Python 2. The recommended way to get Python 3, and not mess up any software the OS is dependent on, is by using Pyenv. You can use the instructions [here](https://github.com/pyenv/pyenv#installation) and also [here](https://opensource.com/article/19/5/python-3-default-mac#what-we-should-do).
diff --git a/build/ci/production-values.yaml b/build/ci/production-values.yaml
index 8c4c4b81e9..b653021544 100644
--- a/build/ci/production-values.yaml
+++ b/build/ci/production-values.yaml
@@ -193,7 +193,7 @@ secrets:
slackWebhook:
ref: slack-webhook-production
instrumentation:
- enabled: true
+ enabled: false
otelEndpoint: "http://otel-collector-collector.monitoring:4317"
jaegerEndpoint: "jaeger-agent-dev.monitoring.svc.cluster.local:6831"
diff --git a/build/node/Dockerfile b/build/node/Dockerfile
index f8f14097d4..4b00650316 100644
--- a/build/node/Dockerfile
+++ b/build/node/Dockerfile
@@ -1,4 +1,5 @@
FROM gcr.io/production-deployment/base-node:v13.0.0
+ARG TYPE=build-prod
WORKDIR /app/
@@ -7,7 +8,7 @@ RUN npm install --unsafe-perm
COPY ./node /app/node
COPY ./static/js /app/static/js
-RUN npm run build-prod
+RUN npm run $TYPE
COPY . /app/
EXPOSE 3000
diff --git a/build/web/Dockerfile b/build/web/Dockerfile
index 07effb5324..7065e16cee 100644
--- a/build/web/Dockerfile
+++ b/build/web/Dockerfile
@@ -1,4 +1,5 @@
FROM gcr.io/production-deployment/base-web:v3.7-bullseye
+ARG TYPE=build-prod
WORKDIR /app/
# Copied separately to allow for caching of the `pip install` build step
@@ -10,7 +11,7 @@ RUN npm install --unsafe-perm
COPY ./node /app/node
COPY ./static/js /app/static/js
-RUN npm run build-prod
+RUN npm run $TYPE
COPY . /app/
diff --git a/helm-chart/sefaria-project/templates/configmap/create-mongo-dumps.yaml b/helm-chart/sefaria-project/templates/configmap/create-mongo-dumps.yaml
index 20c5a5ee3d..60d126a91b 100644
--- a/helm-chart/sefaria-project/templates/configmap/create-mongo-dumps.yaml
+++ b/helm-chart/sefaria-project/templates/configmap/create-mongo-dumps.yaml
@@ -67,7 +67,7 @@ data:
sleep 2
done
- until mongodump --uri="$URI" -v -d $DATABASE --excludeCollection=history --excludeCollection=texts --excludeCollection=sheets --excludeCollection=links --excludeCollection=user_history -o "${DATADIR}/dump"
+ until mongodump --uri="$URI" -v -d $DATABASE --excludeCollection=history --excludeCollection=texts --excludeCollection=sheets --excludeCollection=links --excludeCollection=django_cache --excludeCollection=user_history -o "${DATADIR}/dump"
do
echo "trying to dump other stuff again"
sleep 2
diff --git a/helm-chart/sefaria-project/templates/configmap/mongo-destroy.yaml b/helm-chart/sefaria-project/templates/configmap/mongo-destroy.yaml
index 6874c016c4..3108cb1af0 100644
--- a/helm-chart/sefaria-project/templates/configmap/mongo-destroy.yaml
+++ b/helm-chart/sefaria-project/templates/configmap/mongo-destroy.yaml
@@ -8,7 +8,7 @@ metadata:
{{- include "sefaria.labels" . | nindent 4 }}
annotations:
helm.sh/hook: post-delete
- helm.sh/hook-delete-policy: hook-succeeded
+ helm.sh/hook-delete-policy: hook-succeeded, hook-failed
helm.sh/hook-weight: "5"
data:
destroy-mongo.sh: |-
@@ -37,8 +37,14 @@ data:
DB_NAME=$SEFARIA_DB
{{ end }}
- URI="${URI}${MONGO_HOST}/${DATABASE}?ssl=false&authSource=admin"
+ echo "APSCHEDULER: ${APSCHEDULER_NAME}"
+
+ if [[ -z "$APSCHEDULER_NAME" ]]; then
+ APSCHEDULER_NAME={{ tpl .Values.localSettings.APSCHEDULER_NAME . | quote }}
+ fi
+
APSCHEDULER_URI="${URI}${MONGO_HOST}/${APSCHEDULER_NAME}?ssl=false&authSource=admin"
+ URI="${URI}${MONGO_HOST}/${DB_NAME}?ssl=false&authSource=admin"
if [[ ! -z "$MONGO_REPLICASET_NAME" ]]; then
URI="${URI}&replicaSet=${MONGO_REPLICASET_NAME}"
diff --git a/package-lock.json b/package-lock.json
index 2f6296b5f5..0a2bd0d49b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2410,6 +2410,29 @@
"integrity": "sha512-Wha1UwsB3CYdqUm2PPzh/1gujGCNtWVUYF0mB00fJFoR4gTyWTDPjSm+zBF787Ahw8vSGgBja90MkgFwvB86Dg==",
"dev": true
},
+ "node_modules/@types/prop-types": {
+ "version": "15.7.5",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
+ "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==",
+ "peer": true
+ },
+ "node_modules/@types/react": {
+ "version": "18.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.14.tgz",
+ "integrity": "sha512-A0zjq+QN/O0Kpe30hA1GidzyFjatVvrpIvWLxD+xv67Vt91TWWgco9IvrJBkeyHm1trGaFS/FSGqPlhyeZRm0g==",
+ "peer": true,
+ "dependencies": {
+ "@types/prop-types": "*",
+ "@types/scheduler": "*",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@types/scheduler": {
+ "version": "0.16.3",
+ "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz",
+ "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==",
+ "peer": true
+ },
"node_modules/@types/stack-utils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz",
@@ -2969,6 +2992,188 @@
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz",
"integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg=="
},
+ "node_modules/babel-code-frame": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz",
+ "integrity": "sha512-XqYMR2dfdGMW+hd0IUZ2PwK+fGeFkOxZJ0wY+JaQAHzt1Zx8LcvpiZD2NiGkEG8qx0CfkAOr5xt76d1e8vG90g==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "chalk": "^1.1.3",
+ "esutils": "^2.0.2",
+ "js-tokens": "^3.0.2"
+ }
+ },
+ "node_modules/babel-code-frame/node_modules/ansi-regex": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+ "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
+ "dev": true,
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/babel-code-frame/node_modules/ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==",
+ "dev": true,
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/babel-code-frame/node_modules/chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "ansi-styles": "^2.2.1",
+ "escape-string-regexp": "^1.0.2",
+ "has-ansi": "^2.0.0",
+ "strip-ansi": "^3.0.0",
+ "supports-color": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/babel-code-frame/node_modules/js-tokens": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz",
+ "integrity": "sha512-RjTcuD4xjtthQkaWH7dFlH85L+QaVtSoOyGdZ3g6HFhS9dFNDfLyqgm2NFe2X6cQpeFmt0452FJjFG5UameExg==",
+ "dev": true,
+ "peer": true
+ },
+ "node_modules/babel-code-frame/node_modules/strip-ansi": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+ "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/babel-code-frame/node_modules/supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==",
+ "dev": true,
+ "peer": true,
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/babel-core": {
+ "version": "6.26.3",
+ "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.26.3.tgz",
+ "integrity": "sha512-6jyFLuDmeidKmUEb3NM+/yawG0M2bDZ9Z1qbZP59cyHLz8kYGKYwpJP0UwUKKUiTRNvxfLesJnTedqczP7cTDA==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "babel-code-frame": "^6.26.0",
+ "babel-generator": "^6.26.0",
+ "babel-helpers": "^6.24.1",
+ "babel-messages": "^6.23.0",
+ "babel-register": "^6.26.0",
+ "babel-runtime": "^6.26.0",
+ "babel-template": "^6.26.0",
+ "babel-traverse": "^6.26.0",
+ "babel-types": "^6.26.0",
+ "babylon": "^6.18.0",
+ "convert-source-map": "^1.5.1",
+ "debug": "^2.6.9",
+ "json5": "^0.5.1",
+ "lodash": "^4.17.4",
+ "minimatch": "^3.0.4",
+ "path-is-absolute": "^1.0.1",
+ "private": "^0.1.8",
+ "slash": "^1.0.0",
+ "source-map": "^0.5.7"
+ }
+ },
+ "node_modules/babel-core/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/babel-core/node_modules/json5": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz",
+ "integrity": "sha512-4xrs1aW+6N5DalkqSVA8fxh458CXvR99WU8WLKmq4v8eWAL86Xo3BVqyd3SkA9wEVjCMqyvvRRkshAdOnBp5rw==",
+ "dev": true,
+ "peer": true,
+ "bin": {
+ "json5": "lib/cli.js"
+ }
+ },
+ "node_modules/babel-core/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true,
+ "peer": true
+ },
+ "node_modules/babel-core/node_modules/slash": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz",
+ "integrity": "sha512-3TYDR7xWt4dIqV2JauJr+EJeW356RXijHeUlO+8djJ+uBXPn8/2dpzBc8yQhh583sVvc9CvFAeQVgijsH+PNNg==",
+ "dev": true,
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/babel-generator": {
+ "version": "6.26.1",
+ "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.26.1.tgz",
+ "integrity": "sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "babel-messages": "^6.23.0",
+ "babel-runtime": "^6.26.0",
+ "babel-types": "^6.26.0",
+ "detect-indent": "^4.0.0",
+ "jsesc": "^1.3.0",
+ "lodash": "^4.17.4",
+ "source-map": "^0.5.7",
+ "trim-right": "^1.0.1"
+ }
+ },
+ "node_modules/babel-generator/node_modules/jsesc": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz",
+ "integrity": "sha512-Mke0DA0QjUWuJlhsE0ZPPhYiJkRap642SmI/4ztCFaUs6V2AiH1sfecc+57NgaryfAA2VR3v6O+CSjC1jZJKOA==",
+ "dev": true,
+ "peer": true,
+ "bin": {
+ "jsesc": "bin/jsesc"
+ }
+ },
+ "node_modules/babel-helpers": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helpers/-/babel-helpers-6.24.1.tgz",
+ "integrity": "sha512-n7pFrqQm44TCYvrCDb0MqabAF+JUBq+ijBvNMUxpkLjJaAu32faIexewMumrH5KLLJ1HDyT0PTEqRyAe/GwwuQ==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "babel-runtime": "^6.22.0",
+ "babel-template": "^6.24.1"
+ }
+ },
"node_modules/babel-jest": {
"version": "24.9.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-24.9.0.tgz",
@@ -3024,6 +3229,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/babel-messages": {
+ "version": "6.23.0",
+ "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.23.0.tgz",
+ "integrity": "sha512-Bl3ZiA+LjqaMtNYopA9TYE9HP1tQ+E5dLxE0XrAzcIJeK2UqF0/EaqXwBn9esd4UmTfEab+P+UYQ1GnioFIb/w==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "babel-runtime": "^6.22.0"
+ }
+ },
"node_modules/babel-plugin-istanbul": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-5.2.0.tgz",
@@ -3112,6 +3327,140 @@
"@babel/core": "^7.0.0"
}
},
+ "node_modules/babel-register": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-register/-/babel-register-6.26.0.tgz",
+ "integrity": "sha512-veliHlHX06wjaeY8xNITbveXSiI+ASFnOqvne/LaIJIqOWi2Ogmj91KOugEz/hoh/fwMhXNBJPCv8Xaz5CyM4A==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "babel-core": "^6.26.0",
+ "babel-runtime": "^6.26.0",
+ "core-js": "^2.5.0",
+ "home-or-tmp": "^2.0.0",
+ "lodash": "^4.17.4",
+ "mkdirp": "^0.5.1",
+ "source-map-support": "^0.4.15"
+ }
+ },
+ "node_modules/babel-register/node_modules/core-js": {
+ "version": "2.6.12",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
+ "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
+ "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
+ "dev": true,
+ "hasInstallScript": true,
+ "peer": true
+ },
+ "node_modules/babel-runtime": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
+ "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "core-js": "^2.4.0",
+ "regenerator-runtime": "^0.11.0"
+ }
+ },
+ "node_modules/babel-runtime/node_modules/core-js": {
+ "version": "2.6.12",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
+ "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
+ "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
+ "dev": true,
+ "hasInstallScript": true,
+ "peer": true
+ },
+ "node_modules/babel-runtime/node_modules/regenerator-runtime": {
+ "version": "0.11.1",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
+ "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==",
+ "dev": true,
+ "peer": true
+ },
+ "node_modules/babel-template": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-6.26.0.tgz",
+ "integrity": "sha512-PCOcLFW7/eazGUKIoqH97sO9A2UYMahsn/yRQ7uOk37iutwjq7ODtcTNF+iFDSHNfkctqsLRjLP7URnOx0T1fg==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "babel-runtime": "^6.26.0",
+ "babel-traverse": "^6.26.0",
+ "babel-types": "^6.26.0",
+ "babylon": "^6.18.0",
+ "lodash": "^4.17.4"
+ }
+ },
+ "node_modules/babel-traverse": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz",
+ "integrity": "sha512-iSxeXx7apsjCHe9c7n8VtRXGzI2Bk1rBSOJgCCjfyXb6v1aCqE1KSEpq/8SXuVN8Ka/Rh1WDTF0MDzkvTA4MIA==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "babel-code-frame": "^6.26.0",
+ "babel-messages": "^6.23.0",
+ "babel-runtime": "^6.26.0",
+ "babel-types": "^6.26.0",
+ "babylon": "^6.18.0",
+ "debug": "^2.6.8",
+ "globals": "^9.18.0",
+ "invariant": "^2.2.2",
+ "lodash": "^4.17.4"
+ }
+ },
+ "node_modules/babel-traverse/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/babel-traverse/node_modules/globals": {
+ "version": "9.18.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-9.18.0.tgz",
+ "integrity": "sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==",
+ "dev": true,
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/babel-traverse/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true,
+ "peer": true
+ },
+ "node_modules/babel-types": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz",
+ "integrity": "sha512-zhe3V/26rCWsEZK8kZN+HaQj5yQ1CilTObixFzKW1UWjqG7618Twz6YEsCnjfg5gBcJh02DrpCkS9h98ZqDY+g==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "babel-runtime": "^6.26.0",
+ "esutils": "^2.0.2",
+ "lodash": "^4.17.4",
+ "to-fast-properties": "^1.0.3"
+ }
+ },
+ "node_modules/babel-types/node_modules/to-fast-properties": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz",
+ "integrity": "sha512-lxrWP8ejsq+7E3nNjwYmUBMAgjMTZoTI+sdBOpvNyijeDLa29LUn9QaoXAHv4+Z578hbmHHJKZknzxVtvo77og==",
+ "dev": true,
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/babel-watch": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/babel-watch/-/babel-watch-2.0.8.tgz",
@@ -3387,6 +3736,16 @@
"safe-buffer": "~5.1.0"
}
},
+ "node_modules/babylon": {
+ "version": "6.18.0",
+ "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz",
+ "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==",
+ "dev": true,
+ "peer": true,
+ "bin": {
+ "babylon": "bin/babylon.js"
+ }
+ },
"node_modules/bail": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz",
@@ -4551,6 +4910,12 @@
"cssom": "0.3.x"
}
},
+ "node_modules/csstype": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
+ "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==",
+ "peer": true
+ },
"node_modules/dashdash": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
@@ -4695,6 +5060,19 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
+ "node_modules/detect-indent": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-4.0.0.tgz",
+ "integrity": "sha512-BDKtmHlOzwI7iRuEkhzsnPoi5ypEhWAJB5RvHWe1kMr06js3uK5B3734i3ui5Yd+wOJV1cpE4JnivPD283GU/A==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "repeating": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/detect-newline": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-2.1.0.tgz",
@@ -6274,6 +6652,29 @@
"node": ">= 0.4.0"
}
},
+ "node_modules/has-ansi": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
+ "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/has-ansi/node_modules/ansi-regex": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+ "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
+ "dev": true,
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/has-bigints": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz",
@@ -6388,6 +6789,20 @@
"react-is": "^16.7.0"
}
},
+ "node_modules/home-or-tmp": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-2.0.0.tgz",
+ "integrity": "sha512-ycURW7oUxE2sNiPVw1HVEFsW+ecOpJ5zaj7eC0RlwhibhRBod20muUN8qu/gzx956YrLolVvs1MTXwKgC2rVEg==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "os-homedir": "^1.0.0",
+ "os-tmpdir": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/hosted-git-info": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
@@ -6871,6 +7286,19 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-finite": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz",
+ "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==",
+ "dev": true,
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-fullwidth-code-point": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
@@ -9361,6 +9789,16 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/os-homedir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
+ "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==",
+ "dev": true,
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/os-tmpdir": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
@@ -9969,6 +10407,16 @@
"node": ">= 6"
}
},
+ "node_modules/private": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz",
+ "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==",
+ "dev": true,
+ "peer": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -10579,6 +11027,19 @@
"node": ">=0.10"
}
},
+ "node_modules/repeating": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz",
+ "integrity": "sha512-ZqtSMuVybkISo2OWvqvm7iHSWngvdaW3IpsT9/uP8v4gMi591LY6h35wdOfvQdWCKFWZWm2Y1Opp4kV7vQKT6A==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "is-finite": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/request": {
"version": "2.88.2",
"resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz",
@@ -12409,6 +12870,16 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/trim-right": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz",
+ "integrity": "sha512-WZGXGstmCWgeevgTL54hrCuw1dyMQIzWy7ZfqRJfSmJZBwklI15egmQytFP6bPidmw3M8d5yEowl1niq4vmqZw==",
+ "dev": true,
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/triple-beam": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz",
diff --git a/reader/views.py b/reader/views.py
index 9231944f8f..81c0878869 100644
--- a/reader/views.py
+++ b/reader/views.py
@@ -845,7 +845,8 @@ def search(request):
}
return render_template(request,'base.html', props, {
"title": (search_params["query"] + " | " if search_params["query"] else "") + _("Sefaria Search"),
- "desc": _("Search 3,000 years of Jewish texts in Hebrew and English translation.")
+ "desc": _("Search 3,000 years of Jewish texts in Hebrew and English translation."),
+ "noindex": True
})
@@ -1214,7 +1215,6 @@ def edit_text(request, ref=None, lang=None, version=None):
mode = "Add"
else:
# Pull a particular section to edit
- version = version.replace("_", " ") if version else None
#text = get_text(ref, lang=lang, version=version)
text = TextFamily(Ref(ref), lang=lang, version=version).contents()
text["mode"] = request.path.split("/")[1]
@@ -1388,15 +1388,11 @@ def texts_api(request, tref):
commentary = bool(int(request.GET.get("commentary", False)))
pad = bool(int(request.GET.get("pad", 1)))
versionEn = request.GET.get("ven", None)
+ versionHe = request.GET.get("vhe", None)
firstAvailableRef = bool(int(request.GET.get("firstAvailableRef", False))) # use first available ref, which may not be the same as oref
if firstAvailableRef:
temp_oref = oref.first_available_section_ref()
oref = temp_oref or oref # don't overwrite oref if first available section ref fails
- if versionEn:
- versionEn = versionEn.replace("_", " ")
- versionHe = request.GET.get("vhe", None)
- if versionHe:
- versionHe = versionHe.replace("_", " ")
layer_name = request.GET.get("layer", None)
alts = bool(int(request.GET.get("alts", True)))
wrapLinks = bool(int(request.GET.get("wrapLinks", False)))
@@ -1552,9 +1548,6 @@ def social_image_api(request, tref):
ref = Ref(tref)
ref_str = ref.normal() if lang == "en" else ref.he_normal()
- if version:
- version = version.replace("_", " ")
-
tf = TextFamily(ref, stripItags=True, lang=lang, version=version, context=0, commentary=False).contents()
he = tf["he"] if type(tf["he"]) is list else [tf["he"]]
@@ -3102,14 +3095,14 @@ def add_new_topic_api(request):
data = json.loads(request.POST["json"])
isTopLevelDisplay = data["category"] == Topic.ROOT
t = Topic({'slug': "", "isTopLevelDisplay": isTopLevelDisplay, "data_source": "sefaria", "numSources": 0})
- update_topic_titles(t, data)
+ update_topic_titles(t, **data)
if not isTopLevelDisplay: # not Top Level so create an IntraTopicLink to category
new_link = IntraTopicLink({"toTopic": data["category"], "fromTopic": t.slug, "linkType": "displays-under", "dataSource": "sefaria"})
new_link.save()
if data["category"] == 'authors':
- t = update_authors_place_and_time(t, data)
+ t = update_authors_place_and_time(t, **data)
t.description_published = True
t.data_source = "sefaria" # any topic edited manually should display automatically in the TOC and this flag ensures this
@@ -3164,15 +3157,15 @@ def topics_api(request, topic, v2=False):
if not request.user.is_staff:
return jsonResponse({"error": "Adding topics is locked.
Please email hello@sefaria.org if you believe edits are needed."})
topic_data = json.loads(request.POST["json"])
- topic_obj = Topic().load({'slug': topic_data["origSlug"]})
+ topic = Topic().load({'slug': topic_data["origSlug"]})
topic_data["manual"] = True
author_status_changed = (topic_data["category"] == "authors") ^ (topic_data["origCategory"] == "authors")
- topic_obj = update_topic(topic_obj, **topic_data)
+ topic = update_topic(topic, **topic_data)
if author_status_changed:
library.build_topic_auto_completer()
def protected_index_post(request):
- return jsonResponse(topic_obj.contents())
+ return jsonResponse(topic.contents())
return protected_index_post(request)
@@ -3292,6 +3285,16 @@ def recommend_topics_api(request, ref_list=None):
return response
+@api_view(["GET"])
+@catch_error_as_json
+def portals_api(request, slug):
+ """
+ API to get data for a Portal object by slug
+ """
+ portal = Portal.init(slug)
+ return jsonResponse(portal.contents(), callback=request.GET.get("callback", None))
+
+
@ensure_csrf_cookie
@sanitize_get_params
def global_activity(request, page=1):
@@ -4011,8 +4014,17 @@ def random_text_api(request):
"""
Return Texts API data for a random ref.
"""
- categories = set(request.GET.get('categories', '').split('|'))
- titles = set(request.GET.get('titles', '').split('|'))
+
+ if "categories" in request.GET:
+ categories = set(request.GET.get('categories', '').split('|'))
+ else:
+ categories = None
+
+ if "titles" in request.GET:
+ titles = set(request.GET.get('titles', '').split('|'))
+ else:
+ titles = None
+
response = redirect(iri_to_uri("/api/texts/" + random_ref(categories, titles)) + "?commentary=0&context=0", permanent=False)
return response
@@ -4567,4 +4579,3 @@ def isNodeJsReachable():
logger.warn("Failed rollout healthcheck. Healthcheck Response: {}".format(resp))
return http.JsonResponse(resp, status=statusCode)
-
diff --git a/requirements.txt b/requirements.txt
index e82dd864ad..0bd6dda6f8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -26,7 +26,7 @@ elasticsearch==8.8.2
git+https://github.com/Sefaria/elasticsearch-dsl-py@v8.0.0#egg=elasticsearch-dsl
geojson==2.5.0
geopy==2.3.0
-gevent==20.12.0
+gevent==20.12.0; sys_platform != 'darwin'
google-api-python-client==1.12.5
google-cloud-logging==1.15.1
google-cloud-storage==1.32.0
@@ -42,7 +42,8 @@ google-auth==1.24.0
google-auth-oauthlib==0.4.2
p929==0.6.1
pathos==0.2.6
-pillow==8.0.1
+pillow==8.0.1; sys_platform == 'linux'
+pillow==10.0.1; sys_platform != 'linux'
psycopg2==2.8.6
py2-py3-django-email-as-username==1.7.1
pymongo==3.12.*
@@ -63,6 +64,8 @@ user-agents==2.2.0
sentry-sdk==1.26.0
babel
python-bidi
+requests
+Cerberus
opentelemetry-distro
opentelemetry-exporter-otlp
diff --git a/scripts/migrations/add_topic_images.py b/scripts/migrations/add_topic_images.py
new file mode 100644
index 0000000000..ed42ba96dd
--- /dev/null
+++ b/scripts/migrations/add_topic_images.py
@@ -0,0 +1,40 @@
+import django
+
+django.setup()
+
+from sefaria.helper.topic import add_image_to_topic
+
+## Adding images
+
+hardcodedTopicImagesMap = {
+ 'rosh-hashanah': {'image_uri': 'https://storage.googleapis.com/img.sefaria.org/topics/rosh-hashanah.jpeg',
+ 'enCaption': 'Rosh Hashanah, Arthur Szyk (1894-1951) Tempera and ink on paper. New Canaan, 1948. Collection of Yeshiva University Museum. Gift of Charles Frost',
+ 'heCaption': 'ראש השנה, ארתור שיק, ארה״ב 1948. אוסף ישיבה יוניברסיטי'},
+
+ 'yom-kippur': {'image_uri': 'https://storage.googleapis.com/img.sefaria.org/topics/yom-kippur.jpeg',
+ 'enCaption': 'Micrography of Jonah being swallowed by the fish. Germany, 1300-1500, The British Library',
+ 'heCaption': 'מיקרוגרפיה של יונה בבטן הדג, מתוך ספר יונה ההפטרה של יום כיפור, 1300-1500'},
+
+ 'the-four-species': {'image_uri': 'https://storage.googleapis.com/img.sefaria.org/topics/the-four-species.jpg',
+ 'enCaption': 'Etrog container, K B, late 19th century, Germany. The Jewish Museum, Gift of Dr. Harry G. Friedman',
+ 'heCaption': 'תיבת אתרוג, סוף המאה ה19, גרמניה. המוזיאון היהודי בניו יורק, מתנת דר. הארי ג. פרידמן '},
+
+ 'sukkot': {'image_uri': 'https://storage.googleapis.com/img.sefaria.org/topics/sukkot.jpg',
+ 'enCaption': 'Detail of a painting of a sukkah. Image taken from f. 316v of Forli Siddur. 1383, Italian rite. The British Library',
+ 'heCaption': 'פרט ציור של סוכה עם שולחן פרוש ושלוש דמויות. דימוי מתוך סידור פורלי, 1383 איטליה'},
+
+ 'simchat-torah': {'image_uri': 'https://storage.googleapis.com/img.sefaria.org/topics/simchat-torah.jpg',
+ 'enCaption': 'Rosh Hashanah postcard: Hakafot, Haim Yisroel Goldberg (1888-1943) Publisher: Williamsburg Post Card Co. Germany, ca. 1915 Collection of Yeshiva University Museum',
+ 'heCaption': 'גלויה לראש השנה: הקפות, חיים גולדברג, גרמניה 1915, אוסף ישיבה יוניברסיטי'},
+
+ 'shabbat': {'image_uri': 'https://storage.googleapis.com/img.sefaria.org/topics/shabbat.jpg',
+ 'enCaption': 'Friday Evening, Isidor Kaufmann, Austria c. 1920. The Jewish Museum, Gift of Mr. and Mrs. M. R. Schweitzer',
+ 'heCaption': 'שישי בערב, איזידור קאופמן, וינה 1920. המוזיאון היהודי בניו יורק, מתנת מר וגברת מ.ר. שוויצר'},
+
+}
+
+for topic in hardcodedTopicImagesMap:
+ add_image_to_topic(topic,
+ image_uri=hardcodedTopicImagesMap[topic]["image_uri"],
+ en_caption=hardcodedTopicImagesMap[topic]["enCaption"],
+ he_caption=hardcodedTopicImagesMap[topic]["heCaption"])
\ No newline at end of file
diff --git a/sefaria/forms.py b/sefaria/forms.py
index 8d72976778..4268968211 100644
--- a/sefaria/forms.py
+++ b/sefaria/forms.py
@@ -30,6 +30,10 @@ class SefariaDeleteUserForm(EmailAuthenticationForm):
email = forms.EmailField(max_length=75, widget=forms.EmailInput(attrs={'placeholder': _("Email Address to delete")}))
password = forms.CharField(widget=forms.PasswordInput(attrs={'placeholder': _("Admin Password")}))
+class SefariaDeleteSheet(forms.Form):
+ sid = forms.CharField(max_length=20, widget=forms.TextInput(attrs={'placeholder': _("Sheet ID to delete")}))
+ password = forms.CharField(widget=forms.PasswordInput(attrs={'placeholder': _("Admin Password")}))
+
class SefariaLoginForm(EmailAuthenticationForm):
email = forms.EmailField(max_length=75, widget=forms.EmailInput(attrs={'placeholder': _("Email Address")}))
diff --git a/sefaria/helper/linker.py b/sefaria/helper/linker.py
index a0f09659a2..998f346a05 100644
--- a/sefaria/helper/linker.py
+++ b/sefaria/helper/linker.py
@@ -62,10 +62,11 @@ class _FindRefsTextOptions:
class _FindRefsText:
title: str
body: str
+ lang: str
- def __post_init__(self):
- from sefaria.utils.hebrew import is_mostly_hebrew
- self.lang = 'he' if is_mostly_hebrew(self.body) else 'en'
+ # def __post_init__(self):
+ # from sefaria.utils.hebrew import is_mostly_hebrew
+ # self.lang = 'he' if is_mostly_hebrew(self.body) else 'en'
def _unpack_find_refs_request(request):
@@ -75,9 +76,11 @@ def _unpack_find_refs_request(request):
def _create_find_refs_text(post_body) -> _FindRefsText:
+ from sefaria.utils.hebrew import is_mostly_hebrew
title = post_body['text']['title']
body = post_body['text']['body']
- return _FindRefsText(title, body)
+ lang = post_body['lang'] if 'lang' in post_body else 'he' if is_mostly_hebrew(body) else 'en'
+ return _FindRefsText(title, body, lang)
def _create_find_refs_options(get_body: dict, post_body: dict) -> _FindRefsTextOptions:
diff --git a/sefaria/helper/tests/topic_test.py b/sefaria/helper/tests/topic_test.py
index 66a635f394..242539b5dc 100644
--- a/sefaria/helper/tests/topic_test.py
+++ b/sefaria/helper/tests/topic_test.py
@@ -52,10 +52,10 @@ def grandchild_of_root_with_self_link(child_of_root_with_self_link):
@pytest.fixture(autouse=True, scope='module')
-def root_wout_self_link():
- # create second branch of tree starting with root_wout_self_link
+def author_root():
+ # create second branch of tree starting with author_root
t = Topic({'slug': "", "isTopLevelDisplay": True, "data_source": "sefaria", "numSources": 0})
- title = "Normal Root"
+ title = "Authors"
he_title = title[::-1]
t.add_primary_titles(title, he_title)
t.set_slug_to_primary_title()
@@ -66,48 +66,61 @@ def root_wout_self_link():
@pytest.fixture(autouse=True, scope='module')
-def child_of_root_wout_self_link(root_wout_self_link):
+def actual_author(author_root):
t = Topic({'slug': "", "isTopLevelDisplay": False, "data_source": "sefaria", "numSources": 0})
- title = "Normal Root Leaf Node"
+ title = "Author Dude"
he_title = title[::-1]
t.add_primary_titles(title, he_title)
t.set_slug_to_primary_title()
t.save()
l = IntraTopicLink({"linkType": "displays-under", "fromTopic": t.slug,
- "toTopic": root_wout_self_link["topic"].slug, "dataSource": "sefaria",
- "class": "intraTopic"}).save() # root_wout_self_link has child leaf_node
+ "toTopic": author_root["topic"].slug, "dataSource": "sefaria",
+ "class": "intraTopic"}).save() # author_root has child leaf_node
yield {"topic": t, "link": l}
t.delete()
l.delete()
-def test_title_and_desc(root_wout_self_link, child_of_root_wout_self_link, root_with_self_link, child_of_root_with_self_link, grandchild_of_root_with_self_link):
- for t in [root_wout_self_link, child_of_root_wout_self_link, root_with_self_link, child_of_root_with_self_link, grandchild_of_root_with_self_link]:
- new_values = {"title": "new title", "heTitle": "new hebrew title", "description": {"en": "desc", "he": "hebrew desc"}}
+def test_title_and_desc(author_root, actual_author, root_with_self_link, child_of_root_with_self_link, grandchild_of_root_with_self_link):
+ for count, t in enumerate([author_root, actual_author, root_with_self_link, child_of_root_with_self_link, grandchild_of_root_with_self_link]):
+ new_values = {"title": f"new title {count+1}",
+ "altTitles": {"en": [f"New Alt title {count+1}"], "he": [f"New He Alt Title {count+1}"]},
+ "heTitle": f"new hebrew title {count+1}", "description": {"en": f"new desc", "he": "new hebrew desc"}}
update_topic(t["topic"], **new_values)
assert t["topic"].description == new_values["description"]
- assert t["topic"].get_primary_title('en') == new_values['title']
assert t["topic"].get_primary_title('he') == new_values['heTitle']
-
-
-def test_change_categories_and_titles(root_wout_self_link, root_with_self_link):
+ assert t["topic"].get_titles('en') == [new_values["title"]]+new_values["altTitles"]['en']
+
+def test_author_root(author_root, actual_author):
+ new_values = {"category": "authors", "title": actual_author["topic"].get_primary_title('en'),
+ "heTitle": actual_author["topic"].get_primary_title('he'),
+ "birthPlace": "Kyoto, Japan", "birthYear": 1300}
+ assert Place().load({'key': new_values["birthPlace"]}) is None
+ update_topic(actual_author["topic"], **new_values)
+ assert Place().load({'key': new_values["birthPlace"]})
+ assert actual_author["topic"].properties["birthYear"]["value"] == 1300
+ Place().load({'key': new_values["birthPlace"]}).delete()
+
+def test_change_categories_and_titles(author_root, root_with_self_link):
# tests moving both root categories down the tree and back up and asserting that moving down the tree changes the tree
# and assert that moving it back to the root position yields the original tree.
- # also tests
-
- orig_tree_from_normal_root = library.get_topic_toc_json_recursive(root_wout_self_link["topic"])
+ orig_tree_from_normal_root = library.get_topic_toc_json_recursive(author_root["topic"])
orig_tree_from_root_with_self_link = library.get_topic_toc_json_recursive(root_with_self_link["topic"])
orig_trees = [orig_tree_from_normal_root, orig_tree_from_root_with_self_link]
- roots = [root_wout_self_link["topic"], root_with_self_link["topic"]]
+ roots = [author_root["topic"], root_with_self_link["topic"]]
orig_titles = [roots[0].get_primary_title('en'), roots[1].get_primary_title('en')]
+ orig_he_titles = [roots[0].get_primary_title('he'), roots[1].get_primary_title('he')]
for i, root in enumerate(roots):
other_root = roots[1 - i]
- update_topic(root, title=f"fake new title {i+1}", category=other_root.slug) # move root to be child of other root
+ update_topic(root, title=f"fake new title {i+1}", heTitle=f"fake new he title {i+1}", category=other_root.slug) # move root to be child of other root
new_tree = library.get_topic_toc_json_recursive(other_root)
assert new_tree != orig_trees[i] # assert that the changes in the tree have occurred
- assert root.get_primary_title('en') != orig_titles[i]
- update_topic(root, title=orig_titles[i], category=Topic.ROOT) # move it back to the main menu
- assert root.get_primary_title('en') == orig_titles[i]
+ assert root.get_titles('en') != [orig_titles[i]]
+ assert root.get_titles('he') != [orig_he_titles[i]]
+ update_topic(root, title=orig_titles[i], heTitle=orig_he_titles[i], category=Topic.ROOT) # move it back to the main menu
+ assert root.get_titles('en') == [orig_titles[i]]
+ assert root.get_titles('he') == [orig_he_titles[i]]
+
final_tree_from_normal_root = library.get_topic_toc_json_recursive(roots[0])
final_tree_from_root_with_self_link = library.get_topic_toc_json_recursive(roots[1])
@@ -115,24 +128,24 @@ def test_change_categories_and_titles(root_wout_self_link, root_with_self_link):
assert final_tree_from_root_with_self_link == orig_tree_from_root_with_self_link
-def test_change_categories(root_wout_self_link, child_of_root_wout_self_link, root_with_self_link, child_of_root_with_self_link, grandchild_of_root_with_self_link):
+def test_change_categories(author_root, actual_author, root_with_self_link, child_of_root_with_self_link, grandchild_of_root_with_self_link):
# tests moving topics across the tree to a different root
- orig_tree_from_normal_root = library.get_topic_toc_json_recursive(root_wout_self_link["topic"])
+ orig_tree_from_normal_root = library.get_topic_toc_json_recursive(author_root["topic"])
orig_tree_from_root_with_self_link = library.get_topic_toc_json_recursive(root_with_self_link["topic"])
- topic_change_category(child_of_root_with_self_link["topic"], root_wout_self_link["topic"].slug)
- topic_change_category(child_of_root_wout_self_link["topic"], root_with_self_link["topic"].slug)
+ topic_change_category(child_of_root_with_self_link["topic"], author_root["topic"].slug)
+ topic_change_category(actual_author["topic"], root_with_self_link["topic"].slug)
- new_tree_from_normal_root = library.get_topic_toc_json_recursive(root_wout_self_link["topic"])
+ new_tree_from_normal_root = library.get_topic_toc_json_recursive(author_root["topic"])
new_tree_from_root_with_self_link = library.get_topic_toc_json_recursive(root_with_self_link["topic"])
assert new_tree_from_normal_root != orig_tree_from_normal_root
assert new_tree_from_root_with_self_link != orig_tree_from_root_with_self_link
topic_change_category(child_of_root_with_self_link["topic"], root_with_self_link["topic"].slug)
- topic_change_category(child_of_root_wout_self_link["topic"], root_wout_self_link["topic"].slug)
+ topic_change_category(actual_author["topic"], author_root["topic"].slug)
- new_tree_from_normal_root = library.get_topic_toc_json_recursive(root_wout_self_link["topic"])
+ new_tree_from_normal_root = library.get_topic_toc_json_recursive(author_root["topic"])
new_tree_from_root_with_self_link = library.get_topic_toc_json_recursive(root_with_self_link["topic"])
assert new_tree_from_normal_root == orig_tree_from_normal_root
assert new_tree_from_root_with_self_link == orig_tree_from_root_with_self_link
diff --git a/sefaria/helper/topic.py b/sefaria/helper/topic.py
index 36ebef5f48..5b9094fe78 100644
--- a/sefaria/helper/topic.py
+++ b/sefaria/helper/topic.py
@@ -50,12 +50,7 @@ def get_topic(v2, topic, with_html=True, with_links=True, annotate_links=True, w
ref_links = group_links_by_type('refTopic', ref_links, False, False)
response['refs'] = ref_links
if with_indexes and isinstance(topic_obj, AuthorTopic):
- response['indexes'] = [
- {
- "text": text_dict,
- "url": url
- } for (url, text_dict) in topic_obj.get_aggregated_urls_for_authors_indexes()
- ]
+ response['indexes'] = topic_obj.get_aggregated_urls_for_authors_indexes()
if getattr(topic_obj, 'isAmbiguous', False):
possibility_links = topic_obj.link_set(_class="intraTopic", query_kwargs={"linkType": TopicLinkType.possibility_type})
@@ -114,7 +109,7 @@ def merge_props_for_similar_refs(curr_link, new_link):
# as well as datasource and descriptions of all the similar refs
data_source = new_link.get('dataSource', None)
if data_source:
- curr_link = update_refs(curr_link, new_link, data_source)
+ curr_link = update_refs(curr_link, new_link)
curr_link = update_data_source_in_link(curr_link, new_link, data_source)
if not curr_link['is_sheet']:
@@ -128,20 +123,9 @@ def update_data_source_in_link(curr_link, new_link, data_source):
del new_link['dataSource']
return curr_link
-def is_data_source_learning_team(func):
- def wrapper(curr_link, new_link, data_source):
- if data_source == 'learning-team':
- return func(curr_link, new_link)
- else:
- return curr_link
- return wrapper
-
-@is_data_source_learning_team
def update_refs(curr_link, new_link):
- # in case the new_link was created by the learning team, we want to use ref of learning team link
- # in the case when both links are from the learning team, use whichever ref covers a smaller range
- if 'learning-team' not in curr_link['dataSources'] or len(curr_link['expandedRefs']) > len(
- new_link['expandedRefs']):
+ # use whichever ref covers a smaller range
+ if len(curr_link['expandedRefs']) > len(new_link['expandedRefs']):
curr_link['ref'] = new_link['ref']
curr_link['expandedRefs'] = new_link['expandedRefs']
return curr_link
@@ -171,6 +155,20 @@ def update_curated_primacy(curr_link, new_link):
curr_link['order']['curatedPrimacy'] = curr_curated_primacy
return curr_link
+def is_learning_team(dataSource):
+ return dataSource == 'learning-team' or dataSource == 'learning-team-editing-tool'
+
+def iterate_and_merge(new_ref_links, new_link, subset_ref_map, temp_subset_refs):
+ # temp_subset_refs contains the refs within link's expandedRefs that overlap with other refs
+ # subset_ref_map + new_ref_links contain mappings to get from the temp_subset_refs to the actual link objects
+ for seg_ref in temp_subset_refs:
+ for index in subset_ref_map[seg_ref]:
+ new_ref_links[index]['similarRefs'] += [new_link]
+ curr_link_learning_team = any([is_learning_team(dataSource) for dataSource in new_ref_links[index]['dataSources']])
+ if not curr_link_learning_team: # if learning team, ignore source with overlapping refs
+ new_ref_links[index] = merge_props_for_similar_refs(new_ref_links[index], new_link)
+ return new_ref_links
+
def sort_and_group_similar_refs(ref_links):
ref_links.sort(key=cmp_to_key(sort_refs_by_relevance))
subset_ref_map = defaultdict(list)
@@ -178,11 +176,11 @@ def sort_and_group_similar_refs(ref_links):
for link in ref_links:
del link['topic']
temp_subset_refs = subset_ref_map.keys() & set(link.get('expandedRefs', []))
- for seg_ref in temp_subset_refs:
- for index in subset_ref_map[seg_ref]:
- new_ref_links[index]['similarRefs'] += [link]
- new_ref_links[index] = merge_props_for_similar_refs(new_ref_links[index], link)
- if len(temp_subset_refs) == 0:
+ new_data_source = link.get("dataSource", None)
+ should_merge = len(temp_subset_refs) > 0 and not is_learning_team(new_data_source) # learning team links should be handled separately from one another and not merged
+ if should_merge:
+ new_ref_links = iterate_and_merge(new_ref_links, link, subset_ref_map, temp_subset_refs)
+ else:
link['similarRefs'] = []
link['dataSources'] = {}
if link.get('dataSource', None):
@@ -1039,26 +1037,24 @@ def topic_change_category(topic_obj, new_category, old_category="", rebuild=Fals
rebuild_topic_toc(topic_obj, category_changed=True)
return topic_obj
-def update_topic_titles(topic_obj, data):
- for lang in ['en', 'he']:
- for title in topic_obj.get_titles(lang):
- topic_obj.remove_title(title, lang)
- for title in data['altTitles'][lang]:
- topic_obj.add_title(title, lang)
-
- topic_obj.add_title(data['title'], 'en', True, True)
- topic_obj.add_title(data['heTitle'], 'he', True, True)
- return topic_obj
+def update_topic_titles(topic, title="", heTitle="", **kwargs):
+ new_primary = {"en": title, "he": heTitle}
+ for lang in ['en', 'he']: # first remove all titles and add new primary and then alt titles
+ for title in topic.get_titles(lang):
+ topic.remove_title(title, lang)
+ topic.add_title(new_primary[lang], lang, True, False)
+ if 'altTitles' in kwargs:
+ for title in kwargs['altTitles'][lang]:
+ topic.add_title(title, lang)
+ return topic
-def update_authors_place_and_time(topic_obj, data, dataSource='learning-team-editing-tool'):
+def update_authors_place_and_time(topic, dataSource='learning-team-editing-tool', **kwargs):
# update place info added to author, then update year and era info
- if not hasattr(topic_obj, 'properties'):
- topic_obj.properties = {}
- process_topic_place_change(topic_obj, data)
- topic_obj = update_author_era(topic_obj, data, dataSource=dataSource)
-
- return topic_obj
+ if not hasattr(topic, 'properties'):
+ topic.properties = {}
+ process_topic_place_change(topic, **kwargs)
+ return update_author_era(topic, dataSource=dataSource, **kwargs)
def update_properties(topic_obj, dataSource, k, v):
if v == '':
@@ -1066,54 +1062,54 @@ def update_properties(topic_obj, dataSource, k, v):
else:
topic_obj.properties[k] = {'value': v, 'dataSource': dataSource}
-def update_author_era(topic_obj, data, dataSource='learning-team-editing-tool'):
+def update_author_era(topic_obj, dataSource='learning-team-editing-tool', **kwargs):
for k in ["birthYear", "deathYear"]:
- if k in data.keys(): # only change property value if key is in data, otherwise it indicates no change
- year = data[k]
+ if k in kwargs.keys(): # only change property value if key exists, otherwise it indicates no change
+ year = kwargs[k]
update_properties(topic_obj, dataSource, k, year)
- if 'era' in data.keys(): # only change property value if key is in data, otherwise it indicates no change
+ if 'era' in kwargs.keys(): # only change property value if key is in data, otherwise it indicates no change
prev_era = topic_obj.properties.get('era', {}).get('value')
- era = data['era']
+ era = kwargs['era']
update_properties(topic_obj, dataSource, 'era', era)
if era != '':
create_era_link(topic_obj, prev_era_to_delete=prev_era)
return topic_obj
-def update_topic(topic_obj, **kwargs):
+def update_topic(topic, **kwargs):
"""
Can update topic object's title, hebrew title, category, description, and categoryDescription fields
- :param topic_obj: (Topic) The topic to update
+ :param topic: (Topic) The topic to update
:param **kwargs can be title, heTitle, category, description, categoryDescription, and rebuild_toc where `title`, `heTitle`,
and `category` are strings. `description` and `categoryDescription` are dictionaries where the fields are `en` and `he`.
The `category` parameter should be the slug of the new category. `rebuild_topic_toc` is a boolean and is assumed to be True
:return: (model.Topic) The modified topic
"""
old_category = ""
- orig_slug = topic_obj.slug
- update_topic_titles(topic_obj, kwargs)
+ orig_slug = topic.slug
+ update_topic_titles(topic, **kwargs)
if kwargs.get('category') == 'authors':
- topic_obj = update_authors_place_and_time(topic_obj, kwargs)
+ topic = update_authors_place_and_time(topic, **kwargs)
- if 'category' in kwargs and kwargs['category'] != kwargs['origCategory']:
- orig_link = IntraTopicLink().load({"linkType": "displays-under", "fromTopic": topic_obj.slug, "toTopic": {"$ne": topic_obj.slug}})
+ if 'category' in kwargs and kwargs['category'] != kwargs.get('origCategory', kwargs['category']):
+ orig_link = IntraTopicLink().load({"linkType": "displays-under", "fromTopic": topic.slug, "toTopic": {"$ne": topic.slug}})
old_category = orig_link.toTopic if orig_link else Topic.ROOT
if old_category != kwargs['category']:
- topic_obj = topic_change_category(topic_obj, kwargs["category"], old_category=old_category) # can change topic and intratopiclinks
+ topic = topic_change_category(topic, kwargs["category"], old_category=old_category) # can change topic and intratopiclinks
if kwargs.get('manual', False):
- topic_obj.data_source = "sefaria" # any topic edited manually should display automatically in the TOC and this flag ensures this
- topic_obj.description_published = True
+ topic.data_source = "sefaria" # any topic edited manually should display automatically in the TOC and this flag ensures this
+ topic.description_published = True
if "description" in kwargs or "categoryDescription" in kwargs:
- topic_obj.change_description(kwargs.get("description", None), kwargs.get("categoryDescription", None))
+ topic.change_description(kwargs.get("description", None), kwargs.get("categoryDescription", None))
- topic_obj.save()
+ topic.save()
if kwargs.get('rebuild_topic_toc', True):
- rebuild_topic_toc(topic_obj, orig_slug=orig_slug, category_changed=(old_category != kwargs.get('category', "")))
- return topic_obj
+ rebuild_topic_toc(topic, orig_slug=orig_slug, category_changed=(old_category != kwargs.get('category', "")))
+ return topic
def rebuild_topic_toc(topic_obj, orig_slug="", category_changed=False):
@@ -1250,12 +1246,12 @@ def delete_ref_topic_link(tref, to_topic, link_type, lang):
if link is None:
return {"error": f"Link between {tref} and {to_topic} doesn't exist."}
- if lang in link.order['availableLangs']:
+ if lang in link.order.get('availableLangs', []):
link.order['availableLangs'].remove(lang)
- if lang in link.order['curatedPrimacy']:
+ if lang in link.order.get('curatedPrimacy', []):
link.order['curatedPrimacy'].pop(lang)
- if len(link.order['availableLangs']) > 0:
+ if len(link.order.get('availableLangs', [])) > 0:
link.save()
return {"status": "ok"}
else: # deleted in both hebrew and english so delete link object
@@ -1263,4 +1259,23 @@ def delete_ref_topic_link(tref, to_topic, link_type, lang):
link.delete()
return {"status": "ok"}
else:
- return {"error": f"Cannot delete link between {tref} and {to_topic}."}
\ No newline at end of file
+ return {"error": f"Cannot delete link between {tref} and {to_topic}."}
+
+
+def add_image_to_topic(topic_slug, image_uri, en_caption, he_caption):
+ """
+ A function to add an image to a Topic in the database. Helper for data migration.
+ This function queries the desired Topic, adds the image data, and then saves.
+ :param topic_slug String: A valid slug for a Topic
+ :param image_uri String: The URI of the image stored in the GCP images bucket, in the topics subdirectory.
+ NOTE: Incorrectly stored, or external images, will not pass validation for save
+ :param en_caption String: The English caption for a Topic image
+ :param he_caption String: The Hebrew caption for a Topic image
+ """
+ topic = Topic.init(topic_slug)
+ topic.image = {"image_uri": image_uri,
+ "image_caption": {
+ "en": en_caption,
+ "he": he_caption
+ }}
+ topic.save()
\ No newline at end of file
diff --git a/sefaria/model/__init__.py b/sefaria/model/__init__.py
index b5acc4cc97..83661bc1cb 100644
--- a/sefaria/model/__init__.py
+++ b/sefaria/model/__init__.py
@@ -41,6 +41,7 @@
from .webpage import WebPage, WebPageSet
from .media import Media, MediaSet
from .topic import Topic, PersonTopic, AuthorTopic, TopicLinkType, IntraTopicLink, RefTopicLink, TopicLinkType, TopicDataSource, TopicSet, PersonTopicSet, AuthorTopicSet, TopicLinkTypeSet, RefTopicLinkSet, IntraTopicLinkSet, TopicLinkSetHelper
+from .portal import Portal
from .manuscript import Manuscript, ManuscriptSet, ManuscriptPage, ManuscriptPageSet
from .linker.ref_part import RawRef
from .linker.ref_resolver import RefResolver
diff --git a/sefaria/model/abstract.py b/sefaria/model/abstract.py
index ad2d0ddc66..2057c0e91a 100644
--- a/sefaria/model/abstract.py
+++ b/sefaria/model/abstract.py
@@ -3,6 +3,7 @@
"""
abstract.py - abstract classes for Sefaria models
"""
+from cerberus import Validator
import collections
import structlog
import copy
@@ -15,7 +16,7 @@
from bson.objectid import ObjectId
from sefaria.system.database import db
-from sefaria.system.exceptions import InputError
+from sefaria.system.exceptions import InputError, SluggedMongoRecordMissingError
logger = structlog.get_logger(__name__)
@@ -31,6 +32,7 @@ class AbstractMongoRecord(object):
criteria_override_field = None # If a record type uses a different primary key (such as 'title' for Index records), and the presence of an override field in a save indicates that the primary attribute is changing ("oldTitle" in Index records) then this class attribute has that override field name used.
required_attrs = [] # list of names of required attributes
optional_attrs = [] # list of names of optional attributes
+ attr_schemas = {} # schemas to validate that an attribute is in the right format. Keys are attribute names, values are schemas in Cerberus format.
track_pkeys = False
pkeys = [] # list of fields that others may depend on
history_noun = None # Label for history records
@@ -242,6 +244,16 @@ def _validate(self):
" not in " + ",".join(self.required_attrs) + " or " + ",".join(self.optional_attrs))
return False
"""
+ for attr, schema in self.attr_schemas.items():
+ v = Validator(schema)
+ try:
+ value = getattr(self, attr)
+ if not v.validate(value):
+ raise InputError(v.errors)
+ except AttributeError:
+ # not checking here if value exists, that is done above.
+ # assumption is if value doesn't exist, it's optional
+ pass
return True
def _normalize(self):
@@ -382,17 +394,20 @@ class SluggedAbstractMongoRecord(AbstractMongoRecord, metaclass=SluggedAbstractM
cacheable = False
@classmethod
- def init(cls, slug: str) -> 'AbstractMongoRecord':
+ def init(cls, slug: str, slug_field_idx: int = None) -> 'AbstractMongoRecord':
"""
Convenience func to avoid using .load() when you're only passing a slug
Applicable only if class defines `slug_fields`
- :param slug:
- :return:
+ @param slug:
+ @param slug_field_idx: Optional index of slug field in case `cls` has multiple slug fields. Index should be between 0 and len(cls.slug_fields) - 1
+ @return: instance of `cls` with slug `slug`
"""
- if len(cls.slug_fields) != 1:
- raise Exception("Can only call init() if exactly one slug field is defined.")
+ if len(cls.slug_fields) != 1 and slug_field_idx is None:
+ raise Exception("Can only call init() if exactly one slug field is defined or `slug_field_idx` is passed as"
+ " a parameter.")
+ slug_field_idx = slug_field_idx or 0
if not cls.cacheable or slug not in cls._init_cache:
- instance = cls().load({cls.slug_fields[0]: slug})
+ instance = cls().load({cls.slug_fields[slug_field_idx]: slug})
if cls.cacheable:
cls._init_cache[slug] = instance
else:
@@ -427,6 +442,20 @@ def _normalize(self):
for slug_field in self.slug_fields:
setattr(self, slug_field, self.normalize_slug_field(slug_field))
+ @classmethod
+ def validate_slug_exists(cls, slug: str, slug_field_idx: int = None):
+ """
+ Validate that `slug` points to an existing object of type `cls`. Pass `slug_field` if `cls` has multiple slugs
+ associated with it (e.g. TopicLinkType)
+ @param slug: Slug to look up
+ @param slug_field_idx: Optional index of slug field in case `cls` has multiple slug fields. Index should be
+ between 0 and len(cls.slug_fields) - 1
+ @return: raises SluggedMongoRecordMissingError is slug doesn't match an existing object
+ """
+ instance = cls.init(slug, slug_field_idx)
+ if not instance:
+ raise SluggedMongoRecordMissingError(f"{cls.__name__} with slug '{slug}' does not exist.")
+
class Cloneable:
diff --git a/sefaria/model/autospell.py b/sefaria/model/autospell.py
index 38d2b48c93..f9d8379bb9 100644
--- a/sefaria/model/autospell.py
+++ b/sefaria/model/autospell.py
@@ -29,7 +29,6 @@
+ "\u05c1\u05c2" \
+ "\u05d0\u05d1\u05d2\u05d3\u05d4\u05d5\u05d6\u05d7\u05d8\u05d9\u05da\u05db\u05dc\u05dd\u05de\u05df" \
+ "\u05e0\u05e1\u05e2\u05e3\u05e4\u05e5\u05e6\u05e7\u05e8\u05e9\u05ea" \
- + "\u05f3\u05f4" \
+ "\u200e\u200f\u2013\u201c\u201d\ufeff" \
+ " Iabcdefghijklmnopqrstuvwxyz1234567890[]`:;.-,*$()'&?/\""
@@ -218,6 +217,7 @@ def complete(self, instring, limit=0, redirected=False):
:return: completions list, completion objects list
"""
instring = instring.strip() # A terminal space causes some kind of awful "include everything" behavior
+ instring = self.normalizer(instring)
if len(instring) >= self.max_completion_length:
return [], []
cm = Completions(self, self.lang, instring, limit,
diff --git a/sefaria/model/count.py b/sefaria/model/count.py
deleted file mode 100644
index 63b31a9552..0000000000
--- a/sefaria/model/count.py
+++ /dev/null
@@ -1,93 +0,0 @@
-"""
-count.py
-Writes to MongoDB Collection: counts
-"""
-import structlog
-logger = structlog.get_logger(__name__)
-
-from . import abstract as abst
-import sefaria.datatype.jagged_array as ja
-from sefaria.system.exceptions import BookNameError
-
-
-class Count(abst.AbstractMongoRecord):
- """
- """
- collection = 'counts'
-
- required_attrs = [
- "textComplete",
- "percentAvailable",
- "availableCounts"
- ]
- optional_attrs = [
- "categories",
- "availableTexts",
- "title",
- "linksCount",
- "estimatedCompleteness",
- "flags",
- "allVersionCounts"
- ]
-
- def _set_derived_attributes(self):
- from . import text
-
- if getattr(self, "title", None):
- try:
- indx = text.library.get_index(self.title)
- attrs = indx.contents()
- #del attrs["_id"]
- self.index_attr_keys = list(attrs.keys())
- self.__dict__.update(attrs)
- except BookNameError as e:
- logger.warning("Count object failed to get Index for {} : {} Normal right after Index name change.".format(self.title, e))
-
- #todo: this needs to be considered. What happens when the data is modified? etc.
- if getattr(self, "allVersionCounts", None) is not None:
- self._allVersionCountsJA = ja.JaggedIntArray(self.allVersionCounts)
-
- #remove uneccesary and dangerous categories attr from text counts
- #This assumes that category nodes have no title element
- #todo: review this. Do we need to subclass text and category counts?
- def _saveable_attr_keys(self):
- attrs = super(Count, self)._saveable_attr_keys()
- if getattr(self, "title", None):
- attrs.remove("categories")
- return attrs
-
- def contents(self, **kwargs):
- attrs = super(Count, self).contents()
- for key in self.index_attr_keys:
- attrs[key] = getattr(self, key, None)
- return attrs
-
- #deprecated - use JA directly
- def next_address(self, starting_points=None):
- starting_points = starting_points or []
- if len(starting_points) > 0:
- starting_points[-1] += 1
- return self._allVersionCountsJA.next_index(starting_points)
-
- #deprecated - use JA directly
- def prev_address(self, starting_points=None):
- starting_points = starting_points or []
- if len(starting_points) > 0:
- starting_points[-1] -= 1
- return self._allVersionCountsJA.prev_index(starting_points)
-
- #deprecated - use JA directly
- def section_length(self, section_numbers):
- """
- :param section_numbers: The list of 1-based (E.g. Chapter 5 is section_number 5) section numbers
- :return: The length of that section
- """
- return self._allVersionCountsJA.sub_array_length([s - 1 for s in section_numbers])
-
-
-class CountSet(abst.AbstractMongoSet):
- recordClass = Count
-
-
-def process_index_delete_in_counts(indx, **kwargs):
- CountSet({"title":indx.title}).delete()
diff --git a/sefaria/model/place.py b/sefaria/model/place.py
index a39bf0e610..6f5b2729b5 100644
--- a/sefaria/model/place.py
+++ b/sefaria/model/place.py
@@ -64,7 +64,11 @@ def create_new_place(cls, en, he=None):
def city_to_coordinates(self, city):
geolocator = Nominatim(user_agent='hello@sefaria.org')
location = geolocator.geocode(city)
- self.point_location(lon=location.longitude, lat=location.latitude)
+ if location and location.raw['type'] in ['administrative', 'city', 'town', 'municipality']:
+ self.point_location(lon=location.longitude, lat=location.latitude)
+ else:
+ raise InputError(f"{city} is not a real city.")
+
def point_location(self, lon=None, lat=None):
if lat is None and lon is None:
@@ -105,14 +109,14 @@ def process_index_place_change(indx, **kwargs):
if kwargs['new'] != '':
Place.create_new_place(en=kwargs['new'], he=he_new_val)
-def process_topic_place_change(topic_obj, data):
+def process_topic_place_change(topic_obj, **kwargs):
keys = ["birthPlace", "deathPlace"]
for key in keys:
- if key in data.keys(): # only change property value if key is in data, otherwise it indicates no change
- new_val = data[key]
+ if key in kwargs.keys(): # only change property value if key is in data, otherwise it indicates no change
+ new_val = kwargs[key]
if new_val != '':
he_key = get_he_key(key)
- he_new_val = data.get(he_key, '')
+ he_new_val = kwargs.get(he_key, '')
place = Place.create_new_place(en=new_val, he=he_new_val)
topic_obj.properties[key] = {"value": place.primary_name('en'), 'dataSource': 'learning-team-editing-tool'}
else:
diff --git a/sefaria/model/portal.py b/sefaria/model/portal.py
new file mode 100644
index 0000000000..36984ceaad
--- /dev/null
+++ b/sefaria/model/portal.py
@@ -0,0 +1,136 @@
+from . import abstract as abst
+from sefaria.system.validators import validate_url, validate_http_method
+import structlog
+
+logger = structlog.get_logger(__name__)
+
+
+class Portal(abst.SluggedAbstractMongoRecord):
+ collection = 'portals'
+ slug_fields = ['slug']
+
+ required_attrs = [
+ "slug",
+ "about",
+ "name",
+ ]
+ optional_attrs = [
+ "mobile",
+ "newsletter",
+ "organization"
+ ]
+ attr_schemas = {
+ "about": {
+ "title": {
+ "type": "dict",
+ "required": True,
+ "schema": {
+ "en": {"type": "string", "required": True},
+ "he": {"type": "string", "required": True}
+ }
+ },
+ "title_url": {"type": "string"},
+ "image_uri": {"type": "string"},
+ "image_caption": {
+ "type": "dict",
+ "schema": {
+ "en": {"type": "string"},
+ "he": {"type": "string"}
+ }
+ },
+ "description": {
+ "type": "dict",
+ "schema": {
+ "en": {"type": "string", "required": True},
+ "he": {"type": "string", "required": True}
+ }
+ },
+ },
+ "mobile": {
+ "title": {
+ "type": "dict",
+ "required": True,
+ "schema": {
+ "en": {"type": "string", "required": True},
+ "he": {"type": "string", "required": True}
+ }
+ },
+ "description": {
+ "type": "dict",
+ "schema": {
+ "en": {"type": "string"},
+ "he": {"type": "string"}
+ }
+ },
+ "android_link": {"type": "string"},
+ "ios_link": {"type": "string"}
+ },
+ "organization": {
+ "title": {
+ "type": "dict",
+ "required": True,
+ "schema": {
+ "en": {"type": "string", "required": True},
+ "he": {"type": "string", "required": True}
+ }
+ },
+ "description": {
+ "type": "dict",
+ "schema": {
+ "en": {"type": "string", "required": True},
+ "he": {"type": "string", "required": True}
+ }
+ },
+ },
+ "newsletter": {
+ "title": {
+ "type": "dict",
+ "required": True,
+ "schema": {
+ "en": {"type": "string", "required": True},
+ "he": {"type": "string", "required": True}
+ }
+ },
+ "description": {
+ "type": "dict",
+ "schema": {
+ "en": {"type": "string", "required": True},
+ "he": {"type": "string", "required": True}
+ }
+ },
+ "title_url": {"type": "string"},
+ "api_schema": {
+ "type": "dict",
+ "schema": {
+ "http_method": {"type": "string", "required": True},
+ "payload": {
+ "type": "dict",
+ "schema": {
+ "first_name_key": {"type": "string"},
+ "last_name_key": {"type": "string"},
+ "email_key": {"type": "string"}
+ }
+ },
+ }
+ }
+ }
+ }
+
+ def _validate(self):
+ super(Portal, self)._validate()
+ if hasattr(self, "about"):
+ title_url = self.about.get("title_url")
+ if title_url: validate_url(title_url)
+ if hasattr(self, "mobile"):
+ android_link = self.mobile.get("android_link")
+ if android_link: validate_url(android_link)
+ ios_link = self.mobile.get("ios_link")
+ if ios_link: validate_url(ios_link)
+ if hasattr(self, "newsletter"):
+ http_method = self.newsletter.get("api_schema", {}).get("http_method")
+ if http_method: validate_http_method(http_method)
+ return True
+
+
+class PortalSet(abst.AbstractMongoSet):
+ recordClass = Portal
diff --git a/sefaria/model/schema.py b/sefaria/model/schema.py
index 305dda01b8..568f0b89a5 100644
--- a/sefaria/model/schema.py
+++ b/sefaria/model/schema.py
@@ -125,6 +125,9 @@ def secondary_titles(self, lang=None):
return [t for t in self.all_titles(lang) if t != self.primary_title(lang)]
def remove_title(self, text, lang):
+ is_primary = len([t for t in self.titles if (t["lang"] == lang and t["text"] == text and t.get('primary'))])
+ if is_primary:
+ self._primary_title[lang] = None
self.titles = [t for t in self.titles if not (t["lang"] == lang and t["text"] == text)]
return self
@@ -1073,12 +1076,11 @@ def full_regex(self, title, lang, anchored=True, compiled=True, capture_title=Fa
def address_regex(self, lang, **kwargs):
group = "a0"
reg = self._addressTypes[0].regex(lang, group, **kwargs)
- if not self._addressTypes[0].stop_parsing(lang):
- for i in range(1, self.depth):
- group = "a{}".format(i)
- reg += "(" + self.after_address_delimiter_ref + self._addressTypes[i].regex(lang, group, **kwargs) + ")"
- if not kwargs.get("strict", False):
- reg += "?"
+ for i in range(1, self.depth):
+ group = "a{}".format(i)
+ reg += "(" + self.after_address_delimiter_ref + self._addressTypes[i].regex(lang, group, **kwargs) + ")"
+ if not kwargs.get("strict", False):
+ reg += "?"
if kwargs.get("match_range"):
# TODO there is a potential error with this regex. it fills in toSections starting from highest depth and going to lowest.
@@ -1089,14 +1091,13 @@ def address_regex(self, lang, **kwargs):
reg += r"(?=\S)" # must be followed by something (Lookahead)
group = "ar0"
reg += self._addressTypes[0].regex(lang, group, **kwargs)
- if not self._addressTypes[0].stop_parsing(lang):
- reg += "?"
- for i in range(1, self.depth):
- reg += r"(?:(?:" + self.after_address_delimiter_ref + r")?"
- group = "ar{}".format(i)
- reg += "(" + self._addressTypes[i].regex(lang, group, **kwargs) + ")"
- # assuming strict isn't relevant on ranges # if not kwargs.get("strict", False):
- reg += ")?"
+ reg += "?"
+ for i in range(1, self.depth):
+ reg += r"(?:(?:" + self.after_address_delimiter_ref + r")?"
+ group = "ar{}".format(i)
+ reg += "(" + self._addressTypes[i].regex(lang, group, **kwargs) + ")"
+ # assuming strict isn't relevant on ranges # if not kwargs.get("strict", False):
+ reg += ")?"
reg += r")?" # end range clause
return reg
@@ -2072,15 +2073,6 @@ def hebrew_number_regex():
[\u05d0-\u05d8]? # One or zero alef-tet (1-9)
)"""
- def stop_parsing(self, lang):
- """
- If this is true, the regular expression will stop parsing at this address level for this language.
- It is currently checked for only in the first address position, and is used for Hebrew Talmud addresses.
- :param lang: "en" or "he"
- :return bool:
- """
- return False
-
def toNumber(self, lang, s):
"""
Return the numerical form of s in this address scheme
@@ -2351,11 +2343,6 @@ def _core_regex(self, lang, group_id=None, **kwargs):
return reg
- def stop_parsing(self, lang):
- if lang == "he":
- return True
- return False
-
def toNumber(self, lang, s, **kwargs):
amud_b_list = ['b', 'B', 'ᵇ']
if lang == "en":
@@ -2490,11 +2477,6 @@ def _core_regex(self, lang, group_id=None, **kwargs):
return reg
- def stop_parsing(self, lang):
- if lang == "he":
- return True
- return False
-
def toNumber(self, lang, s, **kwargs):
if lang == "en":
try:
diff --git a/sefaria/model/tests/abstract_test.py b/sefaria/model/tests/abstract_test.py
index ac6d876eeb..0b1efd1031 100644
--- a/sefaria/model/tests/abstract_test.py
+++ b/sefaria/model/tests/abstract_test.py
@@ -9,6 +9,7 @@
# cascade functions are tested in person_test.py
+
def setup_module(module):
global record_classes
global set_classes
@@ -21,7 +22,12 @@ def setup_module(module):
print(record_classes)
-class Test_Mongo_Record_Models(object):
+def get_record_classes_with_slugs():
+ classes = abstract.get_record_classes()
+ return filter(lambda x: getattr(x, 'slug_fields', None) is not None and x.__name__ != "Portal", classes)
+
+
+class TestMongoRecordModels(object):
def test_class_attribute_collection(self):
for sub in record_classes:
@@ -59,7 +65,7 @@ def test_slug(slug, final_slug):
test_slug('blah/blah', 'blah-blah')
test_slug('blah == בלה', 'blah-בלה')
- @pytest.mark.parametrize("sub", filter(lambda x: getattr(x, 'slug_fields', None) is not None, abstract.get_record_classes()))
+ @pytest.mark.parametrize("sub", get_record_classes_with_slugs())
def test_normalize_slug_field(self, sub):
"""
diff --git a/sefaria/model/tests/chunk_test.py b/sefaria/model/tests/chunk_test.py
index 28e99bc099..b1c725c745 100644
--- a/sefaria/model/tests/chunk_test.py
+++ b/sefaria/model/tests/chunk_test.py
@@ -196,6 +196,17 @@ def test_text_family_alts():
c = tf.contents()
assert c.get("alts")
+def test_text_family_version_with_underscores():
+ with_spaces = TextFamily(
+ Ref("Amos 1"), lang="he", lang2="en", commentary=False,
+ version="Miqra according to the Masorah",
+ version2="Tanakh: The Holy Scriptures, published by JPS")
+ with_underscores = TextFamily(
+ Ref("Amos 1"), lang="he", lang2="en", commentary=False,
+ version="Miqra_according_to_the_Masorah",
+ version2="Tanakh:_The_Holy_Scriptures,_published_by_JPS")
+ assert with_spaces.he == with_underscores.he
+ assert with_spaces.text == with_underscores.text
def test_validate():
passing_refs = [
diff --git a/sefaria/model/tests/he_ref_test.py b/sefaria/model/tests/he_ref_test.py
index 55fa32975e..b5c615b9ed 100644
--- a/sefaria/model/tests/he_ref_test.py
+++ b/sefaria/model/tests/he_ref_test.py
@@ -100,6 +100,10 @@ def test_talmud(self):
assert r.sections[0] == 58
assert len(r.sections) == 1
+ r = m.Ref("סוטה לה א:יא")
+ assert r.book == 'Sotah'
+ assert r.sections == [69, 11]
+
def test_length_catching(self):
with pytest.raises(InputError):
r = m.Ref('דברים שם')
diff --git a/sefaria/model/tests/portal_test.py b/sefaria/model/tests/portal_test.py
new file mode 100644
index 0000000000..dfe9aed7f0
--- /dev/null
+++ b/sefaria/model/tests/portal_test.py
@@ -0,0 +1,548 @@
+import pytest
+from sefaria.model.portal import Portal # Replace with your actual validation function
+from sefaria.model.topic import Topic
+from sefaria.system.exceptions import SluggedMongoRecordMissingError
+from sefaria.system.database import db
+
+valids = [
+ {
+ "slug": "English Title",
+ "name": {"en": "a", "he": "b"},
+ "about": {
+ "title": {
+ "en": "English Title",
+ "he": "Hebrew Title"
+ },
+ "title_url": "https://example.com",
+ "image_uri": "gs://your-bucket/image.jpg",
+ "description": {
+ "en": "English Description",
+ "he": "Hebrew Description"
+ }
+ },
+ "mobile": {
+ "title": {
+ "en": "Mobile Title",
+ "he": "Mobile Hebrew Title"
+ },
+ "android_link": "https://android-link.com",
+ "ios_link": "https://ios-link.com"
+ },
+ "newsletter": {
+ "title": {
+ "en": "Newsletter Title",
+ "he": "Newsletter Hebrew Title"
+ },
+ "description": {
+ "en": "Newsletter English Description",
+ "he": "Newsletter Hebrew Description"
+ },
+ "api_schema": {
+ "http_method": "POST",
+ "payload": {
+ "first_name_key": "fname",
+ "last_name_key": "lname",
+ "email_key": "email"
+ }
+ }
+ }
+ },
+ {
+ "slug": "English Title",
+ "name": {"en": "a", "he": "b"},
+ "about": {
+ "title": {
+ "en": "English Title",
+ "he": "Hebrew Title"
+ },
+ "description": {
+ "en": "English Description",
+ "he": "Hebrew Description"
+ }
+ },
+ "mobile": {
+ "title": {
+ "en": "Mobile Title",
+ "he": "Mobile Hebrew Title"
+ }
+ },
+ "newsletter": {
+ "title": {
+ "en": "Newsletter Title",
+ "he": "Newsletter Hebrew Title"
+ },
+ "api_schema": {
+ "http_method": "GET"
+ }
+ }
+ },
+{
+ "slug": "English Title",
+ "name": {"en": "a", "he": "b"},
+ "about": {
+ "title": {
+ "en": "English Title",
+ "he": "Hebrew Title"
+ },
+ "title_url": "https://example.com",
+ "image_uri": "gs://your-bucket/image.jpg",
+ "description": {
+ "en": "English Description",
+ "he": "Hebrew Description"
+ }
+ },
+ "mobile": {
+ "title": {
+ "en": "Mobile Title",
+ "he": "Mobile Hebrew Title"
+ },
+ "android_link": "https://android-link.com",
+ "ios_link": "https://ios-link.com"
+ },
+ "newsletter": {
+ "title": {
+ "en": "Newsletter Title",
+ "he": "Newsletter Hebrew Title"
+ },
+ "description": {
+ "en": "Newsletter English Description",
+ "he": "Newsletter Hebrew Description"
+ },
+ "api_schema": {
+ "http_method": "POST",
+ "payload": {
+ "first_name_key": "fname",
+ "last_name_key": "lname",
+ "email_key": "email"
+ }
+ }
+ }
+ },
+ {
+ "slug": "English Title",
+ "name": {"en": "a", "he": "b"},
+ "about": {
+ "title": {
+ "en": "English Title",
+ "he": "Hebrew Title"
+ }
+ },
+ "mobile": {
+ "title": {
+ "en": "Mobile Title",
+ "he": "Mobile Hebrew Title"
+ }
+ },
+ "newsletter": {
+ "title": {
+ "en": "Newsletter Title",
+ "he": "Newsletter Hebrew Title"
+ },
+ "api_schema": {
+ "http_method": "GET"
+ }
+ }
+ },
+ {
+ "slug": "English Title",
+ "name": {"en": "a", "he": "b"},
+ "about": {
+ "title": {
+ "en": "English Title",
+ "he": "Hebrew Title"
+ }
+ },
+ "mobile": {
+ "title": {
+ "en": "Mobile Title",
+ "he": "Mobile Hebrew Title"
+ },
+ "android_link": "https://android-link.com"
+ },
+ "newsletter": {
+ "title": {
+ "en": "Newsletter Title",
+ "he": "Newsletter Hebrew Title"
+ }
+ }
+ },
+ {
+ "slug": "English Title",
+ "name": {"en": "a", "he": "b"},
+ "about": {
+ "title": {
+ "en": "English Title",
+ "he": "Hebrew Title"
+ },
+ "title_url": "https://example.com",
+ "image_uri": "gs://your-bucket/image.jpg",
+ "description": {
+ "en": "English Description",
+ "he": "Hebrew Description"
+ }
+ },
+ "mobile": {
+ "title": {
+ "en": "Mobile Title",
+ "he": "Mobile Hebrew Title"
+ },
+ "android_link": "https://android-link.com",
+ "ios_link": "https://ios-link.com"
+ },
+ "newsletter": {
+ "title": {
+ "en": "Newsletter Title",
+ "he": "Newsletter Hebrew Title"
+ },
+ "description": {
+ "en": "Newsletter English Description",
+ "he": "Newsletter Hebrew Description"
+ },
+ "api_schema": {
+ "http_method": "POST",
+ "payload": {
+ "first_name_key": "fname",
+ "last_name_key": "lname",
+ "email_key": "email"
+ }
+ }
+ }
+ },
+ {
+ "slug": "English Title",
+ "name": {"en": "a", "he": "b"},
+ "about": {
+ "title": {
+ "en": "English Title",
+ "he": "Hebrew Title"
+ }
+ },
+ "mobile": {
+ "title": {
+ "en": "Mobile Title",
+ "he": "Mobile Hebrew Title"
+ }
+ },
+ "newsletter": {
+ "title": {
+ "en": "Newsletter Title",
+ "he": "Newsletter Hebrew Title"
+ },
+ "api_schema": {
+ "http_method": "GET"
+ }
+ }
+ },
+ {
+ "slug": "English Title",
+ "name": {"en": "a", "he": "b"},
+ "about": {
+ "title": {
+ "en": "English Title",
+ "he": "Hebrew Title"
+ },
+ "image_uri": "gs://your-bucket/image.jpg"
+ },
+ "mobile": {
+ "title": {
+ "en": "Mobile Title",
+ "he": "Mobile Hebrew Title"
+ }
+ },
+ "newsletter": {
+ "title": {
+ "en": "Newsletter Title",
+ "he": "Newsletter Hebrew Title"
+ },
+ "api_schema": {
+ "http_method": "POST",
+ "payload": {
+ "first_name_key": "fname",
+ "last_name_key": "lname",
+ "email_key": "email"
+ }
+ }
+ }
+ },
+ {
+ "slug": "English Title",
+ "name": {"en": "a", "he": "b"},
+ "about": {
+ "title": {
+ "en": "About Us",
+ "he": "עלינו"
+ }
+ }
+ }
+]
+
+invalids = [
+ # Missing "about" key
+ {
+ "slug": "English Title",
+ "name": {"en": "a", "he": "b"},
+ "mobile": {
+ "title": {
+ "en": "Mobile Title",
+ "he": "Mobile Hebrew Title"
+ },
+ "android_link": "https://android-link.com",
+ "ios_link": "https://ios-link.com"
+ },
+ "newsletter": {
+ "title": {
+ "en": "Newsletter Title",
+ "he": "Newsletter Hebrew Title"
+ },
+ "api_schema": {
+ "http_method": "POST",
+ "payload": {
+ "first_name_key": "fname",
+ "last_name_key": "lname",
+ "email_key": "email"
+ }
+ }
+ }
+ },
+ # Invalid "about.title_url" (not a URL)
+ {
+ "slug": "English Title",
+ "name": {"en": "a", "he": "b"},
+ "about": {
+ "title": {
+ "en": "English Title",
+ "he": "Hebrew Title"
+ },
+ "title_url": "invalid-url",
+ "image_uri": "gs://your-bucket/image.jpg",
+ "description": {
+ "en": "English Description",
+ "he": "Hebrew Description"
+ }
+ },
+ "mobile": {
+ "title": {
+ "en": "Mobile Title",
+ "he": "Mobile Hebrew Title"
+ },
+ "android_link": "https://android-link.com",
+ "ios_link": "https://ios-link.com"
+ },
+ "newsletter": {
+ "title": {
+ "en": "Newsletter Title",
+ "he": "Newsletter Hebrew Title"
+ },
+ "description": {
+ "en": "Newsletter English Description",
+ "he": "Newsletter Hebrew Description"
+ },
+ "api_schema": {
+ "http_method": "POST",
+ "payload": {
+ "first_name_key": "fname",
+ "last_name_key": "lname",
+ "email_key": "email"
+ }
+ }
+ }
+ },
+ # Including invalid field "newsletter.description.fr"
+ {
+ "slug": "English Title",
+ "name": {"en": "a", "he": "b"},
+ "about": {
+ "title": {
+ "en": "English Title",
+ "he": "Hebrew Title"
+ },
+ "title_url": "https://example.com",
+ "image_uri": "gs://your-bucket/image.jpg",
+ "description": {
+ "en": "English Description",
+ "he": "Hebrew Description"
+ }
+ },
+ "mobile": {
+ "title": {
+ "en": "Mobile Title",
+ "he": "Mobile Hebrew Title"
+ },
+ "android_link": "https://android-link.com"
+ },
+ "newsletter": {
+ "title": {
+ "fr": "Titre de la newsletter",
+ "he": "Newsletter Hebrew Title"
+ },
+ "description": {
+ "en": "Newsletter English Description",
+ "he": "Newsletter Hebrew Description"
+ },
+ "api_schema": {
+ "http_method": "POST",
+ "payload": {
+ "first_name_key": "fname",
+ "last_name_key": "lname",
+ "email_key": "email"
+ }
+ }
+ }
+ },
+ # Invalid "newsletter.api_schema.http_method" (not a valid HTTP method)
+ {
+ "slug": "English Title",
+ "name": {"en": "a", "he": "b"},
+ "about": {
+ "title": {
+ "en": "English Title",
+ "he": "Hebrew Title"
+ },
+ "image_uri": "gs://your-bucket/image.jpg",
+ "description": {
+ "en": "English Description",
+ "he": "Hebrew Description"
+ }
+ },
+ "mobile": {
+ "title": {
+ "en": "Mobile Title",
+ "he": "Mobile Hebrew Title"
+ },
+ "android_link": "https://android-link.com",
+ "ios_link": "https://ios-link.com"
+ },
+ "newsletter": {
+ "title": {
+ "en": "Newsletter Title",
+ "he": "Newsletter Hebrew Title"
+ },
+ "description": {
+ "en": "Newsletter English Description",
+ "he": "Newsletter Hebrew Description"
+ },
+ "api_schema": {
+ "http_method": "INVALID_METHOD",
+ "payload": {
+ "first_name_key": "fname",
+ "last_name_key": "lname",
+ "email_key": "email"
+ }
+ }
+ }
+ },
+ # Invalid data types:
+ {
+ "slug": "English Title",
+ "name": {"en": "a", "he": "b"},
+ "about": {
+ "title": {
+ "en": "About Us",
+ "he": "עלינו"
+ },
+ "image_uri": 67890,
+ "description": {
+ "en": "Description in English",
+ "he": "תיאור בעברית"
+ }
+ }
+ },
+{
+ # Incorrect field names
+ "slug": "English Title",
+ "name": {"en": "a", "he": "b"},
+ "about": {
+ "title": {
+ "en": "About Us",
+ "he": "עלינו"
+ },
+ "image_uri": "gs://bucket/image.jpg",
+ "description": {
+ "en": "Description in English",
+ "he": "תיאור בעברית"
+ }
+ },
+ "mobile": {
+ "title": {
+ "en": "Mobile App",
+ "he": "אפליקציה ניידת"
+ },
+ "android_link": "https://play.google.com/store/apps/details?id=com.example.app",
+ "ios_link": "https://apps.apple.com/us/app/example-app/id1234567890",
+ "invalid_field": "This field should not be here"
+ }
+}
+
+
+]
+@pytest.mark.parametrize("data", valids)
+def test_valid_schema(data):
+ p = Portal(data)
+ assert p._validate() == True
+
+@pytest.mark.parametrize("invalid_case", invalids)
+def test_invalid_schema(invalid_case):
+ with pytest.raises(Exception):
+ p = Portal(invalid_case)
+ p._validate()
+
+
+@pytest.fixture()
+def simple_portal():
+ raw_portal = valids[0]
+ portal = Portal(raw_portal)
+ portal.save()
+
+ yield portal
+
+ portal.delete()
+
+
+@pytest.fixture()
+def simple_portal_saved_directly_to_mongo():
+ raw_portal = valids[0]
+ inserted_result = db.portals.insert_one(raw_portal)
+
+ yield Portal(raw_portal)
+
+ db.portals.delete_one({"_id": inserted_result.inserted_id})
+
+
+@pytest.fixture()
+def simple_topic(simple_portal):
+ topic = Topic({
+ "slug": "blah",
+ "titles": [{"text": "Blah", "lang": "en", "primary": True}],
+ "portal_slug": simple_portal.slug,
+ })
+ topic.save()
+
+ yield topic
+
+ topic.delete()
+
+
+def test_save_simple_portal(simple_portal):
+ """
+ Tests that simple_portal was saved properly and has a normalized slug
+ """
+ assert simple_portal.slug == "english-title"
+
+
+def test_topic_validates_portal_exists(simple_topic):
+ assert simple_topic is not None
+
+
+def test_topic_validation_fails_for_non_existent_portal():
+ with pytest.raises(SluggedMongoRecordMissingError):
+ topic = Topic({
+ "slug": "blah",
+ "titles": [{"text": "Blah", "lang": "en", "primary": True}],
+ "portal_slug": "non-existent-portal",
+ })
+ topic.save()
+
+
+def test_load_portal(simple_portal_saved_directly_to_mongo):
+ portal = Portal().load({"slug": simple_portal_saved_directly_to_mongo.slug})
+ assert portal is not None
diff --git a/sefaria/model/tests/text_test.py b/sefaria/model/tests/text_test.py
index 00192188e2..6609c17354 100644
--- a/sefaria/model/tests/text_test.py
+++ b/sefaria/model/tests/text_test.py
@@ -169,9 +169,9 @@ def test_invalid_index_save_no_category():
def test_best_time_period():
i = model.library.get_index("Rashi on Genesis")
- assert i.best_time_period().period_string('en') == ' (c.1075 - c.1105 CE)'
+ assert i.best_time_period().period_string('en') == ' (c.1075 – c.1105 CE)'
i.compDate = None
- assert i.best_time_period().period_string('en') == ' (1040 - 1105 CE)' # now that compDate is None, period_string should return Rashi's birth to death years
+ assert i.best_time_period().period_string('en') == ' (1040 – 1105 CE)' # now that compDate is None, period_string should return Rashi's birth to death years
def test_invalid_index_save_no_hebrew_collective_title():
title = 'Bartenura (The Next Generation)'
diff --git a/sefaria/model/tests/topic_test.py b/sefaria/model/tests/topic_test.py
index 78e8a89ec5..59f49d205b 100644
--- a/sefaria/model/tests/topic_test.py
+++ b/sefaria/model/tests/topic_test.py
@@ -2,6 +2,7 @@
from sefaria.model.topic import Topic, TopicSet, IntraTopicLink, RefTopicLink, TopicLinkHelper, IntraTopicLinkSet, RefTopicLinkSet
from sefaria.model.text import Ref
from sefaria.system.database import db
+from sefaria.system.exceptions import SluggedMongoRecordMissingError
def make_topic(slug):
@@ -219,7 +220,7 @@ def test_validate(self, topic_graph):
'dataSource': 'blahblah'
}
l = IntraTopicLink(attrs)
- with pytest.raises(AssertionError):
+ with pytest.raises(SluggedMongoRecordMissingError):
l.save()
# non-existant toTopic
@@ -230,7 +231,7 @@ def test_validate(self, topic_graph):
'dataSource': 'sefaria'
}
l = IntraTopicLink(attrs)
- with pytest.raises(AssertionError):
+ with pytest.raises(SluggedMongoRecordMissingError):
l.save()
# non-existant fromTopic
@@ -241,7 +242,7 @@ def test_validate(self, topic_graph):
'dataSource': 'sefaria'
}
l = IntraTopicLink(attrs)
- with pytest.raises(AssertionError):
+ with pytest.raises(SluggedMongoRecordMissingError):
l.save()
# non-existant linkType
@@ -252,7 +253,7 @@ def test_validate(self, topic_graph):
'dataSource': 'sefaria'
}
l = IntraTopicLink(attrs)
- with pytest.raises(AssertionError):
+ with pytest.raises(SluggedMongoRecordMissingError):
l.save()
# duplicate for symmetric linkType
diff --git a/sefaria/model/text.py b/sefaria/model/text.py
index 6346fd5374..e1d398a9e5 100644
--- a/sefaria/model/text.py
+++ b/sefaria/model/text.py
@@ -687,6 +687,12 @@ def _validate(self):
if not Category().load({"path": self.categories}):
raise InputError("You must create category {} before adding texts to it.".format("/".join(self.categories)))
+ for date_key in ['compDate', 'pubDate']:
+ if hasattr(self, date_key):
+ val = getattr(self, date_key)
+ if not isinstance(val, list) or not all([isinstance(x, int) for x in val]):
+ raise InputError(f"Optional attribute '{date_key}' must be list of integers.")
+
'''
for cat in self.categories:
if not hebrew_term(cat):
@@ -1296,7 +1302,7 @@ def _validate(self):
"""
languageCodeRe = re.search(r"\[([a-z]{2})\]$", getattr(self, "versionTitle", None))
if languageCodeRe and languageCodeRe.group(1) != getattr(self,"actualLanguage",None):
- raise InputError("Version actualLanguage does not match bracketed language")
+ self.actualLanguage = languageCodeRe.group(1)
if getattr(self,"language", None) not in ["en", "he"]:
raise InputError("Version language must be either 'en' or 'he'")
index = self.get_index()
@@ -2281,6 +2287,11 @@ def __init__(self, oref, context=1, commentary=True, version=None, lang=None,
elif oref.has_default_child():
oref = oref.default_child_ref()
+ if version:
+ version = version.replace("_", " ")
+ if version2:
+ version2 = version2.replace("_", " ")
+
self.ref = oref.normal()
self.heRef = oref.he_normal()
self.isComplex = oref.index.is_complex()
diff --git a/sefaria/model/timeperiod.py b/sefaria/model/timeperiod.py
index bb0da09560..33bd3d389a 100644
--- a/sefaria/model/timeperiod.py
+++ b/sefaria/model/timeperiod.py
@@ -56,6 +56,8 @@
+---------------+------------+-----------------+-------------------------------+-----------------------+
"""
+DASH = '–'
+
class TimePeriod(abst.AbstractMongoRecord):
"""
TimePeriod is used both for the saved time periods - Eras and Generations
@@ -144,10 +146,11 @@ def period_string(self, lang):
if lang == "en":
if getattr(self, "symbol", "") == "CO" or getattr(self, "end", None) is None:
- name += " ({}{} {} - )".format(
+ name += " ({}{} {} {} )".format(
approxMarker[0],
abs(int(self.start)),
- labels[1])
+ labels[1],
+ DASH)
return name
elif int(self.start) == int(self.end):
name += " ({}{} {})".format(
@@ -155,19 +158,21 @@ def period_string(self, lang):
abs(int(self.start)),
labels[1])
else:
- name += " ({}{} {} - {}{} {})".format(
+ name += " ({}{} {} {} {}{} {})".format(
approxMarker[0],
abs(int(self.start)),
labels[0],
+ DASH,
approxMarker[1],
abs(int(self.end)),
labels[1])
if lang == "he":
if getattr(self, "symbol", "") == "CO" or getattr(self, "end", None) is None:
- name += " ({} {} {} - )".format(
+ name += " ({} {} {} {} )".format(
abs(int(self.start)),
labels[1],
- approxMarker[0])
+ approxMarker[0],
+ DASH)
return name
elif int(self.start) == int(self.end):
name += " ({}{}{})".format(
@@ -177,18 +182,20 @@ def period_string(self, lang):
else:
both_approx = approxMarker[0] and approxMarker[1]
if both_approx:
- name += " ({}{} - {}{} {})".format(
+ name += " ({}{} {} {}{} {})".format(
abs(int(self.start)),
" " + labels[0] if labels[0] else "",
+ DASH,
abs(int(self.end)),
" " + labels[1] if labels[1] else "",
approxMarker[1]
)
else:
- name += " ({}{}{} - {}{}{})".format(
+ name += " ({}{}{} {} {}{}{})".format(
abs(int(self.start)),
" " + labels[0] if labels[0] else "",
" " + approxMarker[0] if approxMarker[0] else "",
+ DASH,
abs(int(self.end)),
" " + labels[1] if labels[1] else "",
" " + approxMarker[1] if approxMarker[1] else ""
@@ -218,6 +225,16 @@ def get_people_in_generation(self, include_doubles = True):
else:
return topic.Topic({"properties.generation.value": self.symbol})
+ def determine_year_estimate(self):
+ start = getattr(self, 'start', None)
+ end = getattr(self, 'end', None)
+ if start != None and end != None:
+ return round((int(start) + int(end)) / 2)
+ elif start != None:
+ return int(start)
+ elif end != None:
+ return int(end)
+
class TimePeriodSet(abst.AbstractMongoSet):
recordClass = TimePeriod
@@ -234,3 +251,41 @@ def get_generations(include_doubles = False):
arg = {"$in": ["Generation", "Two Generations"]} if include_doubles else "Generation"
return TimePeriodSet._get_typed_set(arg)
+class LifePeriod(TimePeriod):
+
+ def period_string(self, lang):
+
+ if getattr(self, "start", None) == None and getattr(self, "end", None) == None:
+ return
+
+ labels = self.getYearLabels(lang)
+ approxMarker = self.getApproximateMarkers(lang)
+ abs_birth = abs(int(getattr(self, "start", 0)))
+ abs_death = abs(int(getattr(self, "end", 0)))
+ if lang == "en":
+ birth = 'b.'
+ death = 'd.'
+ order_vars_by_lang = lambda year, label, approx: (approx, '', year, label)
+ else:
+ birth = 'נו׳'
+ death = 'נפ׳'
+ order_vars_by_lang = lambda year, label, approx: (year, ' ', label, approx)
+
+ if getattr(self, "symbol", "") == "CO" or getattr(self, "end", None) is None:
+ name = '{} {}{}{} {}'.format(birth, *order_vars_by_lang(abs_birth, labels[1], approxMarker[0]))
+ elif getattr(self, "start", None) is None:
+ name = '{} {}{}{} {}'.format(death, *order_vars_by_lang(abs_death, labels[1], approxMarker[0]))
+ elif int(self.start) == int(self.end):
+ name = '{}{}{} {}'.format(*order_vars_by_lang(abs_birth, labels[1], approxMarker[0]))
+ else:
+ both_approx = approxMarker[0] and approxMarker[1]
+ if lang == 'he' and both_approx:
+ birth_string = '{}{}{}'.format(*order_vars_by_lang(abs_birth, labels[0], approxMarker[0])[:-1])
+ else:
+ birth_string = '{}{}{} {}'.format(*order_vars_by_lang(abs_birth, labels[0], approxMarker[0]))
+ death_string = '{}{}{} {}'.format(*order_vars_by_lang(abs_death, labels[1], approxMarker[0]))
+ name = f'{birth_string} {DASH} {death_string}'
+
+ name = f' ({" ".join(name.split())})'
+ return name
+
diff --git a/sefaria/model/topic.py b/sefaria/model/topic.py
index 0127bc723b..2304a7a5b4 100644
--- a/sefaria/model/topic.py
+++ b/sefaria/model/topic.py
@@ -4,11 +4,14 @@
from .text import Ref, IndexSet, AbstractTextRecord
from .category import Category
from sefaria.system.exceptions import InputError, DuplicateRecordError
-from sefaria.model.timeperiod import TimePeriod
+from sefaria.model.timeperiod import TimePeriod, LifePeriod
+from sefaria.system.validators import validate_url
+from sefaria.model.portal import Portal
from sefaria.system.database import db
import structlog, bleach
from sefaria.model.place import Place
import regex as re
+from typing import Type
logger = structlog.get_logger(__name__)
@@ -43,8 +46,35 @@ class Topic(abst.SluggedAbstractMongoRecord, AbstractTitledObject):
'good_to_promote',
'description_published', # bool to keep track of which descriptions we've vetted
'isAmbiguous', # True if topic primary title can refer to multiple other topics
- "data_source" #any topic edited manually should display automatically in the TOC and this flag ensures this
+ "data_source", #any topic edited manually should display automatically in the TOC and this flag ensures this
+ 'image',
+ "portal_slug", # slug to relevant Portal object
]
+
+ attr_schemas = {
+ "image": {
+ "image_uri": {
+ "type": "string",
+ "required": True,
+ "regex": "^https://storage\.googleapis\.com/img\.sefaria\.org/topics/.*?"
+ },
+ "image_caption": {
+ "type": "dict",
+ "required": True,
+ "schema": {
+ "en": {
+ "type": "string",
+ "required": True
+ },
+ "he": {
+ "type": "string",
+ "required": True
+ }
+ }
+ }
+ }
+ }
+
ROOT = "Main Menu" # the root of topic TOC is not a topic, so this is a fake slug. we know it's fake because it's not in normal form
# this constant is helpful in the topic editor tool functions in this file
@@ -68,6 +98,11 @@ def _validate(self):
super(Topic, self)._validate()
if getattr(self, 'subclass', False):
assert self.subclass in self.subclass_map, f"Field `subclass` set to {self.subclass} which is not one of the valid subclass keys in `Topic.subclass_map`. Valid keys are {', '.join(self.subclass_map.keys())}"
+ if getattr(self, 'portal_slug', None):
+ Portal.validate_slug_exists(self.portal_slug)
+ if getattr(self, "image", False):
+ img_url = self.image.get("image_uri")
+ if img_url: validate_url(img_url)
def _normalize(self):
super()._normalize()
@@ -412,8 +447,9 @@ def annotate_place(self, d):
if place and heKey not in properties:
value, dataSource = place['value'], place['dataSource']
place_obj = Place().load({"key": value})
- name = place_obj.primary_name('he')
- d['properties'][heKey] = {'value': name, 'dataSource': dataSource}
+ if place_obj:
+ name = place_obj.primary_name('he')
+ d['properties'][heKey] = {'value': name, 'dataSource': dataSource}
return d
def contents(self, **kwargs):
@@ -421,7 +457,7 @@ def contents(self, **kwargs):
d = super(PersonTopic, self).contents(**kwargs)
if annotate_time_period:
d = self.annotate_place(d)
- tp = self.most_accurate_time_period()
+ tp = self.most_accurate_life_period()
if tp is not None:
d['timePeriod'] = {
"name": {
@@ -437,26 +473,42 @@ def contents(self, **kwargs):
# A person may have an era, a generation, or a specific birth and death years, which each may be approximate.
# They may also have none of these...
- def most_accurate_time_period(self) -> Optional[TimePeriod]:
+ def _most_accurate_period(self, time_period_class: Type[TimePeriod]) -> Optional[LifePeriod]:
if self.get_property("birthYear") and self.get_property("deathYear"):
- return TimePeriod({
+ return time_period_class({
"start": self.get_property("birthYear"),
"startIsApprox": self.get_property("birthYearIsApprox", False),
"end": self.get_property("deathYear"),
"endIsApprox": self.get_property("deathYearIsApprox", False)
})
elif self.get_property("birthYear") and self.get_property("era", "CO"):
- return TimePeriod({
+ return time_period_class({
"start": self.get_property("birthYear"),
"startIsApprox": self.get_property("birthYearIsApprox", False),
})
+ elif self.get_property("deathYear"):
+ return time_period_class({
+ "end": self.get_property("deathYear"),
+ "endIsApprox": self.get_property("deathYearIsApprox", False)
+ })
elif self.get_property("generation"):
- return TimePeriod().load({"symbol": self.get_property("generation")})
+ return time_period_class().load({"symbol": self.get_property("generation")})
elif self.get_property("era"):
- return TimePeriod().load({"symbol": self.get_property("era")})
+ return time_period_class().load({"symbol": self.get_property("era")})
else:
return None
+ def most_accurate_time_period(self):
+ '''
+ :return: most accurate period as TimePeriod (used when a person's LifePeriod should be formatted like a general TimePeriod)
+ '''
+ return self._most_accurate_period(TimePeriod)
+
+ def most_accurate_life_period(self):
+ '''
+ :return: most accurate period as LifePeriod. currently the only difference from TimePeriod is the way the time period is formatted as a string.
+ '''
+ return self._most_accurate_period(LifePeriod)
class AuthorTopic(PersonTopic):
"""
@@ -536,16 +588,21 @@ def index_is_commentary(index):
def get_aggregated_urls_for_authors_indexes(self) -> list:
"""
Aggregates author's works by category when possible and
- returns list of tuples. Each tuple is of shape (url, {"en", "he"}) corresponding to an index or category of indexes of this author's works.
+ returns a dictionary. Each dictionary is of shape {"url": str, "title": {"en": str, "he": str}, "description": {"en": str, "he": str}}
+ corresponding to an index or category of indexes of this author's works.
"""
from .schema import Term
from .text import Index
index_or_cat_list = self.aggregate_authors_indexes_by_category()
- unique_urls = {} # {url: {lang: title}}. This dict arbitrarily chooses one title per URL.
+ unique_urls = []
for index_or_cat, collective_title_term, base_category in index_or_cat_list:
+ en_desc = getattr(index_or_cat, 'enShortDesc', None)
+ he_desc = getattr(index_or_cat, 'heShortDesc', None)
if isinstance(index_or_cat, Index):
- unique_urls[f'/{index_or_cat.title.replace(" ", "_")}'] = {"en": index_or_cat.get_title('en'), "he": index_or_cat.get_title('he')}
+ unique_urls.append({"url":f'/{index_or_cat.title.replace(" ", "_")}',
+ "title": {"en": index_or_cat.get_title('en'), "he": index_or_cat.get_title('he')},
+ "description":{"en": en_desc, "he": he_desc}})
else:
if collective_title_term is None:
cat_term = Term().load({"name": index_or_cat.sharedTitle})
@@ -555,8 +612,10 @@ def get_aggregated_urls_for_authors_indexes(self) -> list:
base_category_term = Term().load({"name": base_category.sharedTitle})
en_text = f'{collective_title_term.get_primary_title("en")} on {base_category_term.get_primary_title("en")}'
he_text = f'{collective_title_term.get_primary_title("he")} על {base_category_term.get_primary_title("he")}'
- unique_urls[f'/texts/{"/".join(index_or_cat.path)}'] = {"en": en_text, "he": he_text}
- return list(unique_urls.items())
+ unique_urls.append({"url": f'/texts/{"/".join(index_or_cat.path)}',
+ "title": {"en": en_text, "he": he_text},
+ "description":{"en": en_desc, "he": he_desc}})
+ return unique_urls
@staticmethod
def is_author(slug):
@@ -659,14 +718,10 @@ def _validate(self):
super(IntraTopicLink, self)._validate()
# check everything exists
- link_type = TopicLinkType().load({"slug": self.linkType})
- assert link_type is not None, "Link type '{}' does not exist".format(self.linkType)
- from_topic = Topic.init(self.fromTopic)
- assert from_topic is not None, "fromTopic '{}' does not exist".format(self.fromTopic)
- to_topic = Topic.init(self.toTopic)
- assert to_topic is not None, "toTopic '{}' does not exist".format(self.toTopic)
- data_source = TopicDataSource().load({"slug": self.dataSource})
- assert data_source is not None, "dataSource '{}' does not exist".format(self.dataSource)
+ TopicLinkType.validate_slug_exists(self.linkType, 0)
+ Topic.validate_slug_exists(self.fromTopic)
+ Topic.validate_slug_exists(self.toTopic)
+ TopicDataSource.validate_slug_exists(self.dataSource)
# check for duplicates
duplicate = IntraTopicLink().load({"linkType": self.linkType, "fromTopic": self.fromTopic, "toTopic": self.toTopic,
@@ -676,6 +731,7 @@ def _validate(self):
"Duplicate intra topic link for linkType '{}', fromTopic '{}', toTopic '{}'".format(
self.linkType, self.fromTopic, self.toTopic))
+ link_type = TopicLinkType.init(self.linkType, 0)
if link_type.slug == link_type.inverseSlug:
duplicate_inverse = IntraTopicLink().load({"linkType": self.linkType, "toTopic": self.fromTopic, "fromTopic": self.toTopic,
"class": getattr(self, 'class'), "_id": {"$ne": getattr(self, "_id", None)}})
@@ -685,6 +741,8 @@ def _validate(self):
duplicate_inverse.linkType, duplicate_inverse.fromTopic, duplicate_inverse.toTopic))
# check types of topics are valid according to validFrom/To
+ from_topic = Topic.init(self.fromTopic)
+ to_topic = Topic.init(self.toTopic)
if getattr(link_type, 'validFrom', False):
assert from_topic.has_types(set(link_type.validFrom)), "from topic '{}' does not have valid types '{}' for link type '{}'. Instead, types are '{}'".format(self.fromTopic, ', '.join(link_type.validFrom), self.linkType, ', '.join(from_topic.get_types()))
if getattr(link_type, 'validTo', False):
@@ -780,10 +838,10 @@ def _normalize(self):
self.expandedRefs = [r.normal() for r in Ref(self.ref).all_segment_refs()]
def _validate(self):
+ Topic.validate_slug_exists(self.toTopic)
+ TopicLinkType.validate_slug_exists(self.linkType, 0)
to_topic = Topic.init(self.toTopic)
- assert to_topic is not None, "toTopic '{}' does not exist".format(self.toTopic)
- link_type = TopicLinkType().load({"slug": self.linkType})
- assert link_type is not None, "Link type '{}' does not exist".format(self.linkType)
+ link_type = TopicLinkType.init(self.linkType, 0)
if getattr(link_type, 'validTo', False):
assert to_topic.has_types(set(link_type.validTo)), "to topic '{}' does not have valid types '{}' for link type '{}'. Instead, types are '{}'".format(self.toTopic, ', '.join(link_type.validTo), self.linkType, ', '.join(to_topic.get_types()))
@@ -871,11 +929,10 @@ def _validate(self):
# Check that validFrom and validTo contain valid topic slugs if exist
for validToTopic in getattr(self, 'validTo', []):
- assert Topic.init(validToTopic) is not None, "ValidTo topic '{}' does not exist".format(self.validToTopic)
+ Topic.validate_slug_exists(validToTopic)
for validFromTopic in getattr(self, 'validFrom', []):
- assert Topic.init(validFromTopic) is not None, "ValidTo topic '{}' does not exist".format(
- self.validFrom)
+ Topic.validate_slug_exists(validFromTopic)
def get(self, attr, is_inverse, default=None):
attr = 'inverse{}{}'.format(attr[0].upper(), attr[1:]) if is_inverse else attr
diff --git a/sefaria/model/version_state.py b/sefaria/model/version_state.py
index 62aea25423..2fef65b10e 100644
--- a/sefaria/model/version_state.py
+++ b/sefaria/model/version_state.py
@@ -20,78 +20,53 @@
except ImportError:
USE_VARNISH = False
'''
-old count docs were:
- c["allVersionCounts"]
- c["availableTexts"] = {
- "en":
- "he":
- }
-
- c["availableCounts"] = { #
- "en":
- "he":
- }
-
- c["percentAvailable"] = {
- "he":
- "en":
- }
-
- c["textComplete"] = {
- "he":
- "en"
- }
-
- c['estimatedCompleteness'] = {
- "he": {
- 'estimatedPercent':
- 'availableSegmentCount': # is availableCounts[-1]
- 'percentAvailableInvalid':
- 'percentAvailable': # duplicate
- 'isSparse':
- }
- "en":
- }
-
-
-and now self.content is:
- {
- "_en": {
- "availableTexts":
- "availableCounts":
- "percentAvailable":
- "textComplete":
- 'completenessPercent':
- 'percentAvailableInvalid':
- 'sparseness': # was isSparse
- }
- "_he": ...
- "_all" {
- "availableTexts":
- "shape":
- For depth 1: Integer - length
- For depth 2: List of chapter lengths
- For depth 3: List of list of chapter lengths?
- }
- }
-
'''
class VersionState(abst.AbstractMongoRecord, AbstractSchemaContent):
"""
This model overrides default init/load/save behavior, since there is one and only one VersionState record for each Index record.
+
+ The `content` attribute is a dictionary which is the root of a tree, mirroring the shape of a Version, where the leaf nodes of the tree are dictionaries with a shape like the following:
+ {
+ "_en": {
+ "availableTexts": Mask of what texts are available in this language. Boolean values (0 or 1) in the shape of the JaggedArray
+ "availableCounts": Array, with length == depth of the node. Each element is the number of available elements at that depth. e.g [chapters, verses]
+ "percentAvailable": Percent of this text available in this language TODO: Only used on the dashboard. Remove?
+ 'percentAvailableInvalid': Boolean. Whether the value of "percentAvailable" can be trusted. TODO: Only used on the dashboard. Remove?
+ "textComplete": Boolean. Whether the text is complete in this language. TODO: Not used outside of this file. Should be removed.
+ 'completenessPercent': Percent of this text complete in this language TODO: Not used outside of this file. Should be removed.
+ 'sparseness': Legacy - present on some records, but no longer in code TODO: remove
+ }
+ "_he": {...} # same keys as _en
+ "_all" {
+ "availableTexts": Mask what texts are available in this text overall. Boolean values (0 or 1) in the shape of the JaggedArray
+ "shape":
+ For depth 1: Integer -length
+ For depth 2: List of section lengths
+ For depth 3: List of list of section lengths
+ }
+ }
+
+ For example:
+ - the `content` attribute for a simple text like `Genesis` will be a dictionary with keys "_en", "_he", and "_all", as above.
+ - the `content` attribute for `Pesach Haggadah` will be a dictionary with keys: "Kadesh", "Urchatz", "Karpas" ... each with a value of a dictionary like the above.
+ The key "Magid" has a value of a dictionary, where each key is a different sub-section of Magid.
+ The value for each key is a dictionary as detailed above, specific to each sub-section.
+ So for example, one key will be "Ha Lachma Anya" and the value will be a dictionary, like the above, specific to the details of "Ha Lachma Anya".
+
+ Every JaggedArrayNode has a corresponding vstate dictionary. So for complex texts, each leaf node (and leaf nodes by definition must be JaggedArrayNodes) has this corresponding dictionary.
"""
collection = 'vstate'
required_attrs = [
"title", # Index title
- "content" # tree of data about nodes
+ "content" # tree of data about nodes. See above.
]
optional_attrs = [
- "flags",
- "linksCount",
- "first_section_ref"
+ "flags", # "heComplete" : Bool, "enComplete" : Bool
+ "linksCount", # Integer
+ "first_section_ref" # Normal text Ref
]
langs = ["en", "he"]
diff --git a/sefaria/pagesheetrank.py b/sefaria/pagesheetrank.py
index 1c2087ed2b..603de805bc 100644
--- a/sefaria/pagesheetrank.py
+++ b/sefaria/pagesheetrank.py
@@ -212,8 +212,8 @@ def put_link_in_graph(ref1, ref2, weight=1.0):
refs = [Ref(r) for r in link.refs]
tp1 = refs[0].index.best_time_period()
tp2 = refs[1].index.best_time_period()
- start1 = int(tp1.start) if tp1 else 3000
- start2 = int(tp2.start) if tp2 else 3000
+ start1 = int(tp1.determine_year_estimate()) if tp1 else 3000
+ start2 = int(tp2.determine_year_estimate()) if tp2 else 3000
older_ref, newer_ref = (refs[0], refs[1]) if start1 < start2 else (refs[1], refs[0])
diff --git a/sefaria/system/exceptions.py b/sefaria/system/exceptions.py
index ff64c89336..72d3493f78 100644
--- a/sefaria/system/exceptions.py
+++ b/sefaria/system/exceptions.py
@@ -56,5 +56,46 @@ class SheetNotFoundError(InputError):
class ManuscriptError(Exception):
pass
+
class MissingKeyError(Exception):
pass
+
+
+class SluggedMongoRecordMissingError(Exception):
+ pass
+
+
+class SchemaValidationException(Exception):
+ def __init__(self, key, expected_type):
+ self.key = key
+ self.expected_type = expected_type
+ self.message = f"Invalid value for key '{key}'. Expected type: {expected_type}"
+ super().__init__(self.message)
+
+
+class SchemaRequiredFieldException(Exception):
+ def __init__(self, key):
+ self.key = key
+ self.message = f"Required field '{key}' is missing."
+ super().__init__(self.message)
+
+
+class SchemaInvalidKeyException(Exception):
+ def __init__(self, key):
+ self.key = key
+ self.message = f"Invalid key '{key}' found in data dictionary."
+ super().__init__(self.message)
+
+
+class InvalidURLException(Exception):
+ def __init__(self, url):
+ self.url = url
+ self.message = f"'{url}' is not a valid URL."
+ super().__init__(self.message)
+
+
+class InvalidHTTPMethodException(Exception):
+ def __init__(self, method):
+ self.method = method
+ self.message = f"'{method}' is not a valid HTTP API method."
+ super().__init__(self.message)
diff --git a/sefaria/system/validators.py b/sefaria/system/validators.py
new file mode 100644
index 0000000000..f36ba8282f
--- /dev/null
+++ b/sefaria/system/validators.py
@@ -0,0 +1,45 @@
+"""
+Pre-written validation functions
+Useful for validating model schemas when overriding AbstractMongoRecord._validate()
+"""
+
+import urllib.parse
+from urllib.parse import urlparse
+from django.core.validators import URLValidator
+from django.core.exceptions import ValidationError
+from sefaria.system.exceptions import SchemaValidationException, SchemaInvalidKeyException, SchemaRequiredFieldException\
+ , InvalidHTTPMethodException, InvalidURLException
+
+
+def validate_url(url):
+ try:
+ # Attempt to parse the URL
+ validator = URLValidator()
+ validator(url)
+ return True
+
+ except ValidationError:
+ # URL parsing failed
+ raise InvalidURLException(url)
+
+
+def validate_http_method(method):
+ """
+ Validate if a string represents a valid HTTP API method.
+
+ Args:
+ method (str): The HTTP method to validate.
+
+ Raises:
+ InvalidHTTPMethodException: If the method is not valid.
+
+ Returns:
+ bool: True if the method is valid, False otherwise.
+ """
+ valid_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
+
+ # Convert the method to uppercase and check if it's in the list of valid methods
+ if method.upper() in valid_methods:
+ return True
+ else:
+ raise InvalidHTTPMethodException(method)
diff --git a/sefaria/urls.py b/sefaria/urls.py
index b97e18771a..757fcef1aa 100644
--- a/sefaria/urls.py
+++ b/sefaria/urls.py
@@ -85,12 +85,12 @@
url(r'^settings/account?$', reader_views.account_settings),
url(r'^settings/profile?$', reader_views.edit_profile),
url(r'^interface/(?P
-
-
-
@@ -340,6 +344,7 @@ const TopicHeader = ({ topic, topicData, multiPanel, isCat, setNavTopic, openDis
+ Delete Sheet
+
+ CAUTION THIS WILL DELETE THE SHEET ID YOU INPUT
+ IT CANNOT BE UNDONE.
+
+
Olivia Gerber
-
Elise Ringo
Shanee Rosen
-
diff --git a/templates/static/he/team.html b/templates/static/he/team.html
index 45345dd75a..a34e881fa1 100644
--- a/templates/static/he/team.html
+++ b/templates/static/he/team.html
@@ -15,6 +15,19 @@
אוליביה גרבר
-
שני רוזן
יעל רינגו
-
+