From 70328d4cd4969b85d2d87406e0178177f92304d5 Mon Sep 17 00:00:00 2001 From: sanika palav Date: Sat, 7 Mar 2026 22:06:23 +0530 Subject: [PATCH 1/4] Fix Add Round button disappearing and ensure proper round reload --- frontend/package-lock.json | 471 ++++++++++++++++++++- frontend/src/components/Round/RoundNew.vue | 137 +++--- montage/rdb.py | 3 +- 3 files changed, 541 insertions(+), 70 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dd93da32..15256863 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -281,6 +281,74 @@ "ms": "^2.1.1" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/darwin-arm64": { "version": "0.25.10", "cpu": [ @@ -296,6 +364,363 @@ "node": ">=18" } }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.0", "dev": true, @@ -966,6 +1391,7 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -1071,6 +1497,7 @@ "version": "8.15.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1552,9 +1979,6 @@ "node": ">=6" } }, - "node_modules/cli-cursor": { - "dev": true - }, "node_modules/cli-truncate": { "version": "2.1.0", "dev": true, @@ -1957,6 +2381,7 @@ "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" @@ -2261,9 +2686,6 @@ "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", "license": "MIT" }, - "node_modules/debug": { - "dev": true - }, "node_modules/decamelize": { "version": "1.2.0", "dev": true, @@ -2431,6 +2853,7 @@ "version": "8.57.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3288,6 +3711,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/log-update/node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/log-update/node_modules/slice-ansi": { "version": "4.0.0", "dev": true, @@ -3635,6 +4071,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4120,6 +4557,7 @@ "version": "4.0.3", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4270,6 +4708,7 @@ "version": "6.3.6", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -4374,6 +4813,7 @@ "version": "4.0.3", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4384,6 +4824,7 @@ "node_modules/vue": { "version": "3.5.22", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.22", "@vue/compiler-sfc": "3.5.22", @@ -4470,6 +4911,24 @@ "eslint": ">=6.0.0" } }, + "node_modules/vue-eslint-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/vue-i18n": { "version": "10.0.8", "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-10.0.8.tgz", diff --git a/frontend/src/components/Round/RoundNew.vue b/frontend/src/components/Round/RoundNew.vue index 8a222fb5..05ef70b0 100644 --- a/frontend/src/components/Round/RoundNew.vue +++ b/frontend/src/components/Round/RoundNew.vue @@ -166,6 +166,26 @@ import StarOutline from 'vue-material-design-icons/StarOutline.vue' import Sort from 'vue-material-design-icons/Sort.vue' import Check from 'vue-material-design-icons/Check.vue' import Close from 'vue-material-design-icons/Close.vue' +const isDirty = ref(false) +watch( + [formData, importSourceValue], + () => { + isDirty.value = true + }, + { deep: true } +) +const beforeUnloadHandler = (event) => { + if (isDirty.value) { + event.preventDefault() + event.returnValue = '' + } +} + +onMounted(() => { + window.addEventListener('beforeunload', beforeUnloadHandler) +}) + + const { t: $t } = useI18n() const props = defineProps({ @@ -250,26 +270,23 @@ function searchCategory(name) { } const cancelRound = () => { + isDirty.value = false + window.removeEventListener('beforeunload', beforeUnloadHandler) emit('update:showAddRoundForm', false) } -const submitRound = () => { + + const submitRound = () => { + // Validation if (!formData.value.deadline_date) { - alertService.error({ - message: $t('montage-required-voting-deadline') - }); - return; + alertService.error({ message: $t('montage-required-voting-deadline') }) + return } - - if ( - !formData.value.name || - (formData.value.quorum > 0 && formData.value.jurors.length === 0) -) { - alertService.error({ - message: $t('montage-required-fill-inputs') - }); - return; + if (!formData.value.name || (formData.value.quorum > 0 && formData.value.jurors.length === 0)) { + alertService.error({ message: $t('montage-required-fill-inputs') }) + return } + isLoading.value = true // Check if the round is the first round if (roundIndex === 0) { @@ -285,7 +302,7 @@ const submitRound = () => { } isLoading.value = true - adminService + adminService .addRound(campaignId, payload) .then((resp) => { alertService.success($t('montage-round-added')) @@ -296,18 +313,27 @@ const submitRound = () => { .filter((elem) => elem) } - importCategory(resp.data.id) + // Import files/categories + return importCategory(resp.data.id) + }) + .then(() => { + // Hide the form only after successful import + emit('reload-campaign-state') + emit('update:showAddRoundForm', false) }) .catch(alertService.error) .finally(() => { isLoading.value = false }) } else { - if (!prevRound.id) { + // Subsequent rounds + if (!prevRound?.id) { alertService.error($t('montage-something-went-wrong')) + isLoading.value = false return } + const payload = { next_round: { name: formData.value.name, @@ -334,54 +360,39 @@ const submitRound = () => { } const importCategory = (id) => { - const payload = { - import_method: selectedImportSource.value - } + return new Promise((resolve, reject) => { + const payload = { import_method: selectedImportSource.value } - if (selectedImportSource.value === 'category') { - payload.category = importSourceValue.value.category - } else if (selectedImportSource.value === 'csv') { - payload.csv_url = importSourceValue.value.csv_url - } else if (selectedImportSource.value === 'selected') { - payload.file_names = importSourceValue.value.file_names - } + if (selectedImportSource.value === 'category') payload.category = importSourceValue.value.category + else if (selectedImportSource.value === 'csv') payload.csv_url = importSourceValue.value.csv_url + else if (selectedImportSource.value === 'selected') payload.file_names = importSourceValue.value.file_names - isLoading.value = true - adminService - .populateRound(id, payload) - .then((response) => { - if (response.data && response.data.warnings && response.data.warnings.length) { - const { warnings = [], disqualified = [] } = response.data - - const warningsList = warnings.map((warning) => Object.values(warning).pop()) - const filesList = disqualified - .map((image) => `${image.entry.name} – ${image.dq_reason}`.trim()) - .filter((value, index, array) => array.indexOf(value) === index) - .join('\n') - - const text = `${warningsList.join('\n\n')}\n\n${filesList}` - - dialogService().show({ - title: 'Import Warning', - content: text, - primaryAction: { - label: 'OK', - actionType: 'progressive' - }, - onPrimary: () => { - emit('reload-campaign-state') - emit('update:showAddRoundForm', false) - } - }) - } else { - emit('reload-campaign-state') - emit('update:showAddRoundForm', false) - } - }) - .catch(alertService.error) - .finally(() => { - isLoading.value = false - }) + adminService + .populateRound(id, payload) + .then((response) => { + if (response.data?.warnings?.length || response.data?.disqualified?.length) { + const { warnings = [], disqualified = [] } = response.data + const warningsList = warnings.map((w) => Object.values(w).pop()) + const filesList = disqualified + .map((image) => `${image.entry.name} – ${image.dq_reason}`.trim()) + .filter((v, i, arr) => arr.indexOf(v) === i) + .join('\n') + const text = `${warningsList.join('\n\n')}\n\n${filesList}` + dialogService().show({ + title: 'Import Warning', + content: text, + primaryAction: { label: 'OK', actionType: 'progressive' }, + onPrimary: () => resolve() // resolve after user clicks OK + }) + } else { + resolve() + } + }) + .catch((err) => { + alertService.error(err) + reject(err) + }) + }) } watch(thresholds, (value) => { diff --git a/montage/rdb.py b/montage/rdb.py index b7052ff6..c7f7e3be 100644 --- a/montage/rdb.py +++ b/montage/rdb.py @@ -118,7 +118,8 @@ MAINTAINERS = [ 'MahmoudHashemi', 'Slaporte', 'Yarl', 'LilyOfTheWest', - 'Jayprakash12345', 'Ciell', 'Effeietsanders' + 'Jayprakash12345', 'Ciell', 'Effeietsanders','Sanika06' + ] """ From f5d11eb1403bbf7abc23d83101643b595d86bd35 Mon Sep 17 00:00:00 2001 From: sanika palav Date: Sun, 15 Mar 2026 21:31:20 +0530 Subject: [PATCH 2/4] Images from Wiki Loves Monuments 2017 in Ghan --- frontend/src/components/Round/RoundNew.vue | 25 ++++++++++++ montage/admin_endpoints.py | 45 +++++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Round/RoundNew.vue b/frontend/src/components/Round/RoundNew.vue index 05ef70b0..b5c33fe3 100644 --- a/frontend/src/components/Round/RoundNew.vue +++ b/frontend/src/components/Round/RoundNew.vue @@ -276,6 +276,7 @@ const cancelRound = () => { } + const submitRound = () => { // Validation if (!formData.value.deadline_date) { @@ -286,6 +287,30 @@ const cancelRound = () => { alertService.error({ message: $t('montage-required-fill-inputs') }) return } + if (!formData.value.deadline_date) { + alertService.error({ message: $t('montage-required-voting-deadline') }) + return + } + + if (!formData.value.name || (formData.value.quorum > 0 && formData.value.jurors.length === 0)) { + alertService.error({ message: $t('montage-required-fill-inputs') }) + return + } + + + if (formData.value.quorum > formData.value.jurors.length) { + alertService.error({ message: 'Quorum cannot be greater than number of jurors' }) + return + } + + + if (roundIndex === 0) { + if (selectedImportSource.value === 'category' && !importSourceValue.value.category) { + alertService.error({ message: 'Please select a category before creating the round' }) + return + } + } + isLoading.value = true // Check if the round is the first round diff --git a/montage/admin_endpoints.py b/montage/admin_endpoints.py index a4f608d1..d96e4181 100644 --- a/montage/admin_endpoints.py +++ b/montage/admin_endpoints.py @@ -351,8 +351,31 @@ def import_entries(user_dao, round_id, request_dict): import_warnings.append(msg) elif import_method == CATEGORY_METHOD: cat_name = request_dict['category'] - entries = coord_dao.add_entries_from_cat(round_id, cat_name) params = {'category': cat_name} + + try: + entries = coord_dao.add_entries_from_cat(round_id, cat_name) + if not entries: + return { + 'status': 'failure', + '_status_code': 400, + 'errors': 'No images found in this category', + 'data': None + } + except Exception: + return { + 'status': 'failure', + '_status_code': 400, + 'errors': 'Invalid Wikimedia Commons category', + 'data': None + } + + + + + + + elif import_method == ROUND_METHOD: threshold = request_dict['threshold'] prev_round_id = request_dict['previous_round_id'] @@ -467,21 +490,41 @@ def _prepare_round_params(coord_dao, request_dict): for column in req_columns + extra_columns: val = request_dict.get(column) + + # Required field validation if not val and column in req_columns: raise InvalidAction('%s is required to create a round (got %r)' % (column, val)) + + # Vote method validation if column == 'vote_method' and val not in valid_vote_methods: raise InvalidAction('%s is an invalid vote method' % val) + + # Deadline validation if column == 'deadline_date': val = js_isoparse(val) + if not val: + raise InvalidAction('Voting deadline is required') + + # Juror validation (PRE-EMPTIVE CHECK) if column == 'jurors': juror_names = val + if not juror_names or len(juror_names) == 0: + raise InvalidAction('At least one juror must be selected') + rnd_dict[column] = val + # Default quorum based on juror count default_quorum = len(rnd_dict['jurors']) rnd_dict['quorum'] = request_dict.get('quorum', default_quorum) + + # Quorum validation + if rnd_dict['quorum'] <= 0: + raise InvalidAction('Quorum must be greater than 0') + rnd_dict['jurors'] = [] + # Convert juror names to user objects for juror_name in juror_names: juror = coord_dao.get_or_create_user(juror_name, 'juror') rnd_dict['jurors'].append(juror) From 2a343204915fb70764c3b20261ebcb58cdb192ad Mon Sep 17 00:00:00 2001 From: sanika palav Date: Mon, 30 Mar 2026 00:49:09 +0530 Subject: [PATCH 3/4] Improve README: add WSL instructions, OAuth setup, YAML example, troubleshooting table --- README.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/README.md b/README.md index b3f46d39..bb5c8844 100644 --- a/README.md +++ b/README.md @@ -14,3 +14,63 @@ workflow that adapts to the conventions of all groups. ## Testing `pip install tox` into your virtualenv, then `tox`. +## Local Development Setup + +### 1. Recommended Environment (Windows Users) + +Running the backend directly on Windows may cause dependency and environment issues. +It is highly recommended to use **Windows Subsystem for Linux (WSL)** for a smoother setup experience. + +#### Steps to install WSL: + +1. Open PowerShell as Administrator and run: + +```wsl --install +``` + + +2. Install Ubuntu from the Microsoft Store + +3. Open the Ubuntu terminal and run the project inside WSL + +Tested on: Windows (WSL - Ubuntu) + +### 2. OAuth Consumer Setup + +To enable authentication, you must create an OAuth consumer on Meta-Wiki. + +1. Go to: https://meta.wikimedia.org/wiki/Special:OAuthConsumerRegistration + +2. Fill in the details: + - **Application Name:** Montage Local Dev(example) + - **Callback URL:** http://localhost:5000/callback + +3. Select grants: + - Basic identity + +**IMPORTANT:** +Ensure the box **"This consumer is for use only by [YourUsername]" is UNCHECKED** + +Otherwise, you will get: +OAuthException: Consumer is owner-only (E010) + + +### 3. Example Configuration + +Create or update your `config.dev.yaml` file: + +```yaml +oauth: + consumer_key: "your_key_here" + consumer_secret: "your_secret_here" + callback_url: "http://localhost:5000/callback" +``` + +### 4. Troubleshooting Common Setup Issues + +| Error / Issue | Root Cause | Solution | +|--------------|-----------|----------| +| OAuthException (E010) | Consumer is owner-only | Uncheck "owner-only" in Meta-Wiki | +| Invalid Consumer | Incorrect key/secret | Verify credentials carefully | +| Callback issues | URL mismatch | Ensure callback URL matches exactly | +| Dependency errors | Running on Windows | Use WSL (Ubuntu) | \ No newline at end of file From 6bb59e782bdb1d219cba394b351f011f6e84ccfa Mon Sep 17 00:00:00 2001 From: sanika palav Date: Thu, 9 Apr 2026 21:17:36 +0530 Subject: [PATCH 4/4] Fix: handle category import errors to prevent 500 crash --- frontend/src/components/Round/RoundEdit.vue | 12 +- frontend/src/components/Round/RoundNew.vue | 2 +- frontend/src/components/Round/RoundView.vue | 7 +- montage/admin_endpoints.py | 1103 ++++++++++++++++++- 4 files changed, 1086 insertions(+), 38 deletions(-) diff --git a/frontend/src/components/Round/RoundEdit.vue b/frontend/src/components/Round/RoundEdit.vue index 50c89a73..1fd3fb86 100644 --- a/frontend/src/components/Round/RoundEdit.vue +++ b/frontend/src/components/Round/RoundEdit.vue @@ -100,9 +100,9 @@ import Delete from 'vue-material-design-icons/Delete.vue' import alertService from '@/services/alertService' import adminService from '@/services/adminService' import dialogService from '@/services/dialogService' -import { useRouter } from 'vue-router' -const router = useRouter() + + const { t: $t } = useI18n() const props = defineProps({ round: Object, @@ -190,10 +190,12 @@ const deleteRound = () => { adminService .cancelRound(props.round.id) .then(() => { - emit('update:isRoundEditing', false) - router.reload() + alertService.success('Round deleted successfully.') + window.location.reload() + }) + .catch(() => { + alertService.error('Failed to delete round.') }) - .catch(alertService.error) } }) } diff --git a/frontend/src/components/Round/RoundNew.vue b/frontend/src/components/Round/RoundNew.vue index b080d751..f5507693 100644 --- a/frontend/src/components/Round/RoundNew.vue +++ b/frontend/src/components/Round/RoundNew.vue @@ -409,7 +409,7 @@ const cancelRound = () => { }) .then(() => { // Hide the form only after successful import - emit('reload-campaign-state') + emit('reloadCampaignState') emit('update:showAddRoundForm', false) }) .catch(alertService.error) diff --git a/frontend/src/components/Round/RoundView.vue b/frontend/src/components/Round/RoundView.vue index c8828199..cc124ddc 100644 --- a/frontend/src/components/Round/RoundView.vue +++ b/frontend/src/components/Round/RoundView.vue @@ -46,7 +46,12 @@ diff --git a/montage/admin_endpoints.py b/montage/admin_endpoints.py index d96e4181..73ef7c27 100644 --- a/montage/admin_endpoints.py +++ b/montage/admin_endpoints.py @@ -302,6 +302,1040 @@ def get_campaign_log(user_dao, campaign_id, request_dict): return {'data': ret} +def import_entries(user_dao, round_id, request_dict): + """ + Summary: Load entries into a round via one of four import methods + + Request model: + - round_id (in path) + - import_method: + - gistcsv + - category + - round + - selected + - gist_url (if import_method=gistcsv) + - category (if import_method=category) + - threshold (if import_method=round) + - file_names (if import_method=selected) + + Response model name: + - data: + - round_id + - new_entry_count + - new_round_entry_count + - total_entries + - status: success or failure + - errors: description of the failure (if any) + - warnings: possible problems to alert the user + - empty import (no entries) + - duplicate import (no new entries) + - all disqualified + """ + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + import_method = request_dict['import_method'] + # loader warnings + import_warnings = list() + + if import_method == 'csv' or import_method == 'gistcsv': + if import_method == 'gistcsv': + csv_url = request_dict['gist_url'] + else: + csv_url = request_dict['csv_url'] + + entries, warnings = coord_dao.add_entries_from_csv(round_id, csv_url) + params = {'csv_url': csv_url} + + if warnings: + msg = u'unable to load {} files ({!r})'.format(len(warnings), warnings) + import_warnings.append(msg) + + elif import_method == CATEGORY_METHOD: + cat_name = request_dict.get('category') + + if not cat_name: + entries = [] + params = {'category': None} + import_warnings.append('Category is required') + else: + if not cat_name.startswith("Category:"): + cat_name = f"Category:{cat_name}" + + params = {'category': cat_name} + + try: + entries = coord_dao.add_entries_from_cat(round_id, cat_name) + + if not entries: + import_warnings.append('No images found in this category') + + except Exception as e: + print("Import error:", e) + entries = [] + import_warnings.append('Invalid category or unable to fetch images') + + + + + + + + + + + + + elif import_method == ROUND_METHOD: + threshold = request_dict['threshold'] + prev_round_id = request_dict['previous_round_id'] + entries = coord_dao.get_rating_advancing_group(prev_round_id, threshold) + params = {'threshold': threshold, + 'round_id': prev_round_id} + elif import_method == SELECTED_METHOD: + file_names = request_dict['file_names'] + entries, warnings = coord_dao.add_entries_by_name(round_id, file_names) + if warnings: + formatted_warnings = u'\n'.join([ + u'- {}'.format(warning) for warning in warnings + ]) + msg = u'unable to load {} files:\n{}'.format(len(warnings), formatted_warnings) + import_warnings.append({'message': msg}) + params = {'file_names': file_names} + else: + raise NotImplementedResponse() + + new_entry_stats = coord_dao.add_round_entries(round_id, entries, + method=import_method, + params=params) + new_entry_stats['warnings'] = import_warnings + + if not entries: + new_entry_stats['warnings'].append({ + 'message': 'No entries imported' +}) + + elif not new_entry_stats.get('new_entry_count'): + new_entry_stats['warnings'].append({ + 'message': 'No new entries imported' +}) + + # automatically disqualify entries based on round config + auto_dq = autodisqualify(user_dao, round_id, request_dict={}) + new_entry_stats['disqualified'] = auto_dq['data'] + if len(new_entry_stats['disqualified']) >= len(entries): + new_entry_stats['warnings'].append({ + 'message': 'All entries were disqualified by round settings' +}) + + return {'data': new_entry_stats} + + + + + + + + + +def activate_round(user_dao, round_id, request_dict): + """ + Summary: Set the status of a round to active. + + Request model: + round_id: + type: int64 + """ + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + coord_dao.activate_round(round_id) + rnd = coord_dao.get_round(round_id) + ret_data = rnd.get_count_map() + ret_data['round_id'] = round_id + return {'data': ret_data} + + +def pause_round(user_dao, round_id, request_dict): + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + coord_dao.pause_round(round_id) + return {'data': 'paused'} + +def finalize_round(user_dao, round_id, request_dict): + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + rnd = coord_dao.get_round(round_id) + rnd.status = FINALIZED_STATUS + + return {'status': 'success'} + +def edit_campaign(user_dao, campaign_id, request_dict): + """ + Summary: Change the settings for a campaign + + Request model: + campaign_id + request_dict + + """ + edit_dict = {} + name = request_dict.get('name') + if name: + edit_dict['name'] = name + + is_archived = request_dict.get('is_archived') + if is_archived is not None: + edit_dict['is_archived'] = is_archived + + open_date = request_dict.get('open_date') + if open_date: + edit_dict['open_date'] = js_isoparse(open_date) + close_date = request_dict.get('close_date') + if close_date: + edit_dict['close_date'] = js_isoparse(close_date) + + coord_dao = CoordinatorDAO.from_campaign(user_dao, campaign_id) + coord_dao.edit_campaign(edit_dict) + return {'data': edit_dict} + + +def cancel_campaign(user_dao, campaign_id): + coord_dao = CoordinatorDAO.from_campaign(user_dao, campaign_id) + results = coord_dao.cancel_campaign() + return {'data': results} + + + +def _prepare_round_params(coord_dao, request_dict): + rnd_dict = {} + req_columns = ['jurors', 'name', 'vote_method', 'deadline_date'] + extra_columns = ['description', 'config', 'directions', 'show_stats'] + valid_vote_methods = ['ranking', 'rating', 'yesno'] + + for column in req_columns + extra_columns: + val = request_dict.get(column) + + # Required field validation + if not val and column in req_columns: + raise InvalidAction('%s is required to create a round (got %r)' + % (column, val)) + + # Vote method validation + if column == 'vote_method' and val not in valid_vote_methods: + raise InvalidAction('%s is an invalid vote method' % val) + + # Deadline validation + if column == 'deadline_date': + val = js_isoparse(val) + if not val: + raise InvalidAction('Voting deadline is required') + + # Juror validation (PRE-EMPTIVE CHECK) + if column == 'jurors': + juror_names = val + if not juror_names or len(juror_names) == 0: + raise InvalidAction('At least one juror must be selected') + + rnd_dict[column] = val + + # Default quorum based on juror count + default_quorum = len(rnd_dict['jurors']) + rnd_dict['quorum'] = request_dict.get('quorum', default_quorum) + + # Quorum validation + if rnd_dict['quorum'] <= 0: + raise InvalidAction('Quorum must be greater than 0') + + rnd_dict['jurors'] = [] + + # Convert juror names to user objects + for juror_name in juror_names: + juror = coord_dao.get_or_create_user(juror_name, 'juror') + rnd_dict['jurors'].append(juror) + + return rnd_dict + + +def create_round(user_dao, campaign_id, request_dict): + """ + Summary: Create a new round + + Request model: + campaign_id + """ + coord_dao = CoordinatorDAO.from_campaign(user_dao, campaign_id) + + rnd_params = _prepare_round_params(coord_dao, request_dict) + rnd = coord_dao.create_round(**rnd_params) + + data = rnd.to_details_dict() + data['progress'] = rnd.get_count_map() + + return {'data': data} + + +def edit_round(user_dao, round_id, request_dict): + """ + Summary: Post a new campaign + + Request model: + campaign_name + + Response model: AdminCampaignDetails + """ + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + new_val_map = coord_dao.edit_round(round_id, request_dict) + return {'data': new_val_map} + + +def cancel_round(user_dao, round_id): + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + rnd = coord_dao.cancel_round(round_id) + stats = rnd.get_count_map() + return {'data': stats} + + +def get_round_results_preview(user_dao, round_id): + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + rnd = coord_dao.get_round(round_id) + + round_counts = rnd.get_count_map() + is_closeable = rnd.check_closability() + + data = {'round': rnd.to_info_dict(), + 'counts': round_counts, + 'is_closeable': is_closeable} + + if rnd.vote_method in ('yesno', 'rating'): + data['ratings'] = coord_dao.get_round_average_rating_map(round_id) + try: + data['thresholds'] = get_threshold_map(data['ratings']) + except: + # import pdb;pdb.post_mortem() + raise + elif rnd.vote_method == 'ranking': + completed_votes_count = round_counts.get('total_tasks', 0) - round_counts.get('total_open_tasks', 0) + if not is_closeable or not completed_votes_count: + # TODO: What should this return for ranking rounds? The ranking + # round is sorta an all-or-nothing deal, unlike the rating rounds + # where you can take a peek at in-progress results + # import pdb;pdb.set_trace() + return {'status': 'failure', + '_status_code': 400, + 'errors': ('cannot preview results of a ranking ' + 'round until all ballots are ' + 'submitted'), + 'data': None} + rankings = coord_dao.get_round_ranking_list(round_id) + data['rankings'] = [r.to_dict() for r in rankings] + else: + raise NotImplementedResponse() + + return {'data': data} + + +def advance_round(user_dao, round_id, request_dict): + """Technical there are four possibilities. + + 1. Advancing from yesno/rating to another yesno/rating + 2. Advancing from yesno/rating to ranking + 3. Advancing from ranking to yesno/rating + 4. Advancing from ranking to another ranking + + Especially for the first version of Montage, this function will + only be written to cover the first two cases. This is because + campaigns are designed to end with a single ranking round. + + typical advancements are: "yesno -> rating -> ranking" or + "yesno -> rating -> yesno -> ranking" + + """ + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + rnd = coord_dao.get_round(round_id) + + if rnd.vote_method not in ('rating', 'yesno'): + raise NotImplementedResponse() # see docstring above + try: + threshold = float(request_dict['threshold']) + except KeyError: + raise InvalidAction('unset threshold. set the threshold and try again.') + _next_round_params = request_dict['next_round'] + nrp = _prepare_round_params(coord_dao, _next_round_params) + + if nrp['vote_method'] == 'ranking' \ + and len(nrp['jurors']) != nrp.get('quorum'): + # TODO: log + # (ranking round quorum must match juror count) + nrp['quorum'] = len(nrp['jurors']) + + # TODO: inherit round config from previous round? + adv_group = coord_dao.finalize_rating_round(round_id, threshold=threshold) + + next_rnd = coord_dao.create_round(**nrp) + source = 'round(#%s)' % round_id + params = {'round': round_id, + 'threshold': threshold} + coord_dao.add_round_entries(next_rnd.id, adv_group, + method=ROUND_METHOD, params=params) + + # NOTE: disqualifications are not repeated, as they should have + # been performed the first round. + + next_rnd_dict = next_rnd.to_details_dict() + next_rnd_dict['progress'] = next_rnd.get_count_map() + + msg = ('%s advanced campaign %r (#%s) from %s round "%s" to %s round "%s"' + % (user_dao.user.username, rnd.campaign.name, rnd.campaign.id, + rnd.vote_method, round_id, next_rnd.vote_method, next_rnd.name)) + coord_dao.log_action('advance_round', campaign=rnd.campaign, message=msg) + + return {'data': next_rnd_dict} + + +def finalize_campaign(user_dao, campaign_id): + # TODO: add some docs + coord_dao = CoordinatorDAO.from_campaign(user_dao, campaign_id) + last_rnd = coord_dao.campaign.active_round + + if not last_rnd: + raise InvalidAction('no active rounds') + + if last_rnd.vote_method != 'ranking': + raise InvalidAction('only ranking rounds can be finalized') + + campaign_summary = coord_dao.finalize_ranking_round(last_rnd.id) + coord_dao.finalize_campaign() + return campaign_summary + + +def reopen_campaign(user_dao, campaign_id): + coord_dao = CoordinatorDAO.from_campaign(user_dao, campaign_id) + coord_dao.reopen_campaign() + + +def get_index(user_dao, only_active=True): + """ + Summary: Get admin-level details for all campaigns. + + Response model name: AdminCampaignIndex + Response model: + campaigns: + type: array + items: + type: AdminCampaignDetails + + Errors: + 403: User does not have permission to access any campaigns + """ + campaigns = user_dao.get_all_campaigns(only_active=only_active) + data = [] + + for campaign in campaigns: + data.append(campaign.to_details_dict()) + + return {'data': data} + + +def get_user(user_dao, only_active=True): + """ + Summary: Get current login user details. + """ + return {'data': []} + + +def get_all_campaigns(user_dao): + return get_index(user_dao, only_active=False) + + +def get_campaigns(user_dao): + campaigns = user_dao.get_all_campaigns() + data = [] + + # TODO: group by series + for campaign in sorted(campaigns, key=lambda c: c.create_date, reverse=True): + data.append(campaign.to_info_dict()) + + return {'data': data} + + + +def get_campaign(user_dao, campaign_id): + """ + Summary: Get admin-level details for a campaign, identified by campaign ID. + + Request model: + campaign_id: + type: int64 + + Response model name: AdminCampaignDetails + Response model: + id: + type: int64 + name: + type: string + canonical_url_name: + type: string + rounds: + type: array + items: + type: AdminRoundInfo + coordinators: + type: array + items: + type: CoordDetails + + Errors: + 403: User does not have permission to access requested campaign + 404: Campaign not found + """ + coord_dao = CoordinatorDAO.from_campaign(user_dao, campaign_id) + campaign = coord_dao.campaign + if campaign is None: + raise Forbidden('not a coordinator on this campaign') + data = campaign.to_details_dict() + return {'data': data} + + +def get_round(user_dao, round_id): + """ + Summary: Get admin-level details for a round, identified by round ID. + + Request model: + round_id + + Response model name: AdminRoundDetails + + Errors: + 403: User does not have permission to access requested round + 404: Round not found + """ + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + rnd = coord_dao.get_round(round_id) + rnd_stats = rnd.get_count_map() + # entries_info = user_dao.get_entry_info(round_id) # TODO + # TODO: joinedload if this generates too many queries + data = make_admin_round_details(rnd, rnd_stats) + return {'data': data} + + +def get_results(user_dao, round_id, request_dict): + # TODO: Docs + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + results_by_name = coord_dao.make_vote_table(round_id) + return {'data': results_by_name} + + +def download_results_csv(user_dao, round_id, request_dict): + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + rnd = coord_dao.get_round(round_id) + now = datetime.datetime.now().isoformat() + output_name = 'montage_results-%s-%s.csv' % (slugify(rnd.name, ascii=True).decode('ascii'), now) + + # TODO: Confirm round is finalized + # raise DoesNotExist('round results not yet finalized') + + results_by_name = coord_dao.make_vote_table(round_id) + + output = io.BytesIO() + csv_fieldnames = ['filename', 'average'] + [r.username for r in rnd.jurors] + csv_writer = unicodecsv.DictWriter(output, fieldnames=csv_fieldnames, + restval=None) + # na means this entry wasn't assigned + + csv_writer.writeheader() + + for filename, ratings in results_by_name.items(): + csv_row = {'filename': filename} + valid_ratings = [r for r in ratings.values() if type(r) is not str] + if valid_ratings: + # TODO: catch if there are more than a quorum of votes + ratings['average'] = sum(valid_ratings) / len(valid_ratings) + else: + ratings['average'] = 'na' + csv_row.update(ratings) + csv_writer.writerow(csv_row) + + ret = output.getvalue() + resp = Response(ret, mimetype='text/csv') + resp.mimetype_params['charset'] = 'utf-8' + resp.headers['Content-Disposition'] = 'attachment; filename=%s' % output_name + return resp + + +def autodisqualify(user_dao, round_id, request_dict): + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + rnd = coord_dao.get_round(round_id) + + if rnd.status != 'paused': + raise InvalidAction('round must be paused to disqualify entries') + + dq_by_upload_date = request_dict.get('dq_by_upload_date') + dq_by_resolution = request_dict.get('dq_by_resolution') + dq_by_uploader = request_dict.get('dq_by_uploader') + dq_by_filetype = request_dict.get('dq_by_filetype') + + round_entries = [] + + if rnd.config.get('dq_by_upload_date') or dq_by_upload_date: + dq_upload_date = coord_dao.autodisqualify_by_date(round_id) + round_entries += dq_upload_date + + if rnd.config.get('dq_by_resolution') or dq_by_resolution: + dq_resolution = coord_dao.autodisqualify_by_resolution(round_id) + round_entries += dq_resolution + + if ( + rnd.config.get('dq_by_uploader') or + dq_by_uploader or + rnd.config.get('dq_coords') or + rnd.config.get('dq_organizers') or + rnd.config.get('dq_maintainers') + ): + dq_uploader = coord_dao.autodisqualify_by_uploader(round_id) + round_entries += dq_uploader + + if rnd.config.get('dq_by_filetype') or dq_by_filetype: + dq_filetype = coord_dao.autodisqualify_by_filetype(round_id) + round_entries += dq_filetype + + data = [re.to_dq_details() for re in round_entries] + + return {'data': data} + + +def preview_disqualification(user_dao, round_id): + # Let's you see what will get disqualified, without actually + # disqualifying any entries + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + # TODO: explain each disqualification + rnd = coord_dao.get_round(round_id) + ret = {'config': rnd.config} + + by_upload_date = coord_dao.autodisqualify_by_date(round_id, preview=True) + ret['by_upload_date'] = [re.entry.to_details_dict(with_uploader=True) + for re in by_upload_date] + + by_resolution = coord_dao.autodisqualify_by_resolution(round_id, preview=True) + ret['by_resolution'] = [re.entry.to_details_dict(with_uploader=True) + for re in by_resolution] + + by_uploader = coord_dao.autodisqualify_by_uploader(round_id, preview=True) + ret['by_uploader'] = [re.entry.to_details_dict(with_uploader=True) + for re in by_uploader] + + by_filetype = coord_dao.autodisqualify_by_filetype(round_id, preview=True) + ret['by_filetype'] = [re.entry.to_details_dict(with_uploader=True) + for re in by_filetype] + + return {'data': ret} + + +def get_flagged_entries(user_dao, round_id): + # TODO: include a limit? + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + flagged_entries = coord_dao.get_grouped_flags(round_id) + ret = [] + for fe in flagged_entries: + entry = fe.entry.to_details_dict() + entry['flaggings'] = [f.to_details_dict() + for f + in fe.flaggings] + ret.append(entry) + return {'data': ret} + + +def get_disqualified(user_dao, round_id): + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + round_entries = coord_dao.get_disqualified(round_id) + data = [re.to_dq_details() for re in round_entries] + return {'data': data} + + +def add_coordinator(user_dao, campaign_id, request_dict): + """ + Summary: - + Add a new coordinator identified by Wikimedia username to a campaign + identified by campaign ID + + Request model: + username + + Response model: + username + last_active_date + campaign_id + + Errors: + 403: User does not have permission to add coordinators + + """ + coord_dao = CoordinatorDAO.from_campaign(user_dao, campaign_id) + new_user_name = request_dict.get('username') + new_coord = coord_dao.add_coordinator(new_user_name) + data = {'username': new_coord.username, + 'campaign_id': campaign_id, + 'last_active_date': format_date(new_coord.last_active_date)} + return {'data': data} + + +def remove_coordinator(user_dao, campaign_id, request_dict): + coord_dao = CoordinatorDAO.from_campaign(user_dao, campaign_id) + username = request_dict.get('username') + old_coord = coord_dao.remove_coordinator(username) + data = {'username': username, + 'campaign_id': campaign_id, + 'last_active_date': format_date(old_coord.last_active_date)} + return {'data': data} + + +# Endpoints restricted to maintainers + +def add_organizer(user_dao, request_dict): + """ + Summary: Add a new organizer identified by Wikimedia username + + Request mode: + username: + type: string + + Response model: + username: + type: string + last_active_date: + type: date-time + + Errors: + 403: User does not have permission to add organizers + """ + maint_dao = MaintainerDAO(user_dao) + new_user_name = request_dict.get('username') + new_organizer = maint_dao.add_organizer(new_user_name) + data = {'username': new_organizer.username, + 'last_active_date': format_date(new_organizer.last_active_date)} + return {'data': data} + + +def remove_organizer(user_dao, request_dict): + maint_dao = MaintainerDAO(user_dao) + username = request_dict.get('username') + old_organizer = maint_dao.remove_organizer(username) + data = {'username': username, + 'last_active_date': format_date(old_organizer.last_active_date)} + return {'data': data} + + +# Endpoints restricted to organizers + + + + +ADMIN_API_ROUTES, ADMIN_UI_ROUTES = get_admin_routes() + + +# - cancel round +# - update round +# - no reassignment required: name, description, directions, display_settings +# - reassignment required: quorum, active_jurors +# - not updateable: id, open_date, close_date, vote_method, campaign_id/seq +'''from __future__ import absolute_import +import unicodecsv +import io +import datetime + + +from clastic import GET, POST, Response +from clastic.errors import Forbidden +from boltons.strutils import slugify + +from .utils import (format_date, + get_threshold_map, + InvalidAction, + NotImplementedResponse, + js_isoparse) + +from .rdb import (FINALIZED_STATUS, + CoordinatorDAO, + MaintainerDAO, + OrganizerDAO) + +CATEGORY_METHOD = 'category' +ROUND_METHOD = 'round' +SELECTED_METHOD = 'selected' + + +# These are populated at the bottom of the module +ADMIN_API_ROUTES, ADMIN_UI_ROUTES = None, None + + +def get_admin_routes(): + """ + /role/(object/id/object/id/...)verb is the guiding principle + """ + api = [GET('/admin', get_index), + POST('/admin/add_series', add_series), + POST('/admin/series//edit', edit_series), + POST('/admin/add_organizer', add_organizer), + POST('/admin/remove_organizer', remove_organizer), + POST('/admin/add_campaign', create_campaign), + GET('/admin/users', get_users), + GET('/admin/user', get_user), + GET('/admin/campaigns/', get_campaigns), + GET('/admin/campaigns/all', get_all_campaigns), + GET('/admin/campaign/', get_campaign), + POST('/admin/campaign//edit', edit_campaign), + POST('/admin/campaign//cancel', cancel_campaign), + POST('/admin/campaign//add_round', + create_round), + POST('/admin/campaign//add_coordinator', + add_coordinator), + POST('/admin/campaign//remove_coordinator', + remove_coordinator), + POST('/admin/campaign//finalize', finalize_campaign), + POST('/admin/campaign//reopen', reopen_campaign), + POST('/admin/campaign//publish', publish_report), + POST('/admin/campaign//unpublish', unpublish_report), + GET('/admin/campaign//audit', get_campaign_log), + POST('/admin/round//import', import_entries), + POST('/admin/round//activate', activate_round), + POST('/admin/round//pause', pause_round), + POST('/admin/round//finalize', finalize_round), + GET('/admin/round/', get_round), + POST('/admin/round//edit', edit_round), + POST('/admin/round//cancel', cancel_round), + GET('/admin/round//preview_results', + get_round_results_preview), + POST('/admin/round//advance', advance_round), + GET('/admin/round//flags', get_flagged_entries), + GET('/admin/round//disqualified', + get_disqualified), + POST('/admin/round//autodisqualify', + autodisqualify), + POST('/admin/round///disqualify', + disqualify_entry), + POST('/admin/round///requalify', + requalify_entry), + GET('/admin/round//preview_disqualification', + preview_disqualification), + GET('/admin/round//results', get_results), + GET('/admin/round//results/download', download_results_csv), + GET('/admin/round//entries', get_round_entries), + GET('/admin/round//entries/download', download_round_entries_csv), + GET('/admin/round//reviews', get_round_reviews), + GET('/admin/campaign//report', get_campaign_report_raw)] + ui = [GET('/admin/campaign//report', get_campaign_report, + 'report.html')] + # TODO: arguably download URLs should go into "ui" as well, + # anything that generates a response directly (or doesn't return json) + return api, ui + + +def get_round_reviews(user_dao, round_id): + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + entries = coord_dao.get_reviews_table(round_id) + entry_infos = [e.to_details_dict() for e in entries] + return {'data': entry_infos} + + +def get_round_entries(user_dao, round_id): + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + entries = coord_dao.get_round_entries(round_id) + entry_infos = [e.to_export_dict() for e in entries] + return {'file_infos': entry_infos} + + +def download_round_entries_csv(user_dao, round_id): + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + rnd = coord_dao.get_round(round_id) + entries = coord_dao.get_round_entries(round_id) + entry_infos = [e.to_export_dict() for e in entries] + output_name = 'montage_entries-%s.csv' % slugify(rnd.name, ascii=True).decode('ascii') + output = io.BytesIO() + csv_fieldnames = sorted(entry_infos[0].keys()) + csv_writer = unicodecsv.DictWriter(output, fieldnames=csv_fieldnames) + csv_writer.writeheader() + csv_writer.writerows(entry_infos) + ret = output.getvalue() + resp = Response(ret, mimetype='text/csv') + resp.mimetype_params['charset'] = 'utf-8' + resp.headers['Content-Disposition'] = 'attachment; filename=%s' % (output_name,) + return resp + + +def disqualify_entry(user_dao, round_id, entry_id, request_dict): + if not request_dict: + request_dict = {} + reason = request_dict.get('reason') + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + coord_dao.disqualify(round_id, entry_id, reason) + + +def requalify_entry(user_dao, round_id, entry_id, request_dict): + coord_dao = CoordinatorDAO.from_round(user_dao, round_id) + coord_dao.requalify(round_id, entry_id) + + +def add_series(user_dao, request_dict): + org_dao = OrganizerDAO(user_dao) + + name = request_dict['name'] + description = request_dict['description'] + url = request_dict['url'] + status = request_dict.get('status') + + new_series = org_dao.create_series(name, description, url, status) + return {'data': new_series} + + +def edit_series(user_dao, series_id, request_dict): + org_dao = OrganizerDAO(user_dao) + series_dict = {} + name = request_dict.get('name') + if name: + series_dict['name'] = name + description = request_dict.get('description') + if description: + series_dict['description'] = description + url = request_dict.get('url') + if url: + series_dict['url'] = url + status = request_dict.get('status') + if status: + series_dict['status'] = status + + new_series = org_dao.edit_series(series_id, series_dict) + return {'data': new_series} + + +def publish_report(user_dao, campaign_id): + coord_dao = CoordinatorDAO.from_campaign(user_dao, campaign_id) + coord_dao.publish_report() + + +def unpublish_report(user_dao, campaign_id): + coord_dao = CoordinatorDAO.from_campaign(user_dao, campaign_id) + coord_dao.unpublish_report() + + +def make_admin_round_details(rnd, rnd_stats): + # TODO: This should be depricated in favor of rnd.to_details_dict(), which + # is similar except for the stats dict structure. + ret = {'id': rnd.id, + 'name': rnd.name, + 'directions': rnd.directions, + 'canonical_url_name': slugify(rnd.name, '-'), + 'vote_method': rnd.vote_method, + 'open_date': format_date(rnd.open_date), + 'close_date': format_date(rnd.close_date), + 'create_date': format_date(rnd.create_date), + 'config': rnd.config, + 'deadline_date': format_date(rnd.deadline_date), + 'status': rnd.status, + 'quorum': rnd.quorum, + 'total_entries': len(rnd.entries), + 'total_tasks': rnd_stats['total_tasks'], + 'total_open_tasks': rnd_stats['total_open_tasks'], + 'percent_tasks_open': rnd_stats['percent_tasks_open'], + 'total_disqualified_entries': rnd_stats['total_disqualified_entries'], + 'campaign': rnd.campaign.to_info_dict(), + 'stats': rnd_stats, + 'jurors': [rj.to_details_dict() for rj in rnd.round_jurors], + 'is_closable': rnd.check_closability()} + return ret + + +def get_users(user_dao, request_dict): + """View the maintainers, organizers, and campaign coordinators""" + + org_dao = OrganizerDAO(user_dao) + user_list = org_dao.get_user_list() + + return {'data': user_list} + + +# TODO: (clastic) some way to mark arguments as injected from the +# request_dict such that the signature can be expanded here. the goal +# being that create_campaign can be a standalone function without any +# special middleware dependencies, to achieve a level of testing +# between the dao and server tests. +def create_campaign(user_dao, request_dict): + """ + Summary: Post a new campaign + + Request model: + campaign_name: + type: string + + Response model: AdminCampaignDetails + """ + org_dao = OrganizerDAO(user_dao) + + name = request_dict.get('name') + + if not name: + raise InvalidAction('name is required to create a campaign, got %r' + % name) + now = datetime.datetime.utcnow().isoformat() + open_date = request_dict.get('open_date', now) + + if open_date: + open_date = js_isoparse(open_date) + + close_date = request_dict.get('close_date') + + if close_date: + close_date = js_isoparse(close_date) + + url = request_dict['url'] + + series_id = request_dict.get('series_id', 1) + + coord_names = request_dict.get('coordinators') + + coords = [user_dao.user] # Organizer is included as a coordinator by default + for coord_name in coord_names: + coord = org_dao.get_or_create_user(coord_name, 'coordinator') + coords.append(coord) + + campaign = org_dao.create_campaign(name=name, + open_date=open_date, + close_date=close_date, + series_id=series_id, + url=url, + coords=set(coords)) + # TODO: need completion info for each round + data = campaign.to_details_dict() + + return {'data': data} + + +def get_campaign_report(user_dao, campaign_id): + coord_dao = CoordinatorDAO.from_campaign(user_dao, campaign_id) + summary = coord_dao.get_campaign_report() + ctx = summary.summary + ctx['use_ashes'] = True + return ctx + + +def get_campaign_report_raw(user_dao, campaign_id): + coord_dao = CoordinatorDAO.from_campaign(user_dao, campaign_id) + summary = coord_dao.get_campaign_report() + data = summary.summary + return {'data': data} + + +def get_campaign_log(user_dao, campaign_id, request_dict): + request_dict = request_dict or dict() + limit = request_dict.get('limit', 100) + offset = request_dict.get('offset', 0) + round_id = request_dict.get('round_id') + log_id = request_dict.get('id') + action = request_dict.get('action') + + coord_dao = CoordinatorDAO.from_campaign(user_dao, campaign_id) + audit_logs = coord_dao.get_audit_log(limit=limit, + offset=offset, + round_id=round_id, + log_id=log_id, + action=action) + ret = [a.to_info_dict() for a in audit_logs] + return {'data': ret} + + def import_entries(user_dao, round_id, request_dict): """ Summary: Load entries into a round via one of four import methods @@ -336,46 +1370,53 @@ def import_entries(user_dao, round_id, request_dict): # loader warnings import_warnings = list() + if import_method == 'csv' or import_method == 'gistcsv': + if import_method == 'gistcsv': + csv_url = request_dict['gist_url'] + else: + csv_url = request_dict['csv_url'] - if import_method == 'csv' or import_method == 'gistcsv': - if import_method == 'gistcsv': - csv_url = request_dict['gist_url'] - else: - csv_url = request_dict['csv_url'] + entries, warnings = coord_dao.add_entries_from_csv(round_id, csv_url) + params = {'csv_url': csv_url} + + if warnings: + msg = u'unable to load {} files ({!r})'.format(len(warnings), warnings) + import_warnings.append(msg) + +elif import_method == CATEGORY_METHOD: + cat_name = request_dict.get('category') + + if not cat_name: + entries = [] + params = {'category': None} + import_warnings.append('Category is required') + else: + if not cat_name.startswith("Category:"): + cat_name = f"Category:{cat_name}" - entries, warnings = coord_dao.add_entries_from_csv(round_id, - csv_url) - params = {'csv_url': csv_url} - if warnings: - msg = u'unable to load {} files ({!r})'.format(len(warnings), warnings) - import_warnings.append(msg) - elif import_method == CATEGORY_METHOD: - cat_name = request_dict['category'] params = {'category': cat_name} try: entries = coord_dao.add_entries_from_cat(round_id, cat_name) - if not entries: - return { - 'status': 'failure', - '_status_code': 400, - 'errors': 'No images found in this category', - 'data': None - } - except Exception: - return { - 'status': 'failure', - '_status_code': 400, - 'errors': 'Invalid Wikimedia Commons category', - 'data': None - } + if not entries: + import_warnings.append('No images found in this category') + except Exception as e: + print("Import error:", e) + entries = [] + import_warnings.append('Invalid category or unable to fetch images') - - + + + + + + + + elif import_method == ROUND_METHOD: threshold = request_dict['threshold'] prev_round_id = request_dict['previous_round_id'] @@ -390,7 +1431,7 @@ def import_entries(user_dao, round_id, request_dict): u'- {}'.format(warning) for warning in warnings ]) msg = u'unable to load {} files:\n{}'.format(len(warnings), formatted_warnings) - import_warnings.append({'import issues', msg}) + import_warnings.append({'message': msg}) params = {'file_names': file_names} else: raise NotImplementedResponse() @@ -1012,4 +2053,4 @@ def remove_organizer(user_dao, request_dict): # - update round # - no reassignment required: name, description, directions, display_settings # - reassignment required: quorum, active_jurors -# - not updateable: id, open_date, close_date, vote_method, campaign_id/seq +# - not updateable: id, open_date, close_date, vote_method, campaign_id/seq'''