├── montage ├── __init__.py ├── tests │ ├── __init__.py │ └── test_loaders.py ├── docs │ ├── index.md │ └── faq.md ├── __main__.py ├── static │ ├── mdl │ │ ├── bower.json │ │ ├── package.json │ │ └── styles.css │ ├── a │ │ └── index.html │ └── dist │ │ └── images │ │ └── logo_white_fat.svg ├── log.py ├── server.py ├── templates │ ├── docs │ │ └── base.html │ └── report.html ├── imgutils.py ├── clastic_sentry.py ├── rendered_admin.py ├── mw │ └── sqlprof.py ├── labs.py ├── check_rdb.py ├── cors.py ├── meta_endpoints.py └── simple_serdes.py ├── .dockerignore ├── frontend ├── .env.default ├── README.md ├── public │ └── favicon.ico ├── jsconfig.json ├── src │ ├── views │ │ ├── AllCampaignView.vue │ │ ├── NewCampaignView.vue │ │ ├── CampaignView.vue │ │ ├── VoteEditView.vue │ │ ├── VoteView.vue │ │ ├── HomeView.vue │ │ └── PermissionDenied.vue │ ├── stores │ │ ├── loading.js │ │ └── user.js │ ├── App.vue │ ├── services │ │ ├── alertService.js │ │ ├── api.js │ │ ├── dataService.js │ │ ├── adminService.js │ │ ├── dialogService.js │ │ └── jurorService.js │ ├── main.js │ ├── i18n.js │ ├── assets │ │ ├── main.css │ │ └── logo_white.svg │ ├── components │ │ ├── AppFooter.vue │ │ ├── UserAvatarWithName.vue │ │ ├── UserList.vue │ │ ├── Vote │ │ │ └── Vote.vue │ │ ├── AddOrganizer.vue │ │ ├── LoginBox.vue │ │ ├── CommonsImage.vue │ │ ├── AppHeader.vue │ │ ├── Round │ │ │ ├── RoundView.vue │ │ │ └── RoundEdit.vue │ │ └── Campaign │ │ │ ├── CoordinatorCampaignCard.vue │ │ │ ├── JurorCampaignCard.vue │ │ │ ├── AllCampaign.vue │ │ │ └── ActiveCampaign.vue │ ├── router │ │ └── index.js │ ├── utils.js │ └── i18n │ │ ├── pa.json │ │ ├── ko.json │ │ ├── ce.json │ │ ├── pt-br.json │ │ └── ps.json ├── cypress │ ├── fixtures │ │ └── example.json │ ├── support │ │ ├── e2e.js │ │ └── commands.js │ └── e2e │ │ ├── campaign.cy.js │ │ └── login.cy.js ├── .prettierrc.json ├── cypress.config.js ├── .eslintrc.cjs ├── vite.config.js ├── index.html └── package.json ├── requirements-dev.txt ├── cypress.config.js ├── dockerfile ├── .tox-coveragerc ├── Makefile ├── app.py ├── docker-compose.yml ├── .travis.yml ├── _test_campaign_report.py ├── config.default.yaml ├── MANIFEST.in ├── README.md ├── cypress └── support │ ├── e2e.js │ └── commands.js ├── requirements.in ├── tox.ini ├── docs └── process.md ├── tools ├── trim_csv.py ├── check_schema.py ├── create_schema.py ├── drop_schema.py └── _admin.py ├── setup.py ├── LICENSE ├── .gitignore ├── PROJECT_LOG.md ├── requirements.txt ├── TODO.md ├── deployment.md ├── report.html └── design.md /montage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /montage/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | frontend 2 | client 3 | test_data 4 | fe 5 | docs -------------------------------------------------------------------------------- /frontend/.env.default: -------------------------------------------------------------------------------- 1 | VITE_API_ENDPOINT=http://localhost:5001 -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Montage-frontend 2 | 3 | This is montage frontend -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hatnote/montage/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | tox<3.15.0 4 | Fabric3 5 | coverage==5.0.2 6 | pytest==4.6.9 7 | -------------------------------------------------------------------------------- /montage/docs/index.md: -------------------------------------------------------------------------------- 1 | # Montage Docs 2 | 3 | All good software needs good docs, and Montage is no exception. 4 | 5 | 6 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | e2e: { 3 | setupNodeEvents(on, config) { 4 | // implement node event listeners here 5 | }, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /frontend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | } 6 | }, 7 | "exclude": ["node_modules", "dist"] 8 | } -------------------------------------------------------------------------------- /frontend/src/views/AllCampaignView.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /frontend/src/views/NewCampaignView.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /frontend/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": false, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "printWidth": 100, 7 | "trailingComma": "none" 8 | } -------------------------------------------------------------------------------- /frontend/cypress.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | setupNodeEvents(on, config) { 6 | // implement node event listeners here 7 | }, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | 3 | RUN apt-get update && apt-get install -y git 4 | 5 | WORKDIR /app 6 | 7 | COPY requirements.txt . 8 | 9 | RUN pip install --upgrade pip 10 | RUN pip install -r requirements.txt 11 | 12 | EXPOSE 5000 13 | -------------------------------------------------------------------------------- /frontend/src/views/CampaignView.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /.tox-coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = 4 | montage 5 | ../montage 6 | 7 | [paths] 8 | source = 9 | ../montage 10 | */lib/python*/site-packages/montage 11 | */Lib/site-packages/montage 12 | */pypy/site-packages/montage 13 | omit = 14 | */flycheck_* 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | start: 2 | docker compose build && docker compose up 3 | 4 | start-detached: 5 | docker compose build && docker compose up -d 6 | 7 | stop: 8 | docker compose down 9 | 10 | logs: 11 | docker compose logs -f 12 | 13 | restart: 14 | docker compose down && docker compose up --build -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | # this file is only used by wmflabs for hosting 2 | 3 | import urllib3.contrib.pyopenssl 4 | urllib3.contrib.pyopenssl.inject_into_urllib3() 5 | 6 | from montage.app import create_app 7 | from montage.utils import get_env_name 8 | 9 | env_name = get_env_name() 10 | app = create_app(env_name=env_name) 11 | -------------------------------------------------------------------------------- /frontend/src/stores/loading.js: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | import { defineStore } from 'pinia' 3 | 4 | export const useLoadingStore = defineStore('loading', () => { 5 | const loading = ref(null) 6 | 7 | function setLoading(val) { 8 | loading.value = val 9 | } 10 | 11 | return { loading, setLoading } 12 | }) 13 | -------------------------------------------------------------------------------- /frontend/src/views/VoteEditView.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /frontend/src/views/VoteView.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | 17 | -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require('@rushstack/eslint-patch/modern-module-resolution') 3 | 4 | module.exports = { 5 | root: true, 6 | 'extends': [ 7 | 'plugin:vue/vue3-essential', 8 | 'eslint:recommended', 9 | '@vue/eslint-config-prettier/skip-formatting' 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 'latest' 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /montage/docs/faq.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | [TOC] 4 | 5 | ## How many jurors should I have for my rounds? 6 | 7 | It's all about balance. When do you want to make your announcement and 8 | how much time can your jurors devote to rating images? 9 | 10 | Jurors can rate about 300 images per hour for early rounds, varying by 11 | juror and submission quality. 12 | -------------------------------------------------------------------------------- /montage/__main__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | 5 | if __name__ == '__main__': 6 | from .app import create_app 7 | from .utils import get_env_name 8 | 9 | # TODO: don't forget to update the app.py one level above on toolforge 10 | 11 | env_name = get_env_name() 12 | app = create_app(env_name=env_name) 13 | app.serve() 14 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | montage: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | ports: 7 | - "5001:5000" # Airplay runs on port 5000 on mac. See https://forums.developer.apple.com/forums/thread/682332 8 | environment: 9 | - PYTHONPATH=/app 10 | volumes: 11 | - .:/app 12 | command: > 13 | bash -c "python tools/create_schema.py && python -m montage" 14 | -------------------------------------------------------------------------------- /frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | 3 | import { defineConfig } from 'vite' 4 | import vue from '@vitejs/plugin-vue' 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | vue(), 10 | ], 11 | resolve: { 12 | alias: { 13 | '@': fileURLToPath(new URL('./src', import.meta.url)) 14 | } 15 | }, 16 | server: { 17 | port: 5173, 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /frontend/src/views/HomeView.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | cache: 3 | directories: 4 | - $HOME/.cache/pip 5 | 6 | language: python 7 | 8 | 9 | matrix: 10 | include: 11 | - python: "2.7" 12 | env: TOXENV=py27 13 | 14 | install: 15 | - "pip install -r requirements-dev.txt" 16 | 17 | script: 18 | - tox 19 | 20 | before_install: 21 | - pip install codecov coverage 22 | 23 | 24 | after_success: 25 | - tox -e coverage-report 26 | - COVERAGE_FILE=.tox/.coverage coverage xml 27 | - codecov -f coverage.xml 28 | -------------------------------------------------------------------------------- /_test_campaign_report.py: -------------------------------------------------------------------------------- 1 | 2 | from montage.rdb import * 3 | from montage.rdb import make_rdb_session, CoordinatorDAO, User 4 | 5 | def main(): 6 | rdb_session = make_rdb_session(echo=False) 7 | 8 | user = rdb_session.query(User).first() 9 | cdao = CoordinatorDAO(rdb_session=rdb_session, user=user) 10 | campaign = cdao.get_campaign(1) 11 | 12 | ctx = cdao.get_campaign_report(campaign) 13 | 14 | import pdb;pdb.set_trace() 15 | 16 | 17 | if __name__ == '__main__': 18 | main() 19 | -------------------------------------------------------------------------------- /config.default.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | db_echo: True 3 | api_log_path: montage_api.log 4 | db_url: "sqlite:///tmp_montage.db" 5 | 6 | cookie_secret: ReplaceThisWithSomethingSomewhatSecret 7 | superuser: Slaporte 8 | 9 | dev_local_cookie_value: "contact maintainers for details" 10 | dev_remote_cookie_value: "contact maintainers for details" 11 | oauth_secret_token: "see note below" 12 | oauth_consumer_token: "visit https://meta.wikimedia.org/wiki/Special:OAuthConsumerRegistration/propose to get valid OAuth tokens for local development, or contact the maintainers" 13 | ... 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.html 2 | include *.in 3 | include *.md 4 | include *.py 5 | include *.txt 6 | include *.yaml 7 | include LICENSE 8 | graft montage/static 9 | prune client 10 | prune docs 11 | recursive-include montage *.css 12 | recursive-include montage *.html 13 | recursive-include montage *.js 14 | recursive-include montage *.json 15 | recursive-include montage *.map 16 | recursive-include montage *.md 17 | recursive-include montage *.py 18 | recursive-include montage *.svg 19 | recursive-include test_data *.csv 20 | recursive-include tools *.py 21 | global-exclude flycheck_* 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Logo](https://i.imgur.com/EZD3p9r.png) 2 | 3 | ## Montage 4 | 5 | _Photo evaluation tool for and by Wiki Loves competitions_ 6 | 7 | Round-based photo evaluation is a crucial step in the "Wiki Loves" 8 | series of photography competitions. Montage provides a configurable 9 | workflow that adapts to the conventions of all groups. 10 | 11 | - [montage on Wikimedia Commons](https://commons.wikimedia.org/wiki/Commons:Montage) 12 | - [montage on Phabricator](https://phabricator.wikimedia.org/project/view/2287/) 13 | 14 | ## Testing 15 | 16 | `pip install tox` into your virtualenv, then `tox`. 17 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Montage 8 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' -------------------------------------------------------------------------------- /frontend/cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' -------------------------------------------------------------------------------- /montage/static/mdl/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "material-design-lite", 3 | "version": "1.3.0", 4 | "homepage": "https://github.com/google/material-design-lite", 5 | "authors": [ 6 | "Material Design Lite team" 7 | ], 8 | "description": "Material Design Components in CSS, JS and HTML", 9 | "main": [ 10 | "material.min.css", 11 | "material.min.js" 12 | ], 13 | "keywords": [ 14 | "material", 15 | "design", 16 | "styleguide", 17 | "style", 18 | "guide" 19 | ], 20 | "license": "Apache-2", 21 | "ignore": [ 22 | "**/.*", 23 | "node_modules", 24 | "bower_components", 25 | "./lib/.bower_components", 26 | "test", 27 | "tests" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | ashes 2 | boltons>=21.0.0 3 | chert>=21.0.0 4 | clastic 5 | face 6 | lithoxyl>=21.0.0 7 | mwoauth 8 | # mysqlclient # only on toolforge 9 | sentry-sdk 10 | pyopenssl 11 | sqltap 12 | 13 | # look at: 14 | python-graph-core==1.8.2 15 | git+https://github.com/the-maldridge/python-vote-core.git@f0b01e7e24f80673c4c237ee9e6118e8986cf0bb#egg=python3-vote-core ; python_version >= '3.0' 16 | SQLAlchemy==1.2.19 17 | unicodecsv==0.14.1 18 | pymysql==1.1.1 19 | cryptography==40.0.2 # 2024-04-23: higher versions of cryptography breaks with uwsgi due to PyO3's lack of subinterpreter support: https://github.com/PyO3/pyo3/issues/3451 . see also: https://github.com/pyca/cryptography/issues/9016 (says it's fixed, but isn't, at least not on py39) -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 15 | 16 | 35 | -------------------------------------------------------------------------------- /frontend/src/services/alertService.js: -------------------------------------------------------------------------------- 1 | import { useToast } from 'vue-toastification' 2 | 3 | const toast = useToast() 4 | 5 | const AlertService = { 6 | success(text, time, callback) { 7 | toast.success(text, { 8 | timeout: time || 2000, 9 | position: 'top-right', 10 | onClose: () => { 11 | callback && callback() 12 | } 13 | }) 14 | }, 15 | error(error, time) { 16 | const message = error?.response?.data?.message || error?.message || 'An error occurred' 17 | const detail = error?.response?.data?.detail 18 | 19 | const text = detail ? `${message}: ${detail}` : message 20 | 21 | toast.error(text, { 22 | timeout: time || 5000, 23 | position: 'top-right' 24 | }) 25 | } 26 | } 27 | 28 | export default AlertService 29 | -------------------------------------------------------------------------------- /montage/static/a/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Montage Admin 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py39,py311,coverage-report 3 | 4 | [testenv] 5 | changedir = .tox 6 | deps = 7 | -rrequirements-dev.txt 8 | commands = coverage run --parallel --omit 'flycheck_*' --rcfile {toxinidir}/.tox-coveragerc -m pytest --ignore client/ --doctest-modules {envsitepackagesdir}/montage {posargs} 9 | 10 | # Uses default basepython otherwise reporting doesn't work on Travis where 11 | # Python 3.6 is only available in 3.6 jobs. 12 | [testenv:coverage-report] 13 | changedir = .tox 14 | deps = 15 | -rrequirements-dev.txt 16 | commands = coverage combine --rcfile {toxinidir}/.tox-coveragerc 17 | coverage report --rcfile {toxinidir}/.tox-coveragerc 18 | coverage html --rcfile {toxinidir}/.tox-coveragerc -d {toxinidir}/htmlcov 19 | 20 | 21 | [testenv:packaging] 22 | changedir = {toxinidir} 23 | deps = 24 | check-manifest==0.40 25 | commands = 26 | check-manifest 27 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) -------------------------------------------------------------------------------- /frontend/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import { createPinia } from 'pinia' 3 | 4 | import App from './App.vue' 5 | import router from './router' 6 | import { i18n, loadMessages } from './i18n' 7 | import Toast from 'vue-toastification' 8 | import 'vue-toastification/dist/index.css' 9 | 10 | import { CdxTooltip } from '@wikimedia/codex' 11 | import ClipLoader from 'vue-spinner/src/ClipLoader.vue' 12 | 13 | import DatePicker from 'vue-datepicker-next' 14 | import 'vue-datepicker-next/index.css' 15 | 16 | const app = createApp(App) 17 | 18 | app.use(createPinia()) 19 | app.use(router) 20 | app.use(Toast) 21 | app.use(i18n) // Use i18n immediately with English locale 22 | 23 | // Global directive 24 | app.directive('tooltip', CdxTooltip) 25 | app.component('clip-loader', ClipLoader) 26 | app.component('date-picker', DatePicker) 27 | 28 | // Load additional language messages dynamically 29 | loadMessages().then(() => { 30 | app.mount('#app') 31 | }); 32 | -------------------------------------------------------------------------------- /docs/process.md: -------------------------------------------------------------------------------- 1 | # Montage Campaign Process 2 | 3 | ## Roles 4 | 5 | * Jurors 6 | * Coordinators 7 | * Organizers 8 | 9 | ## Process 10 | 11 | * Campaign creation 12 | * Start date and end date -> Entry eligibility date range 13 | * First round creation and import 14 | * Start date and end date -> Juror voting range 15 | * Round types 16 | * Rating 17 | * Yes/No 18 | * Ranking 19 | * Advancing to the next round 20 | * Final ranking round 21 | * Closing a campaign 22 | * Publishing the report 23 | * Exporting results 24 | 25 | 26 | ## FAQ 27 | 28 | * What's an "unofficial" campaign 29 | * How many jurors should a round have? 30 | * How do I choose a quorum? 31 | * How do I go back and fix votes? 32 | * What are favorites? 33 | * What are flags? 34 | 35 | 36 | 37 | ## Quick tips 38 | 39 | * Use a quick yes/no round right before your ranking round to gauge 40 | juror consensus on the final ranking set. 41 | 42 | ## Glossary 43 | 44 | * Quorum 45 | -------------------------------------------------------------------------------- /frontend/src/i18n.js: -------------------------------------------------------------------------------- 1 | import { createI18n } from 'vue-i18n'; 2 | import en from '@/i18n/en.json'; 3 | 4 | const messages = { en }; 5 | 6 | const i18n = createI18n({ 7 | legacy: false, 8 | locale: 'en', 9 | fallbackLocale: 'en', 10 | messages 11 | }); 12 | 13 | // Dynamically load additional messages after initialization 14 | const loadMessages = async () => { 15 | try { 16 | const modules = import.meta.glob('./i18n/*.json'); 17 | await Promise.all( 18 | Object.entries(modules).map(async ([path, importFn]) => { 19 | const lang = path.replace('./i18n/', '').replace('.json', ''); 20 | if (lang !== 'en' && lang !== 'qqq') { 21 | const module = await importFn(); 22 | i18n.global.setLocaleMessage(lang, module.default); 23 | } 24 | }) 25 | ); 26 | return i18n; 27 | } catch (error) { 28 | console.error('Error loading i18n messages:', error); 29 | return i18n; 30 | } 31 | }; 32 | 33 | export { i18n, loadMessages }; 34 | -------------------------------------------------------------------------------- /tools/trim_csv.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import print_function 3 | from __future__ import absolute_import 4 | import os.path 5 | import argparse 6 | from unicodecsv import DictReader, DictWriter 7 | 8 | 9 | def main(): 10 | prs = argparse.ArgumentParser() 11 | 12 | prs.add_argument('--count', type=int, default=100) 13 | 14 | prs.add_argument('file', type=file) 15 | 16 | args = prs.parse_args() 17 | 18 | count = args.count 19 | assert count > 0 20 | path = os.path.abspath(args.file.name) 21 | root, ext = os.path.splitext(path) 22 | new_path = '%s_trimmed_%s%s' % (root, count, ext) 23 | 24 | reader = DictReader(open(path)) 25 | new_entries = [] 26 | for i in range(count): 27 | new_entries.append(next(reader)) 28 | 29 | with open(new_path, 'w') as new_file: 30 | writer = DictWriter(new_file, reader.unicode_fieldnames) 31 | writer.writeheader() 32 | writer.writerows(new_entries) 33 | 34 | print(open(new_path).read()) 35 | 36 | 37 | if __name__ == '__main__': 38 | main() 39 | -------------------------------------------------------------------------------- /frontend/src/views/PermissionDenied.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 17 | 18 | 42 | -------------------------------------------------------------------------------- /frontend/src/stores/user.js: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | import { defineStore } from 'pinia' 3 | import adminService from '@/services/adminService' 4 | 5 | export const useUserStore = defineStore('user-store', () => { 6 | const user = ref(null) 7 | const isAuthenticated = ref(false) 8 | const authChecked = ref(false) 9 | 10 | function login(userObj) { 11 | if (!userObj) { 12 | window.location = import.meta.env.VITE_API_ENDPOINT + '/login' 13 | } 14 | user.value = userObj 15 | isAuthenticated.value = true 16 | } 17 | 18 | function logout() { 19 | window.location = import.meta.env.VITE_API_ENDPOINT + '/logout' 20 | user.value = null 21 | isAuthenticated.value = false 22 | authChecked.value = true 23 | } 24 | 25 | async function checkAuth() { 26 | if (!authChecked.value) { 27 | const res = await adminService.getUser() 28 | if (res.status === 'success' && res.user) { 29 | login(res.user) 30 | } 31 | authChecked.value = true 32 | } 33 | } 34 | 35 | return { user, login, logout, checkAuth, isAuthenticated,authChecked } 36 | }) 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | 2 | from setuptools import setup, find_packages 3 | 4 | 5 | __author__ = 'Mahmoud Hashemi and Stephen LaPorte' 6 | __version__ = '0.0.1' 7 | __contact__ = 'mahmoud@hatnote.com' 8 | __url__ = 'https://github.com/hatnote/montage' 9 | __license__ = 'BSD' 10 | 11 | 12 | setup(name='hatnote-montage', 13 | version=__version__, 14 | description="A voting platform for WLM", 15 | long_description=__doc__, 16 | author=__author__, 17 | author_email=__contact__, 18 | url=__url__, 19 | packages=find_packages(), 20 | include_package_data=True, 21 | zip_safe=False, 22 | license=__license__, 23 | platforms='any', 24 | ) 25 | 26 | """ 27 | TODO 28 | 29 | A brief checklist for release: 30 | 31 | * tox 32 | * git commit (if applicable) 33 | * Bump setup.py version off of -dev 34 | * git commit -a -m "bump version for x.y.z release" 35 | * python setup.py sdist bdist_wheel upload 36 | * bump docs/conf.py version 37 | * git commit 38 | * git tag -a x.y.z -m "brief summary" 39 | * write CHANGELOG 40 | * git commit 41 | * bump setup.py version onto n+1 dev 42 | * git commit 43 | * git push 44 | 45 | """ 46 | -------------------------------------------------------------------------------- /tools/check_schema.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import print_function 3 | from __future__ import absolute_import 4 | import pdb 5 | import sys 6 | import os.path 7 | import argparse 8 | 9 | CUR_PATH = os.path.dirname(os.path.abspath(__file__)) 10 | PROJ_PATH = os.path.dirname(CUR_PATH) 11 | 12 | sys.path.append(PROJ_PATH) 13 | 14 | from montage.rdb import Base 15 | from montage.utils import load_env_config, check_schema 16 | 17 | 18 | def main(): 19 | prs = argparse.ArgumentParser('create montage db and load initial data') 20 | add_arg = prs.add_argument 21 | add_arg('--db_url') 22 | add_arg('--verbose', action="store_true", default=False) 23 | 24 | args = prs.parse_args() 25 | 26 | db_url = args.db_url 27 | if not db_url: 28 | try: 29 | config = load_env_config() 30 | except Exception: 31 | print('!! no db_url specified and could not load config file') 32 | raise 33 | else: 34 | db_url = config.get('db_url') 35 | 36 | check_schema(db_url=db_url, 37 | base_type=Base, 38 | echo=args.verbose, 39 | autoexit=True) 40 | 41 | return 42 | 43 | 44 | if __name__ == '__main__': 45 | main() 46 | -------------------------------------------------------------------------------- /montage/tests/test_loaders.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function 4 | 5 | from __future__ import absolute_import 6 | from pytest import raises 7 | 8 | from montage.loaders import get_entries_from_gsheet 9 | 10 | RESULTS = 'https://docs.google.com/spreadsheets/d/1RDlpT23SV_JB1mIz0OA-iuc3MNdNVLbaK_LtWAC7vzg/edit?usp=sharing' 11 | FILENAME_LIST = 'https://docs.google.com/spreadsheets/d/1Nqj-JsX3L5qLp5ITTAcAFYouglbs5OpnFwP6zSFpa0M/edit?usp=sharing' 12 | GENERIC_CSV = 'https://docs.google.com/spreadsheets/d/1WzHFg_bhvNthRMwNmxnk010KJ8fwuyCrby29MvHUzH8/edit#gid=550467819' 13 | FORBIDDEN_SHEET = 'https://docs.google.com/spreadsheets/d/1tza92brMKkZBTykw3iS6X9ij1D4_kvIYAiUlq1Yi7Fs/edit' 14 | 15 | def test_load_results(): 16 | imgs, warnings = get_entries_from_gsheet(RESULTS, source='remote') 17 | assert len(imgs) == 331 18 | 19 | def test_load_filenames(): 20 | imgs, warnings = get_entries_from_gsheet(FILENAME_LIST, source='remote') 21 | assert len(imgs) == 89 22 | 23 | def test_load_csv(): 24 | imgs, warnings = get_entries_from_gsheet(GENERIC_CSV, source='remote') 25 | assert len(imgs) == 93 26 | 27 | def test_no_persmission(): 28 | with raises(ValueError): 29 | imgs, warnings = get_entries_from_gsheet(FORBIDDEN_SHEET, source='remote') 30 | -------------------------------------------------------------------------------- /montage/log.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import absolute_import 3 | import os 4 | 5 | from lithoxyl import (Logger, 6 | StreamEmitter, 7 | SensibleSink, 8 | SensibleFilter, 9 | SensibleFormatter) 10 | 11 | from lithoxyl.sinks import DevDebugSink 12 | 13 | # import lithoxyl; lithoxyl.get_context().enable_async() 14 | 15 | script_log = Logger('dev_script') 16 | 17 | fmt = ('{status_char}+{import_delta_s}' 18 | ' - {duration_ms:>8.3f}ms' 19 | ' - {parent_depth_indent}{end_message}') 20 | 21 | begin_fmt = ('{status_char}+{import_delta_s}' 22 | ' --------------' 23 | ' {parent_depth_indent}{begin_message}') 24 | 25 | stderr_fmtr = SensibleFormatter(fmt, 26 | begin=begin_fmt) 27 | stderr_emtr = StreamEmitter('stderr') 28 | stderr_filter = SensibleFilter(success='info', 29 | failure='debug', 30 | exception='debug') 31 | stderr_sink = SensibleSink(formatter=stderr_fmtr, 32 | emitter=stderr_emtr, 33 | filters=[stderr_filter]) 34 | script_log.add_sink(stderr_sink) 35 | 36 | dds = DevDebugSink(post_mortem=bool(os.getenv('ENABLE_PDB'))) 37 | script_log.add_sink(dds) 38 | -------------------------------------------------------------------------------- /frontend/src/assets/main.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | font-family: 'Lato', sans-serif; 6 | } 7 | 8 | .greyed { 9 | color: #8c8c8c; 10 | } 11 | 12 | .icon-small { 13 | font-size: 6px !important; 14 | } 15 | 16 | .key { 17 | display: inline-block; 18 | margin: 0 0.1em; 19 | width: 18px; 20 | line-height: 18px; 21 | height: 18px; 22 | 23 | text-align: center; 24 | color: darkgray; 25 | background: white; 26 | font-size: 11px; 27 | border-radius: 3px; 28 | text-shadow: 0 1px 0 white; 29 | white-space: nowrap; 30 | border: 1px solid grey; 31 | 32 | -moz-box-shadow: 33 | 0 1px 0px rgba(0, 0, 0, 0.2), 34 | 0 0 0 2px #fff inset; 35 | -webkit-box-shadow: 36 | 0 1px 0px rgba(0, 0, 0, 0.2), 37 | 0 0 0 2px #fff inset; 38 | box-shadow: 39 | 0 1px 0px rgba(0, 0, 0, 0.2), 40 | 0 0 0 2px #fff inset; 41 | } 42 | 43 | .juror-campaign-accordion summary:focus { 44 | border-color: white !important; 45 | box-shadow: none !important; 46 | } 47 | 48 | .information-card .cdx-card__text { 49 | width: 100%; 50 | } 51 | 52 | .info-accordion summary { 53 | padding-left: 0 !important; 54 | } 55 | 56 | .date-time-inputs .cdx-label { 57 | font-size: 14px !important; 58 | color: gray !important; 59 | } 60 | 61 | .cdx-select-vue__handle { 62 | min-width: 120px; 63 | } -------------------------------------------------------------------------------- /frontend/src/components/AppFooter.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 25 | 26 | 57 | -------------------------------------------------------------------------------- /montage/server.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """x Logging in 4 | - Health check 5 | - Coordinators 6 | x See a list of campaigns 7 | - Save edits to a campaign 8 | x See a list of rounds per campaign 9 | - Save edits to a round 10 | - Import photos for a round 11 | - Close out a round 12 | - Export the output from a round 13 | - Send notifications to coordinators & jurors (?) 14 | - Jurors 15 | x See a list of campaigns and rounds 16 | x See the next vote 17 | x Submit a vote 18 | x Skip a vote 19 | - Expoert their own votes (?) 20 | - Change a vote for an open round (?) 21 | 22 | Practical design: 23 | 24 | Because we're building on angular, most URLs return JSON, except for 25 | login and complete_login, which give back redirects, and the root 26 | page, which gives back the HTML basis. 27 | 28 | # A bit of TBI design 29 | 30 | We add privileged Users (with coordinator flag enabled). Coordinators 31 | can create Campaigns, and see and interact only with Campaigns they've 32 | created or been added to. Can Coordinators create other Coordinators? 33 | 34 | """ 35 | from __future__ import absolute_import 36 | from .app import create_app 37 | from .utils import get_env_name 38 | 39 | # TODO: don't forget to update the app.py one level above on toolforge 40 | 41 | 42 | if __name__ == '__main__': 43 | env_name = get_env_name() 44 | app = create_app(env_name=env_name) 45 | app.serve() 46 | -------------------------------------------------------------------------------- /montage/templates/docs/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {title} - Montage Docs 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |
14 | 17 |

Montage: {title}

18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |

26 |

{?body}{body|s}{:else}Nothing to see here!{/body}

27 |

28 |
29 |
30 |
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /frontend/src/components/UserAvatarWithName.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 29 | 30 | 58 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "montage-frontend", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "toolforge:build": "export SHELL=/bin/sh && npm run build && cp -r dist/* ../montage/static", 11 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore", 12 | "format": "prettier --write src/" 13 | }, 14 | "dependencies": { 15 | "@wikimedia/codex": "^1.14.0", 16 | "@wikimedia/codex-icons": "^1.14.0", 17 | "axios": "^1.12.0", 18 | "dayjs": "^1.11.13", 19 | "iso-639-1": "^3.1.3", 20 | "lodash": "^4.17.21", 21 | "pinia": "^2.1.7", 22 | "vue": "^3.4.27", 23 | "vue-datepicker-next": "^1.0.3", 24 | "vue-draggable-next": "^2.2.1", 25 | "vue-i18n": "^10.0.8", 26 | "vue-material-design-icons": "^5.3.0", 27 | "vue-router": "^4.3.3", 28 | "vue-spinner": "^1.0.4", 29 | "vue-toastification": "^2.0.0-rc.5", 30 | "vuedraggable": "^2.24.3", 31 | "zod": "^3.23.8" 32 | }, 33 | "devDependencies": { 34 | "@rushstack/eslint-patch": "^1.8.0", 35 | "@vitejs/plugin-vue": "^5.2.3", 36 | "@vue/eslint-config-prettier": "^9.0.0", 37 | "chokidar-cli": "^3.0.0", 38 | "cypress": "^14.5.3", 39 | "eslint": "^8.57.0", 40 | "eslint-plugin-vue": "^9.23.0", 41 | "prettier": "^3.2.5", 42 | "vite": "^6.3.6" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Stephen LaPorte, Mahmoud Hashemi, Yuvi Panda, and Pawel Marynowski 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /frontend/src/components/UserList.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 52 | -------------------------------------------------------------------------------- /frontend/src/components/Vote/Vote.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 37 | 38 | -------------------------------------------------------------------------------- /frontend/src/services/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { useLoadingStore } from '@/stores/loading' 3 | 4 | // Create Axios instance for Backend API 5 | const apiBackend = axios.create({ 6 | baseURL: import.meta.env.VITE_API_ENDPOINT + '/v1/', 7 | headers: { 8 | 'Content-Type': 'application/json', 9 | Accept: 'application/json' 10 | }, 11 | withCredentials: true 12 | }) 13 | 14 | // Create Axios instance for Commons API 15 | const apiCommons = axios.create({ 16 | baseURL: 'https://commons.wikimedia.org/w/api.php', 17 | headers: { 18 | 'Content-Type': 'application/json', 19 | Accept: 'application/json' 20 | } 21 | }) 22 | 23 | const addInterceptors = (instance) => { 24 | instance.interceptors.request.use( 25 | (config) => { 26 | const loadingStore = useLoadingStore() 27 | loadingStore.setLoading(true) 28 | 29 | return config 30 | }, 31 | (error) => { 32 | const loadingStore = useLoadingStore() 33 | loadingStore.setLoading(false) 34 | 35 | return Promise.reject(error) 36 | } 37 | ) 38 | 39 | // Response Interceptor 40 | instance.interceptors.response.use( 41 | (response) => { 42 | const loadingStore = useLoadingStore() 43 | loadingStore.setLoading(false) 44 | 45 | return response['data'] 46 | }, 47 | (error) => { 48 | const loadingStore = useLoadingStore() 49 | loadingStore.setLoading(false) 50 | 51 | return Promise.reject(error) 52 | } 53 | ) 54 | } 55 | 56 | addInterceptors(apiBackend) 57 | addInterceptors(apiCommons) 58 | 59 | export { apiBackend, apiCommons } 60 | -------------------------------------------------------------------------------- /tools/create_schema.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import print_function 3 | from __future__ import absolute_import 4 | import pdb 5 | import sys 6 | import os.path 7 | import argparse 8 | 9 | CUR_PATH = os.path.dirname(os.path.abspath(__file__)) 10 | PROJ_PATH = os.path.dirname(CUR_PATH) 11 | 12 | sys.path.append(PROJ_PATH) 13 | 14 | from sqlalchemy import create_engine 15 | 16 | from montage.rdb import Base 17 | from montage.utils import load_env_config 18 | 19 | 20 | def create_schema(db_url, echo=True): 21 | 22 | # echo="debug" also prints results of selects, etc. 23 | engine = create_engine(db_url, echo=echo) 24 | Base.metadata.create_all(engine) 25 | 26 | return 27 | 28 | 29 | def main(): 30 | prs = argparse.ArgumentParser('create montage db and load initial data') 31 | add_arg = prs.add_argument 32 | add_arg('--db_url') 33 | add_arg('--debug', action="store_true", default=False) 34 | add_arg('--verbose', action="store_true", default=False) 35 | 36 | args = prs.parse_args() 37 | 38 | db_url = args.db_url 39 | if not db_url: 40 | try: 41 | config = load_env_config() 42 | except Exception: 43 | print('!! no db_url specified and could not load config file') 44 | raise 45 | else: 46 | db_url = config.get('db_url') 47 | 48 | try: 49 | create_schema(db_url=db_url, echo=args.verbose) 50 | except Exception: 51 | if not args.debug: 52 | raise 53 | pdb.post_mortem() 54 | else: 55 | print('++ schema created') 56 | 57 | return 58 | 59 | 60 | if __name__ == '__main__': 61 | main() 62 | -------------------------------------------------------------------------------- /frontend/src/components/AddOrganizer.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 64 | 78 | -------------------------------------------------------------------------------- /frontend/src/components/LoginBox.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 37 | 38 | 75 | -------------------------------------------------------------------------------- /frontend/src/router/index.js: -------------------------------------------------------------------------------- 1 | import '@wikimedia/codex/dist/codex.style.css' 2 | import '@/assets/main.css' 3 | 4 | import { createRouter, createWebHashHistory } from 'vue-router' 5 | import { useUserStore } from '@/stores/user' 6 | 7 | import HomeView from '../views/HomeView.vue' 8 | import NewCampaignView from '@/views/NewCampaignView.vue' 9 | import CampaignView from '@/views/CampaignView.vue' 10 | import VoteView from '@/views/VoteView.vue' 11 | import VoteEditView from '@/views/VoteEditView.vue' 12 | import AllCampaignView from '@/views/AllCampaignView.vue' 13 | import PermissionDenied from '@/views/PermissionDenied.vue' 14 | 15 | const routes = [ 16 | { 17 | path: '/', 18 | name: 'home', 19 | component: HomeView 20 | }, 21 | { 22 | path: '/campaign/all', 23 | name: 'campaign-all', 24 | component: AllCampaignView, 25 | meta: { requiresAuth: true } 26 | }, 27 | { 28 | path: '/campaign/new', 29 | name: 'new-campaign', 30 | component: NewCampaignView, 31 | meta: { requiresAuth: true } 32 | }, 33 | { 34 | path: '/campaign/:id', 35 | name: 'campaign', 36 | component: CampaignView, 37 | meta: { requiresAuth: true } 38 | }, 39 | { 40 | path: '/vote/:id', 41 | name: 'vote', 42 | component: VoteView, 43 | meta: { requiresAuth: true } 44 | }, 45 | { 46 | path: '/vote/:id/edit', 47 | name: 'vote-edit', 48 | component: VoteEditView, 49 | meta: { requiresAuth: true } 50 | }, 51 | { 52 | path: '/permission-denied', 53 | name: 'permission-denied', 54 | component: PermissionDenied, 55 | } 56 | ] 57 | 58 | const router = createRouter({ 59 | history: createWebHashHistory(import.meta.env.BASE_URL), 60 | routes 61 | }) 62 | 63 | router.beforeEach(async (to, from, next) => { 64 | const userStore = useUserStore() 65 | 66 | if (!userStore.authChecked) { 67 | await userStore.checkAuth() 68 | } 69 | 70 | if (to.meta.requiresAuth && userStore.user === null) { 71 | return next({ name: 'home' }) 72 | } 73 | 74 | next() 75 | }) 76 | 77 | export default router 78 | -------------------------------------------------------------------------------- /frontend/src/services/dataService.js: -------------------------------------------------------------------------------- 1 | import { apiCommons } from './api' 2 | 3 | const dataService = { 4 | async getImageInfo(images) { 5 | const parts = Math.ceil(images.length / 50) 6 | let promises = [] 7 | 8 | for (let i = 0; i < parts; i++) { 9 | const part = images.slice(50 * i, 50 * i + 50) 10 | promises.push( 11 | apiCommons({ 12 | method: 'GET', 13 | params: { 14 | action: 'query', 15 | prop: 'imageinfo', 16 | titles: part.map((image) => 'File:' + image).join('|'), 17 | redirects: '1', 18 | format: 'json', 19 | iiprop: 'timestamp|user|userid|size|dimensions|url', 20 | iilimit: '10', 21 | origin: '*' 22 | } 23 | }) 24 | ) 25 | } 26 | 27 | try { 28 | return await Promise.all(promises) 29 | } catch (error) { 30 | console.error('Error fetching image info:', error) 31 | throw error 32 | } 33 | }, 34 | 35 | async searchUser(username) { 36 | try { 37 | const response = await apiCommons({ 38 | method: 'GET', 39 | params: { 40 | action: 'query', 41 | list: 'globalallusers', 42 | format: 'json', 43 | rawcontinue: 'true', 44 | agufrom: username, 45 | origin: '*' 46 | } 47 | }) 48 | return response 49 | } catch (error) { 50 | console.error('Error searching for user:', error) 51 | throw error 52 | } 53 | }, 54 | 55 | async searchCategory(category) { 56 | try { 57 | const response = await apiCommons({ 58 | method: 'GET', 59 | params: { 60 | action: 'opensearch', 61 | format: 'json', 62 | namespace: '14', 63 | limit: '10', 64 | search: category, 65 | origin: '*' 66 | } 67 | }) 68 | return response 69 | } catch (error) { 70 | console.error('Error searching for category:', error) 71 | throw error 72 | } 73 | } 74 | } 75 | 76 | export default dataService 77 | -------------------------------------------------------------------------------- /montage/imgutils.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import absolute_import 3 | 4 | import hashlib 5 | 6 | import six.moves.urllib.parse, six.moves.urllib.error 7 | from .utils import unicode 8 | 9 | """ 10 | https://upload.wikimedia.org/wikipedia/commons/8/8e/%D9%86%D9%82%D8%B4_%D8%A8%D8%B1%D8%AC%D8%B3%D8%AA%D9%87_%D8%A8%D9%84%D8%A7%D8%B4_2.JPG 11 | 12 | https://upload.wikimedia.org/wikipedia/commons/thumb/8/8e/%D9%86%D9%82%D8%B4_%D8%A8%D8%B1%D8%AC%D8%B3%D8%AA%D9%87_%D8%A8%D9%84%D8%A7%D8%B4_2.JPG/360px-%D9%86%D9%82%D8%B4_%D8%A8%D8%B1%D8%AC%D8%B3%D8%AA%D9%87_%D8%A8%D9%84%D8%A7%D8%B4_2.JPG 13 | """ 14 | 15 | BASE = u'https://upload.wikimedia.org/wikipedia/commons' 16 | 17 | 18 | def make_mw_img_url(title, size=None): 19 | """Returns a unicode object URL that handles file moves/renames on Wikimedia Commons. 20 | 21 | Uses Special:Redirect which works for all file types including TIFF, 22 | handles redirects for moved files, and performs automatic format conversion. 23 | """ 24 | if isinstance(title, unicode): 25 | url_title = six.moves.urllib.parse.quote(title.encode('utf8')) 26 | elif isinstance(title, bytes): 27 | url_title = six.moves.urllib.parse.quote(title) 28 | else: 29 | raise TypeError('image title must be bytes or unicode') 30 | 31 | if size is None or str(size).lower() == 'orig': 32 | # No size parameter = full size, use Special:Redirect without width 33 | return u'https://commons.wikimedia.org/w/index.php?title=Special:Redirect/file/%s' % url_title 34 | elif isinstance(size, int): 35 | width = size 36 | elif str(size).lower().startswith('sm'): 37 | width = 240 38 | elif str(size).lower().startswith('med'): 39 | width = 480 40 | else: 41 | raise ValueError('size expected one of "sm", "med", "orig",' 42 | ' or an integer pixel value, not %r' % size) 43 | 44 | # Use Special:Redirect with width parameter - works universally for all formats 45 | return u'https://commons.wikimedia.org/w/index.php?title=Special:Redirect/file/%s&width=%d' % (url_title, width) 46 | -------------------------------------------------------------------------------- /tools/drop_schema.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import print_function 3 | from __future__ import absolute_import 4 | import pdb 5 | import sys 6 | import time 7 | import os.path 8 | import argparse 9 | 10 | CUR_PATH = os.path.dirname(os.path.abspath(__file__)) 11 | PROJ_PATH = os.path.dirname(CUR_PATH) 12 | 13 | sys.path.append(PROJ_PATH) 14 | 15 | from sqlalchemy import MetaData, create_engine 16 | 17 | # from montage.rdb import Base # might not drop tables which were removed 18 | from montage.utils import load_env_config 19 | 20 | 21 | def drop_schema(db_url, echo=True): 22 | 23 | # echo="debug" also prints results of selects, etc. 24 | engine = create_engine(db_url, echo=echo) 25 | metadata = MetaData() 26 | metadata.reflect(bind=engine) 27 | metadata.drop_all(engine) 28 | 29 | return 30 | 31 | 32 | def main(): 33 | prs = argparse.ArgumentParser('drop montage db') 34 | add_arg = prs.add_argument 35 | add_arg('--db_url') 36 | add_arg('--debug', action="store_true", default=False) 37 | add_arg('--force', action="store_true", default=False) 38 | add_arg('--verbose', action="store_true", default=False) 39 | 40 | args = prs.parse_args() 41 | 42 | db_url = args.db_url 43 | if not db_url: 44 | try: 45 | config = load_env_config() 46 | except Exception: 47 | print('!! no db_url specified and could not load config file') 48 | raise 49 | else: 50 | db_url = config.get('db_url') 51 | 52 | if not args.force: 53 | confirmed = input('?? this will drop all tables from %r.' 54 | ' type yes to confirm: ' % db_url) 55 | if not confirmed == 'yes': 56 | print('-- you typed %r, aborting' % confirmed) 57 | sys.exit(0) 58 | 59 | print('.. dropping all tables in %r in:' % db_url) 60 | time.sleep(1.2) 61 | for x in range(3, 0, -1): 62 | print('.. ', x) 63 | time.sleep(0.85) 64 | 65 | try: 66 | drop_schema(db_url=db_url, echo=args.verbose) 67 | except Exception: 68 | if not args.debug: 69 | raise 70 | pdb.post_mortem() 71 | else: 72 | print('++ schema dropped') 73 | 74 | 75 | return 76 | 77 | 78 | if __name__ == '__main__': 79 | main() 80 | -------------------------------------------------------------------------------- /montage/static/mdl/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "material-design-lite", 3 | "version": "1.3.0", 4 | "description": "Material Design Components in CSS, JS and HTML", 5 | "private": true, 6 | "license": "Apache-2.0", 7 | "author": "Google", 8 | "repository": "google/material-design-lite", 9 | "main": "dist/material.min.js", 10 | "devDependencies": { 11 | "acorn": "^4.0.3", 12 | "babel-core": "^6.20.0", 13 | "babel-preset-es2015": "^6.18.0", 14 | "browser-sync": "^2.2.3", 15 | "chai": "^3.3.0", 16 | "chai-jquery": "^2.0.0", 17 | "del": "^2.0.2", 18 | "drool": "^0.4.0", 19 | "escodegen": "^1.6.1", 20 | "google-closure-compiler": "", 21 | "gulp": "^3.9.0", 22 | "gulp-autoprefixer": "^3.0.2", 23 | "gulp-cache": "^0.4.5", 24 | "gulp-closure-compiler": "^0.4.0", 25 | "gulp-concat": "^2.4.1", 26 | "gulp-connect": "^5.0.0", 27 | "gulp-css-inline-images": "^0.1.1", 28 | "gulp-csso": "1.0.0", 29 | "gulp-file": "^0.3.0", 30 | "gulp-flatten": "^0.3.1", 31 | "gulp-front-matter": "^1.2.2", 32 | "gulp-header": "^1.2.2", 33 | "gulp-if": "^2.0.0", 34 | "gulp-iife": "^0.3.0", 35 | "gulp-imagemin": "^3.1.0", 36 | "gulp-jscs": "^4.0.0", 37 | "gulp-jshint": "^2.0.4", 38 | "gulp-load-plugins": "^1.3.0", 39 | "gulp-marked": "^1.0.0", 40 | "gulp-mocha-phantomjs": "^0.12.0", 41 | "gulp-open": "^2.0.0", 42 | "gulp-rename": "^1.2.0", 43 | "gulp-replace": "^0.5.3", 44 | "gulp-sass": "3.0.0", 45 | "gulp-shell": "^0.5.2", 46 | "gulp-size": "^2.0.0", 47 | "gulp-sourcemaps": "^2.0.1", 48 | "gulp-subtree": "^0.1.0", 49 | "gulp-tap": "^0.1.3", 50 | "gulp-uglify": "^2.0.0", 51 | "gulp-util": "^3.0.4", 52 | "gulp-zip": "^3.0.2", 53 | "humanize": "0.0.9", 54 | "jquery": "^3.1.1", 55 | "jshint": "^2.9.4", 56 | "jshint-stylish": "^2.2.1", 57 | "merge-stream": "^1.0.0", 58 | "mocha": "^3.0.2", 59 | "prismjs": "1.30.0", 60 | "run-sequence": "^1.0.2", 61 | "swig": "^1.4.2", 62 | "through2": "^2.0.0", 63 | "vinyl-paths": "^2.0.0" 64 | }, 65 | "engines": { 66 | "node": ">=0.12.0" 67 | }, 68 | "scripts": { 69 | "test": "gulp && git status | grep 'working directory clean' >/dev/null || (echo 'Please commit all changes generated by building'; exit 1)" 70 | }, 71 | "babel": { 72 | "only": "gulpfile.babel.js", 73 | "presets": [ 74 | "es2015" 75 | ] 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /frontend/src/services/adminService.js: -------------------------------------------------------------------------------- 1 | import { apiBackend } from './api' 2 | 3 | const adminService = { 4 | get: () => apiBackend.get('admin'), 5 | 6 | getUser: () => apiBackend.get('admin/user'), 7 | 8 | allCampaigns: () => apiBackend.get('admin/campaigns/all'), 9 | 10 | getCampaign: (id) => apiBackend.get(`admin/campaign/${id}`), 11 | 12 | getRound: (id) => apiBackend.get(`admin/round/${id}`), 13 | 14 | getReviews: (id) => apiBackend.get(`admin/round/${id}/reviews`), 15 | 16 | addOrganizer: (data) => apiBackend.post('admin/add_organizer', data), 17 | 18 | addCampaign: (data) => apiBackend.post('admin/add_campaign', data), 19 | 20 | addRound: (id, data) => apiBackend.post(`admin/campaign/${id}/add_round`, data), 21 | 22 | finalizeCampaign: (id) => apiBackend.post(`admin/campaign/${id}/finalize`, { post: true }), 23 | 24 | addCoordinator: (id, username) => 25 | apiBackend.post(`admin/campaign/${id}/add_coordinator`, { username }), 26 | 27 | removeCoordinator: (id, username) => 28 | apiBackend.post(`admin/campaign/${id}/remove_coordinator`, { username }), 29 | 30 | activateRound: (id) => apiBackend.post(`admin/round/${id}/activate`, { post: true }), 31 | 32 | pauseRound: (id) => apiBackend.post(`admin/round/${id}/pause`, { post: true }), 33 | 34 | populateRound: (id, data) => apiBackend.post(`admin/round/${id}/import`, data), 35 | 36 | editCampaign: (id, data) => apiBackend.post(`admin/campaign/${id}/edit`, data), 37 | 38 | editRound: (id, data) => apiBackend.post(`admin/round/${id}/edit`, data), 39 | 40 | cancelRound: (id) => apiBackend.post(`admin/round/${id}/cancel`), 41 | 42 | getRoundFlags: (id) => apiBackend.get(`admin/round/${id}/flags`), 43 | 44 | getRoundReviews: (id) => apiBackend.get(`admin/round/${id}/reviews`), 45 | 46 | getRoundVotes: (id) => apiBackend.get(`admin/round/${id}/votes`), 47 | 48 | previewRound: (id) => apiBackend.get(`admin/round/${id}/preview_results`), 49 | 50 | advanceRound: (id, data) => apiBackend.post(`admin/round/${id}/advance`, data), 51 | 52 | finalizeRound: (id) => apiBackend.post(`/admin/round/${id}/finalize`), 53 | 54 | // Direct download URLs (manual baseURL needed) 55 | downloadRound: (id) => `${apiBackend.defaults.baseURL}admin/round/${id}/results/download`, 56 | downloadEntries: (id) => `${apiBackend.defaults.baseURL}admin/round/${id}/entries/download`, 57 | downloadReviews: (id) => `${apiBackend.defaults.baseURL}admin/round/${id}/reviews` 58 | } 59 | 60 | export default adminService 61 | -------------------------------------------------------------------------------- /frontend/src/utils.js: -------------------------------------------------------------------------------- 1 | export function formatDate(dateString) { 2 | const options = { year: 'numeric', month: 'short', day: 'numeric' } 3 | return new Date(dateString).toLocaleDateString('en-US', options) 4 | } 5 | 6 | export function getVotingName(voting) { 7 | const types = { 8 | "yesno": "montage-round-yesno", 9 | "rating": "montage-round-rating", 10 | "ranking": "montage-round-ranking", 11 | } 12 | 13 | return types[voting] 14 | } 15 | 16 | export function getAvatarColor(username) { 17 | const colors = [ 18 | '#1abc9c', 19 | '#2ecc71', 20 | '#3498db', 21 | '#9b59b6', 22 | '#34495e', 23 | '#16a085', 24 | '#27ae60', 25 | '#2980b9', 26 | '#8e44ad', 27 | '#2c3e50', 28 | '#f1c40f', 29 | '#e67e22', 30 | '#e74c3c', 31 | '#95a5a6', 32 | '#f39c12', 33 | '#d35400', 34 | '#c0392b', 35 | '#bdc3c7', 36 | '#7f8c8d' 37 | ] 38 | 39 | const sum = stringToColor(username) 40 | const color = colors[sum % colors.length] 41 | const rgba = hexToRgba(color, 0.5) 42 | 43 | return rgba 44 | } 45 | 46 | function stringToColor(str) { 47 | let hash = 0 48 | for (let char of str) { 49 | hash = char.charCodeAt(0) + ((hash << 5) - hash) 50 | } 51 | return Math.abs(hash % 19) 52 | } 53 | 54 | function hexToRgba(hex, alpha) { 55 | const r = parseInt(cutHex(hex).substring(0, 2), 16) 56 | const g = parseInt(cutHex(hex).substring(2, 4), 16) 57 | const b = parseInt(cutHex(hex).substring(4, 6), 16) 58 | return `rgba(${r}, ${g}, ${b}, ${alpha})` 59 | } 60 | 61 | function cutHex(h) { 62 | return h.startsWith('#') ? h.substring(1, 7) : h 63 | } 64 | 65 | export function getCommonsImageUrl(image, width = 1280) { 66 | if (!image) return null 67 | 68 | // Handle different data structures: 69 | // - image.entry.name (task/vote object with nested entry) 70 | // - image.name (direct entry object or task with top-level name) 71 | // - image (string filename) 72 | const imageName = image.entry?.name || image.name || image 73 | const encodedName = encodeURIComponent(imageName) 74 | 75 | // Use Special:Redirect which works universally for all file types including TIFF 76 | // It handles redirects for moved files and performs automatic format conversion 77 | if (width) { 78 | return `//commons.wikimedia.org/w/index.php?title=Special:Redirect/file/${encodedName}&width=${width}` 79 | } else { 80 | return `//commons.wikimedia.org/w/index.php?title=Special:Redirect/file/${encodedName}` 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /frontend/src/components/CommonsImage.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 90 | 91 | -------------------------------------------------------------------------------- /frontend/src/services/dialogService.js: -------------------------------------------------------------------------------- 1 | import { ref, defineComponent, h, render, getCurrentInstance } from 'vue' 2 | import { CdxDialog } from '@wikimedia/codex' 3 | 4 | const dialogService = () => { 5 | const open = ref(false) 6 | const dialogConfig = ref({}) 7 | let dialogInstance = null 8 | 9 | const show = (config) => { 10 | dialogConfig.value = config 11 | open.value = true 12 | } 13 | 14 | const DialogComponent = defineComponent({ 15 | setup() { 16 | const { appContext } = getCurrentInstance() 17 | 18 | const onPrimaryAction = () => { 19 | open.value = false 20 | 21 | if (dialogInstance?.saveImageData) { 22 | dialogInstance.saveImageData() 23 | } 24 | 25 | if (dialogConfig.value.onPrimary) { 26 | dialogConfig.value.onPrimary() 27 | } 28 | } 29 | 30 | const onDefaultAction = () => { 31 | open.value = false 32 | if (dialogConfig.value.onDefault) { 33 | dialogConfig.value.onDefault() 34 | } 35 | } 36 | 37 | return () => 38 | h( 39 | CdxDialog, 40 | { 41 | appContext: appContext, 42 | open: open.value, 43 | 'onUpdate:open': (value) => (open.value = value), 44 | title: dialogConfig.value.title, 45 | useCloseButton: true, 46 | primaryAction: dialogConfig.value.primaryAction, 47 | defaultAction: dialogConfig.value.defaultAction, 48 | onPrimary: onPrimaryAction, 49 | onDefault: onDefaultAction, 50 | style: dialogConfig.value.maxWidth ? { 'max-width': dialogConfig.value.maxWidth } : {} 51 | }, 52 | { 53 | default: () => { 54 | if (typeof dialogConfig.value.content === 'string') { 55 | return h('div', { innerHTML: dialogConfig.value.content }) 56 | } else if (dialogConfig.value.content) { 57 | return h(dialogConfig.value.content, { 58 | ...dialogConfig.value.props, 59 | ref: (el) => (dialogInstance = el) 60 | }) 61 | } 62 | return null 63 | } 64 | } 65 | ) 66 | } 67 | }) 68 | 69 | const mountDialog = () => { 70 | const container = document.createElement('div') 71 | document.body.appendChild(container) 72 | render(h(DialogComponent), container) 73 | } 74 | 75 | mountDialog() 76 | 77 | return { 78 | show 79 | } 80 | } 81 | 82 | export default dialogService 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *.db 3 | config*yaml 4 | .DS_Store 5 | *.log 6 | 7 | # Compiled front-end 8 | montage/static/ 9 | 10 | # emacs 11 | *~ 12 | ._* 13 | .\#* 14 | \#*\# 15 | 16 | 17 | # Byte-compiled / optimized / DLL files 18 | __pycache__/ 19 | *.py[cod] 20 | *$py.class 21 | 22 | # C extensions 23 | *.so 24 | 25 | # Distribution / packaging 26 | .Python 27 | env/ 28 | build/ 29 | develop-eggs/ 30 | dist/ 31 | downloads/ 32 | eggs/ 33 | .eggs/ 34 | lib/ 35 | lib64/ 36 | parts/ 37 | sdist/ 38 | var/ 39 | *.egg-info/ 40 | .installed.cfg 41 | *.egg 42 | 43 | # PyInstaller 44 | # Usually these files are written by a python script from a template 45 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 46 | *.manifest 47 | *.spec 48 | 49 | # Installer logs 50 | pip-log.txt 51 | pip-delete-this-directory.txt 52 | 53 | # Unit test / coverage reports 54 | htmlcov/ 55 | .tox/ 56 | .coverage 57 | .coverage.* 58 | .cache 59 | nosetests.xml 60 | coverage.xml 61 | *,cover 62 | .hypothesis/ 63 | 64 | # Translations 65 | *.mo 66 | *.pot 67 | 68 | # Django stuff: 69 | *.log 70 | local_settings.py 71 | 72 | # Flask stuff: 73 | instance/ 74 | .webassets-cache 75 | 76 | # Scrapy stuff: 77 | .scrapy 78 | 79 | # Sphinx documentation 80 | docs/_build/ 81 | 82 | # PyBuilder 83 | target/ 84 | 85 | # IPython Notebook 86 | .ipynb_checkpoints 87 | 88 | # pyenv 89 | .python-version 90 | 91 | # celery beat schedule file 92 | celerybeat-schedule 93 | 94 | # dotenv 95 | .env 96 | 97 | # virtualenv 98 | venv/ 99 | ENV/ 100 | 101 | # Logs 102 | logs 103 | *.log 104 | npm-debug.log* 105 | yarn-debug.log* 106 | yarn-error.log* 107 | pnpm-debug.log* 108 | lerna-debug.log* 109 | 110 | node_modules 111 | .DS_Store 112 | dist 113 | dist-ssr 114 | coverage 115 | *.local 116 | 117 | /cypress/videos/ 118 | /cypress/screenshots/ 119 | 120 | # Editor directories and files 121 | .vscode/* 122 | !.vscode/extensions.json 123 | .idea 124 | *.suo 125 | *.ntvs* 126 | *.njsproj 127 | *.sln 128 | *.sw? 129 | 130 | *.tsbuildinfo 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # PyCharm stuff: 139 | .idea/ 140 | 141 | # Ignore Cypress example tests 142 | cypress/e2e/1-getting-started/ 143 | cypress/e2e/2-advanced-examples/ 144 | 145 | # Ignore default fixtures unless used 146 | cypress/fixtures/example.json 147 | 148 | # Ignore screenshots & videos from Cypress runs 149 | cypress/screenshots/ 150 | cypress/videos/ 151 | 152 | .vscode/ -------------------------------------------------------------------------------- /frontend/src/i18n/pa.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "Cabal", 5 | "Kuldeepburjbhalaike" 6 | ] 7 | }, 8 | "montage-about": "ਬਾਬਤ", 9 | "montage-source-code": "ਸਰੋਤ ਕੋਡ", 10 | "montage-login-heading": "ਕਿਰਪਾ ਕਰਕੇ ਦਾਖ਼ਲ ਹੋਵੋ", 11 | "montage-login-button": "ਵਿਕੀਮੀਡੀਆ ਖਾਤੇ ਦੀ ਵਰਤੋਂ ਕਰਕੇ ਦਾਖ਼ਲ ਹੋਵੋ", 12 | "montage-login-logout": "ਬਾਹਰ ਆਉ", 13 | "montage-or": "ਜਾਂ", 14 | "montage-view-all": "ਸਾਰੀਆਂ ਮੁਹਿੰਮਾਂ ਅਤੇ ਗੇਡ਼ੇ ਵੇਖੋ।", 15 | "montage-active-voting-round": "ਸਰਗਰਮ ਚੋਣ ਗੇਡ਼ੇ", 16 | "montage-voting-deadline": "ਚੋਣਾਂ ਦੀ ਆਖਰੀ ਮਿਤੀ", 17 | "montage-your-progress": "ਤੁਹਾਡੀ ਤਰੱਕੀ", 18 | "montage-vote": "ਵੋਟ ਪਾਓ", 19 | "montage-progress-status": "{1} ਵਿੱਚੋਂ {0}", 20 | "montage-label-open-date": "ਖੁੱਲਣ ਦੀ ਮਿਤੀ", 21 | "montage-required-open-date": "ਖੁੱਲਣ ਦੀ ਮਿਤੀ ਲੋੜੀਂਦੀ ਹੈ", 22 | "montage-label-open-time": "ਖੁੱਲਣ ਦਾ ਸਮਾਂ", 23 | "montage-label-close-time": "ਬੰਦ ਕਰਨ ਦਾ ਸਮਾਂ", 24 | "montage-something-went-wrong": "ਕੁਝ ਗਲਤ ਹੋ ਗਿਆ", 25 | "montage-btn-save": "ਸਾਂਭੋ", 26 | "montage-btn-cancel": "ਰੱਦ ਕਰੋ", 27 | "montage-archive": "ਪੁਰਾਲੇਖ", 28 | "montage-unarchive": "ਪੁਰਾਲੇਖ ਨਾ ਕਰੋ", 29 | "montage-round-open-time": "ਖੁੱਲਣ ਦਾ ਸਮਾਂ (UTC)", 30 | "montage-round-close-time": "ਬੰਦ ਕਰਨ ਦਾ ਸਮਾਂ (UTC)", 31 | "montage-round-min-resolution": "ਘੱਟੋ-ਘੱਟ ਬਿੰਦੀਆਂ ਦੀ ਨਿਸ਼ਚਿਤ ਖੇਤਰ ਵਿੱਚ ਗਿਨਤੀ", 32 | "montage-round-show-stats": "ਅੰਕੜੇ ਵਿਖਾਓ", 33 | "montage-round-vote-ending": "{0} ਦਿਨਾਂ ਵਿੱਚ", 34 | "montage-round-ranking": "ਦਰਜਾਬੰਦੀ", 35 | "montage-round-yesno": "ਹਾਂ/ਨਹੀਂ", 36 | "montage-round-pause": "ਰੋਕੋ", 37 | "montage-round-source": "ਸਰੋਤ", 38 | "montage-round-threshold": "ਹੱਦ", 39 | "montage-vote-commons-page": "ਕਾਮਨਜ਼ ਸਫ਼ਾ", 40 | "montage-vote-accept": "ਸਵੀਕਾਰ ਕਰੋ", 41 | "montage-vote-decline": "ਮਨਜ਼ੂਰ ਨਹੀਂ", 42 | "montage-vote-actions": "ਕਾਰਵਾਈਆਂ", 43 | "montage-vote-add-favorites": "ਮਨਪਸੰਦ ਵਿੱਚ ਸ਼ਾਮਲ ਕਰੋ", 44 | "montage-vote-remove-favorites": "ਮਨਪਸੰਦ ਵਿੱਚੋਂ ਹਟਾਓ", 45 | "montage-vote-skip": "ਛੱਡੋ (ਬਾਅਦ ਵਿੱਚ ਵੋਟ ਪਾਓ)", 46 | "montage-vote-description": "ਵੇਰਵਾ", 47 | "montage-vote-all-done": "ਸਭ ਕੁਝ ਹੋ ਗਿਆ!", 48 | "montage-vote-grid-size-medium": "ਦਰਮਿਆਨਾ", 49 | "montage-option-yes": "ਹਾਂ", 50 | "montage-option-no": "ਨਹੀਂ", 51 | "montage-btn-add": "ਜੋੜੋ", 52 | "montage-at-least-one-user": "ਕਿਰਪਾ ਕਰਕੇ ਘੱਟੋ-ਘੱਟ ਇੱਕ ਵਰਤੋਂਕਾਰ ਸ਼ਾਮਲ ਕਰੋ", 53 | "montage-only-one-user": "ਕਿਰਪਾ ਕਰਕੇ ਸਿਰਫ਼ ਇੱਕ ਵਰਤੋਂਕਾਰ ਸ਼ਾਮਲ ਕਰੋ", 54 | "montage-round-open-task-percentage": "ਖੁੱਲ੍ਹੇ ਕੰਮਾਂ ਦੀ ਪ੍ਰਤੀਸ਼ਤਤਾ", 55 | "montage-round-open-tasks": "ਖੁਲ੍ਹੇ ਕਾਰਜ", 56 | "montage-round-files": "ਫ਼ਾਈਲਾਂ", 57 | "montage-round-tasks": "ਕਾਰਜ", 58 | "permission-denied-title": "ਪ੍ਰਵਾਨਗੀ ਨਹੀਂ ਮਿਲੀ", 59 | "permission-denied-home": "ਘਰ 'ਤੇ ਜਾਓ", 60 | "montage-required-fill-inputs": "ਕਿਰਪਾ ਕਰਕੇ ਸਾਰੇ ਖਾਨੇ ਭਰੋ।" 61 | } 62 | -------------------------------------------------------------------------------- /PROJECT_LOG.md: -------------------------------------------------------------------------------- 1 | # Montage Project Log 2 | 3 | ## 2020-03-08 4 | 5 | Kicked off new FE for admins, based on Svelte. 6 | 7 | * View campaign list 8 | * View individual campaign 9 | * Create campaign 10 | 11 | ### Remaining items 12 | 13 | - [ ] Separate bundle.js and bundle.css 14 | - [ ] Babel backport to IE 11 perhaps: https://blog.az.sg/posts/svelte-and-ie11/ 15 | - [ ] Lodewijk says tell them to use the supported browser (coordinators at least) 16 | - [ ] Other rollup improvements: https://medium.com/@camille_hdl/rollup-based-dev-environment-for-javascript-part-1-eab8523c8ee6 17 | 18 | #### Svelte components 19 | 20 | - [ ] Sentry integration for the frontend 21 | - [ ] Refine campaign list 22 | - [ ] Link to active round on campaign list 23 | - [ ] Refine single campaign page (add a bit of round detail) 24 | - [ ] Edit functionality 25 | - [ ] Style? 26 | - [ ] Show jurors 27 | - [ ] Show description (?) 28 | - [ ] # of files (?) 29 | - [ ] Active/inactive styling 30 | - [ ] Refine round page 31 | - [ ] Edit functionality 32 | - [ ] Style? 33 | - [ ] Button to download all entries, all reviews, all votes (?) 34 | - [ ] If closed: Results summary 35 | - [ ] Should there be a summary of the campaign, somewhere on this page? 36 | - [ ] Create round 37 | - [ ] Create initial round 38 | - [ ] Advance round (same component as above) 39 | - [ ] Show campaigns by series 40 | - [ ] Add a column 41 | - [ ] Backfill series (take country into account) 42 | - [ ] Campaign opendate/closedate should be in UTC or AoE 43 | - [ ] Backend expects hour 44 | - [ ] Create view page 45 | - [ ] Create entry list page, re disqualify and requalify images 46 | - [ ] Need a paginated datatable component 47 | - [ ] User page (?) 48 | 49 | ## 2020-03-01 50 | 51 | * Made setup.py to make montage installable (not for pypi upload!) 52 | * Merged admin CLI changes (still need to integrate into package and make entrypoint) 53 | * Migrated system test into tox + pytest (in prep for more tests + py3 conversion) 54 | * Added coverage report (time of writing: 75%) 55 | * Read up on new toolforge setup, make sure to restart with: 56 | `webservice --cpu 1 --mem 4000Mi python2 restart` 57 | * requirements.in and requirements.txt working 58 | * Added CI and coverage 59 | * https://travis-ci.org/hatnote/montage 60 | * https://codecov.io/gh/hatnote/montage 61 | 62 | ## TODO 63 | 64 | ### 2020 Technical Roadmap 65 | 66 | * Admin tools refactor 67 | * Integrate admin tools into montage package 68 | * Make montage installable 69 | * Switch to in-process integration tests + unit tests instead of big 70 | system test. 71 | * Python 3 migration 72 | * Upgrade dependencies 73 | * Add tests + coverage 74 | * Update syntax 75 | * Migrate to k8s 76 | * Deploy script? 77 | * Sentry integration? 78 | * Dynamic assignment 79 | * Archiving? 80 | * Better dev docs 81 | -------------------------------------------------------------------------------- /frontend/src/components/AppHeader.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 64 | 65 | 102 | -------------------------------------------------------------------------------- /frontend/src/i18n/ko.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "Whatback11", 5 | "Ykhwong" 6 | ] 7 | }, 8 | "montage-about": "정보", 9 | "montage-source-code": "소스 코드", 10 | "montage-login-heading": "로그인해 주십시오", 11 | "montage-login-metawiki": "메타위키", 12 | "montage-login-logout": "로그아웃", 13 | "montage-all-campaigns": "모든 캠페인", 14 | "montage-coordinators": "관리자", 15 | "montage-new-campaig-heading": "새로운 캠페인", 16 | "montage-placeholder-campaign-name": "캠페인 이름", 17 | "montage-required-campaign-name": "캠페인 이름은 필수입니다", 18 | "montage-placeholder-campaign-url": "캠페인 URL", 19 | "montage-required-campaign-url": "캠페인 URL은 필수입니다", 20 | "montage-invalid-campaign-url": "유효하지 않은 URL", 21 | "montage-label-campaign-coordinators": "캠페인 코디네이터", 22 | "montage-campaign-added-success": "캠페인이 성공적으로 추가되었습니다", 23 | "montage-something-went-wrong": "무언가가 잘못되었습니다", 24 | "montage-btn-create-campaign": "캠페인 만들기", 25 | "montage-btn-save": "저장", 26 | "montage-btn-cancel": "취소", 27 | "montage-edit-campaign": "캠페인 편집", 28 | "montage-round-show-filename": "파일 이름 표시", 29 | "montage-round-show-link": "링크 표시", 30 | "montage-round-yesno": "예/아니요", 31 | "montage-round-file-type": "파일 유형", 32 | "montage-round-activate": "활성화", 33 | "montage-round-pause": "일시 정지", 34 | "montage-round-download-results": "결과 다운로드", 35 | "montage-round-source-category": "위키미디어 공용의 분류", 36 | "montage-round-source-csv": "파일 목록 URL", 37 | "montage-round-source-filelist": "파일 목록", 38 | "montage-round-category-placeholder": "분류 입력", 39 | "montage-round-category-label": "분류 입력", 40 | "montage-round-no-category": "분류가 없습니다.", 41 | "montage-round-file-url": "파일 URL 입력", 42 | "montage-round-file-list": "목록 (한 줄에 파일 한 개)", 43 | "montage-round-threshold": "한계치", 44 | "montage-round-threshold-default": "임계값 선택", 45 | "montage-no-results": "결과가 없습니다", 46 | "montage-vote-accept": "수락", 47 | "montage-vote-decline": "거부", 48 | "montage-vote-add-favorites": "즐겨찾기에 추가", 49 | "montage-vote-remove-favorites": "즐겨찾기에서 제거", 50 | "montage-vote-removed-favorites": "즐겨찾기에서 이미지를 제거했습니다", 51 | "montage-vote-description": "설명", 52 | "montage-vote-version": "버전", 53 | "montage-vote-all-done": "모두 완료했습니다!", 54 | "montage-vote-hide-panel": "패널 숨기기", 55 | "montage-vote-show-panel": "패널 표시", 56 | "montage-vote-image": "이미지", 57 | "montage-vote-image-review": "이미지 리뷰 #{0}", 58 | "montage-vote-order-by": "정렬 기준:", 59 | "montage-vote-gallery-size": "갤러리 크기", 60 | "montage-option-yes": "예", 61 | "montage-btn-add": "추가", 62 | "montage-at-least-one-user": "적어도 한 명의 사용자를 추가해 주십시오", 63 | "montage-round-cancelled-tasks": "취소된 작업", 64 | "montage-round-files": "파일", 65 | "montage-round-tasks": "작업", 66 | "montage-round-uploaders": "업로더", 67 | "permission-denied-title": "권한이 없습니다", 68 | "permission-denied-message": "이 문서에 접근하는 데 필요한 권한이 없습니다.", 69 | "permission-denied-home": "홈으로 이동", 70 | "montage-required-fill-inputs": "모든 상자를 채워주세요" 71 | } 72 | -------------------------------------------------------------------------------- /tools/_admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | from __future__ import absolute_import 3 | import os 4 | import sys 5 | 6 | import fire 7 | 8 | CUR_PATH = os.path.dirname(os.path.abspath(__file__)) 9 | PROJ_PATH = os.path.dirname(CUR_PATH) 10 | 11 | sys.path.append(PROJ_PATH) 12 | 13 | from montage.rdb import (make_rdb_session, 14 | UserDAO, 15 | MaintainerDAO, 16 | OrganizerDAO, 17 | CoordinatorDAO, 18 | lookup_user) 19 | 20 | 21 | """Generating a command line interface with Python Fire. 22 | 23 | 24 | It's (mostly) functional, but may be daunting if you're not familiar 25 | with the DAOs in ../rdb.py. I haven't tested the full campaign flow, 26 | so you may encounter a few operations that still can't be done via 27 | CLI. 28 | 29 | 30 | Usage: 31 | _admin.py user ... 32 | _admin.py maintainer ... 33 | _admin.py organizer ... 34 | _admin.py coordinator --round-id= ... 35 | OR 36 | _admin.py coordinator --campaign_id= ... 37 | 38 | (ignore the other options) 39 | 40 | Tip: try _admin.py -- --interactive if you want to explore 41 | or use the results in a REPL. 42 | 43 | It would be nice to decorate or list the functions that I want Fire 44 | to expose. There are a lot of internal functions that we can ignore. 45 | """ 46 | 47 | 48 | class AdminTool(object): 49 | 50 | def __init__(self, user='Slaporte', echo=False): 51 | rdb_session = make_rdb_session(echo=echo) 52 | self.rdb_session = rdb_session 53 | user = lookup_user(rdb_session, user) 54 | self.user_dao = UserDAO(rdb_session, user) 55 | self.maint_dao = MaintainerDAO(self.user_dao) 56 | self.org_dao = OrganizerDAO(self.user_dao) 57 | 58 | def commit(self, func): 59 | def wrapper_func(*args, **kwargs): 60 | retval = func(*args, **kwargs) 61 | self.rdb_session.commit() 62 | return retval 63 | return wrapper_func 64 | 65 | def add_commit(self, cls): 66 | for attr in cls.__dict__: 67 | if callable(getattr(cls, attr)): 68 | setattr(cls, attr, self.commit(getattr(cls, attr))) 69 | return cls 70 | 71 | def user(self): 72 | return self.add_commit(self.user_dao) 73 | 74 | def maintainer(self): 75 | return self.add_commit(self.maint_dao) 76 | 77 | def organizer(self): 78 | return self.add_commit(self.org_dao) 79 | 80 | def coordinator(self, round_id=None, campaign_id=None): 81 | if round_id: 82 | print(round_id) 83 | coord = CoordinatorDAO.from_round(self.user_dao, round_id) 84 | elif campaign_id: 85 | coord = CoordinatorDAO.from_campaign(self.user_dao, 86 | campaign_id) 87 | else: 88 | raise Exception('need round_id or campaign_id') 89 | return self.add_commit(coord) 90 | 91 | 92 | if __name__ == '__main__': 93 | fire.Fire(AdminTool) 94 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.9 3 | # by the following command: 4 | # 5 | # pip-compile --annotation-style=line requirements.in 6 | # 7 | ashes==24.0.0 8 | # via 9 | # -r requirements.in 10 | # chert 11 | # clastic 12 | attrs==23.2.0 13 | # via 14 | # clastic 15 | # glom 16 | boltons==24.0.0 17 | # via 18 | # -r requirements.in 19 | # chert 20 | # clastic 21 | # face 22 | # glom 23 | # lithoxyl 24 | certifi==2024.2.2 25 | # via 26 | # requests 27 | # sentry-sdk 28 | cffi==1.16.0 29 | # via cryptography 30 | charset-normalizer==3.3.2 31 | # via requests 32 | chert==21.0.0 33 | # via -r requirements.in 34 | clastic==24.0.0 35 | # via -r requirements.in 36 | cryptography==40.0.2 37 | # via 38 | # -r requirements.in 39 | # pyopenssl 40 | face==20.1.1 41 | # via 42 | # -r requirements.in 43 | # chert 44 | # glom 45 | glom==23.5.0 46 | # via clastic 47 | html5lib==1.1 48 | # via chert 49 | hyperlink==21.0.0 50 | # via chert 51 | idna==3.7 52 | # via 53 | # hyperlink 54 | # requests 55 | importlib-metadata==7.1.0 56 | # via markdown 57 | lithoxyl==21.0.0 58 | # via 59 | # -r requirements.in 60 | # chert 61 | mako==1.3.3 62 | # via sqltap 63 | markdown==3.6 64 | # via chert 65 | markupsafe==2.1.5 66 | # via mako 67 | mwoauth==0.4.0 68 | # via -r requirements.in 69 | oauthlib==3.2.2 70 | # via 71 | # mwoauth 72 | # requests-oauthlib 73 | pycparser==2.22 74 | # via cffi 75 | pyjwt==2.8.0 76 | # via mwoauth 77 | pymysql==1.1.1 78 | # via -r requirements.in 79 | pyopenssl==23.2.0 80 | # via -r requirements.in 81 | python-dateutil==2.9.0.post0 82 | # via chert 83 | python-graph-core==1.8.2 84 | # via 85 | # -r requirements.in 86 | # python3-vote-core 87 | python3-vote-core @ git+https://github.com/the-maldridge/python-vote-core.git@f0b01e7e24f80673c4c237ee9e6118e8986cf0bb ; python_version >= "3.0" 88 | # via -r requirements.in 89 | pyyaml==6.0.1 90 | # via chert 91 | requests==2.32.4 92 | # via 93 | # mwoauth 94 | # requests-oauthlib 95 | requests-oauthlib==2.0.0 96 | # via mwoauth 97 | secure-cookie==0.1.0 98 | # via clastic 99 | sentry-sdk==2.8.0 100 | # via -r requirements.in 101 | six==1.16.0 102 | # via 103 | # html5lib 104 | # python-dateutil 105 | sqlalchemy==1.2.19 106 | # via 107 | # -r requirements.in 108 | # sqltap 109 | sqlparse==0.5.0 110 | # via sqltap 111 | sqltap==0.3.11 112 | # via -r requirements.in 113 | unicodecsv==0.14.1 114 | # via -r requirements.in 115 | urllib3==2.5.0 116 | # via 117 | # requests 118 | # sentry-sdk 119 | webencodings==0.5.1 120 | # via html5lib 121 | werkzeug==1.0.1 122 | # via 123 | # clastic 124 | # secure-cookie 125 | # sqltap 126 | zipp==3.19.1 127 | # via importlib-metadata 128 | -------------------------------------------------------------------------------- /montage/static/mdl/styles.css: -------------------------------------------------------------------------------- 1 | 2 | html, body { 3 | font: 14pt 'Roboto', 'Helvetica', sans-serif; 4 | margin: 0; 5 | padding: 0; 6 | -webkit-font-smoothing: antialiased; 7 | } 8 | 9 | h1, h2, h3, h4, h5 { 10 | width: 100%; 11 | } 12 | 13 | .toc { 14 | width: 100%; 15 | } 16 | 17 | a:hover { 18 | text-decoration: none; 19 | } 20 | 21 | a.toclink { 22 | color: #888; 23 | text-decoration: none; 24 | } 25 | a.toclink:hover { 26 | color: #ff4081; 27 | } 28 | 29 | .mdl-montage .mdl-layout__header { 30 | background-color: rgb(96,125,139); 31 | color: #fff; 32 | height: 64px; 33 | z-index: 0; 34 | } 35 | 36 | .mdl-montage .mdl-layout__header a { 37 | color: #fff; 38 | text-decoration: underline; 39 | } 40 | 41 | .mdl-montage .mdl-layout__header a:hover { 42 | text-decoration: none; 43 | } 44 | 45 | 46 | .mdl-montage .mdl-button { 47 | line-height: 24px; 48 | margin: 0 6px; 49 | height: 40px; 50 | min-width: 0; 51 | line-height: 24px; 52 | padding: 8px; 53 | width: 40px; 54 | border-radius: 50%; 55 | } 56 | 57 | .mdl-montage .mdl-button img { 58 | width: 100%; 59 | height: 100%; 60 | } 61 | 62 | .mdl-montage .mdl-layout__header h1 { 63 | font-size: inherit; 64 | letter-spacing: 0.1px; 65 | } 66 | 67 | .mdl-montage { 68 | height: auto; 69 | display: -webkit-flex; 70 | display: -ms-flexbox; 71 | display: flex; 72 | -webkit-flex-direction: column; 73 | -ms-flex-direction: column; 74 | flex-direction: column; 75 | } 76 | .mdl-montage .mdl-card > * { 77 | height: auto; 78 | width: 100%; 79 | display: block; 80 | } 81 | 82 | .mdl-montage .mdl-card h3 { 83 | border-bottom: 1px solid #888; 84 | padding: 0.5em; 85 | font-size: 1.5em; 86 | line-height: 20px; 87 | } 88 | 89 | .mdl-montage .mdl-card h4 { 90 | font-size: 1em; 91 | margin: 0.5em 0 0.2em 0; 92 | } 93 | 94 | .mdl-montage .mdl-card ul { 95 | margin: 0; 96 | } 97 | 98 | .mdl-montage .mdl-card .mdl-card__supporting-text { 99 | margin: 40px; 100 | -webkit-flex-grow: 1; 101 | -ms-flex-positive: 1; 102 | flex-grow: 1; 103 | padding: 0; 104 | color: inherit; 105 | width: calc(100% - 80px); 106 | } 107 | 108 | .mdl-montage section.section--center { 109 | max-width: 860px; 110 | margin: 48px auto; 111 | } 112 | 113 | .mdl-montage section > header{ 114 | display: -webkit-flex; 115 | display: -ms-flexbox; 116 | display: flex; 117 | -webkit-align-items: center; 118 | -ms-flex-align: center; 119 | align-items: center; 120 | -webkit-justify-content: center; 121 | -ms-flex-pack: center; 122 | justify-content: center; 123 | } 124 | 125 | .mdl-montage section .section__text { 126 | -webkit-flex-grow: 1; 127 | -ms-flex-positive: 1; 128 | flex-grow: 1; 129 | -webkit-flex-shrink: 0; 130 | -ms-flex-negative: 0; 131 | flex-shrink: 0; 132 | padding-top: 8px; 133 | } 134 | 135 | .mdl-montage section.section--center .section__text:not(:last-child) { 136 | border-bottom: 1px solid rgba(0,0,0,.13); 137 | } 138 | -------------------------------------------------------------------------------- /frontend/src/components/Round/RoundView.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 82 | 83 | 104 | -------------------------------------------------------------------------------- /frontend/src/services/jurorService.js: -------------------------------------------------------------------------------- 1 | import { apiBackend } from './api' 2 | import _ from 'lodash' 3 | import dataService from './dataService' 4 | 5 | const jurorService = { 6 | get: () => apiBackend.get('juror'), 7 | 8 | getCampaign: (id) => apiBackend.get(`juror/campaign/${id}`), 9 | 10 | allCampaigns: () => apiBackend.get('juror/campaigns/all'), 11 | 12 | getPastVotes: (id, offset = 0, orderBy = 'date', sort = 'desc') => 13 | apiBackend.get(`juror/round/${id}/votes?offset=${offset}&order_by=${orderBy}&sort=${sort}`), 14 | 15 | getPastRanking: (id) => apiBackend.get(`juror/round/${id}/rankings`), 16 | 17 | getFaves: () => apiBackend.get('juror/faves'), 18 | 19 | getRound: (id) => apiBackend.get(`juror/round/${id}`), 20 | 21 | getRoundVotesStats: (id) => apiBackend.get(`juror/round/${id}/votes-stats`), 22 | 23 | faveImage: (roundId, entryId) => apiBackend.post(`juror/round/${roundId}/${entryId}/fave`, {}), 24 | 25 | unfaveImage: (roundId, entryId) => 26 | apiBackend.post(`juror/round/${roundId}/${entryId}/unfave`, {}), 27 | 28 | flagImage: (roundId, entryId, reason) => 29 | apiBackend.post(`juror/round/${roundId}/${entryId}/flag`, { reason }), 30 | 31 | setRating: (id, data) => apiBackend.post(`juror/round/${id}/tasks/submit`, data), 32 | 33 | getRoundTasks: (id, offset = 0) => { 34 | return apiBackend.get(`juror/round/${id}/tasks?count=10&offset=${offset}`).then((data) => { 35 | const tasks = data.data.tasks 36 | const files = tasks.map((task) => task.entry.name) 37 | 38 | return dataService.getImageInfo(files).then((responses) => { 39 | if (!responses.length) return data 40 | 41 | responses.forEach((response) => { 42 | // Build redirect map (old name -> new name) if present 43 | const redirectMap = {} 44 | if (response.query.redirects) { 45 | response.query.redirects.forEach((redirect) => { 46 | const fromName = redirect.from.replace(/^File:/i, '') 47 | const toName = redirect.to.replace(/^File:/i, '') 48 | redirectMap[fromName] = toName 49 | }) 50 | } 51 | 52 | const pages = _.values(response.query.pages) 53 | pages.forEach((page) => { 54 | if (page && page.imageinfo) { 55 | // Match by filename (could be the actual name or after redirect) 56 | const pageTitle = page.title.replace(/^File:/i, '') 57 | 58 | // Try to find by actual page title first, then check if any task redirects to this 59 | let image = _.find(tasks, (task) => task.entry.name === pageTitle) 60 | 61 | // If not found, check if this page is the target of a redirect 62 | if (!image) { 63 | const originalName = _.findKey(redirectMap, (target) => target === pageTitle) 64 | if (originalName) { 65 | image = _.find(tasks, (task) => task.entry.name === originalName) 66 | } 67 | } 68 | 69 | if (image) { 70 | image.history = page.imageinfo 71 | } 72 | } 73 | }) 74 | }) 75 | return data 76 | }) 77 | }) 78 | } 79 | } 80 | 81 | export default jurorService 82 | -------------------------------------------------------------------------------- /frontend/src/components/Campaign/CoordinatorCampaignCard.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 68 | 69 | 112 | -------------------------------------------------------------------------------- /montage/clastic_sentry.py: -------------------------------------------------------------------------------- 1 | # NB: code heavily modified from sentry's own flask integration 2 | 3 | from __future__ import absolute_import 4 | 5 | import weakref 6 | 7 | from sentry_sdk.hub import Hub, _should_send_default_pii 8 | from sentry_sdk.utils import capture_internal_exceptions, event_from_exception 9 | from sentry_sdk.integrations import Integration, DidNotEnable 10 | from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware 11 | from sentry_sdk.integrations._wsgi_common import RequestExtractor 12 | 13 | from clastic import Middleware, Application, SubApplication 14 | from clastic.errors import BadRequest 15 | 16 | 17 | class SentryMiddleware(Middleware): 18 | provides = ('sentry_scope', 'sentry_hub') 19 | 20 | wsgi_wrapper = SentryWsgiMiddleware 21 | 22 | def request(self, next, request, _route): 23 | hub = Hub.current 24 | 25 | with hub.configure_scope() as scope: 26 | # Rely on WSGI middleware to start a trace 27 | scope.transaction = _route.pattern 28 | 29 | weak_request = weakref.ref(request) 30 | evt_processor = _make_request_event_processor(weak_request) 31 | scope.add_event_processor(evt_processor) 32 | 33 | try: 34 | ret = next(sentry_scope=scope, sentry_hub=hub) 35 | except BadRequest: 36 | raise 37 | except Exception as exc: 38 | client = hub.client 39 | 40 | event, hint = event_from_exception( 41 | exc, 42 | client_options=client.options, 43 | mechanism={"type": "clastic"}, 44 | ) 45 | 46 | hub.capture_event(event, hint=hint) 47 | raise 48 | return ret 49 | 50 | 51 | class ClasticRequestExtractor(RequestExtractor): 52 | def env(self): 53 | # type: () -> Dict[str, str] 54 | return self.request.environ 55 | 56 | def cookies(self): 57 | # type: () -> ImmutableTypeConversionDict[Any, Any] 58 | return self.request.cookies 59 | 60 | def raw_data(self): 61 | # type: () -> bytes 62 | return self.request.get_data() 63 | 64 | def form(self): 65 | # type: () -> ImmutableMultiDict[str, Any] 66 | return self.request.form 67 | 68 | def files(self): 69 | # type: () -> ImmutableMultiDict[str, Any] 70 | return self.request.files 71 | 72 | def is_json(self): 73 | # type: () -> bool 74 | return self.request.is_json 75 | 76 | def json(self): 77 | # type: () -> Any 78 | return self.request.get_json() 79 | 80 | def size_of_file(self, file): 81 | # type: (FileStorage) -> int 82 | return file.content_length 83 | 84 | 85 | def _make_request_event_processor(weak_request): 86 | def inner(event, hint): 87 | request = weak_request() 88 | 89 | # if the request is gone we are fine not logging the data from 90 | # it. This might happen if the processor is pushed away to 91 | # another thread. 92 | if request is None: 93 | return event 94 | 95 | with capture_internal_exceptions(): 96 | ClasticRequestExtractor(request).extract_into_event(event) 97 | 98 | return event 99 | 100 | return inner 101 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | New TODOs 4 | * Add maintainer view for a round's DQed entries 5 | * Translate "score" on threshold selection back into appropriate 6 | units. For instance, on yesno rounds with quorum of 4, instead of 7 | >0.5, say "at least two yeses". Similarly, but not quite the same, 8 | instead of >=0.75, say "an average score of at least 4 stars." 9 | * Ranking round limit to 100 (`create_ranking_round` not used?) 10 | * Eliminate task table, switch to status column 11 | * Be able to deactivate coordinators 12 | * friggin indexes 13 | * Randomize ranking round task order 14 | 15 | ## Final report format 16 | 17 | * Campaign details 18 | * Campaign title 19 | * Open and close dates 20 | * Total number of submissions 21 | * Coordinator names 22 | * Juror names 23 | * Winning entries 24 | * Rank 25 | * Title, uploader, upload date, description 26 | * Juror ranks 27 | * Juror comments 28 | * Round timeline (for each round:) 29 | * Round type 30 | * Open date 31 | * Close date 32 | * Number of entries 33 | * Number of jurors 34 | * Number of votes 35 | * Distribution 36 | * Final threshold 37 | * Quorum 38 | * Report creation 39 | * "Organized with Montage" 40 | * Render date 41 | * (render duration in html comment) 42 | 43 | ## TMP 44 | 45 | ``` 46 | user = session.query(User).first() 47 | cdao = CoordinatorDAO(rdb_session=session, user=user) 48 | campaign = cdao.get_campaign(1) 49 | cdao.get_campaign_report(campaign) 50 | 51 | ``` 52 | # TODOs from DEV.md 53 | 54 | A bit of space for dev bookkeeping. 55 | 56 | ## Backend 57 | 58 | * Check for resource existence instead of raising 500s (e.g., campaign endpoints) 59 | * Logging and timing 60 | * Locking 61 | * Add indexes 62 | * Switch request_dict to Werkzeug MultiDict for BadRequest behavior 63 | * fix `one_or_none` getters 64 | ... 65 | 66 | * DAO.add_juror doesn't add jurors really 67 | * lookup round + check round permissions 68 | * endpoint should return progress info (/admin/round/, /admin) 69 | * Campaign + first Round as single step? 70 | * Blacklisted user disqualification 71 | * Load dates (?) 72 | * create round from previous round 73 | 74 | ... [stephen on the train] 75 | 76 | * Endpoint to view reviews 77 | * Handle NONE user in UserDAO 78 | * check entry existance before getting from db or remote source 79 | * what should happen when someone closes a round with open votes? 80 | * 81 | 82 | ## Frontend 83 | 84 | * Make URLs configurable for different backend paths (e.g., Labs versus localhost) 85 | * Interfaces for closing rounds 86 | * Where to show directions in interface? ("show these directions next time"/cookie) 87 | 88 | Ratings closing round interface: 89 | 90 | * Specify threshold (1, 2, 3, 4, 5 stars, etc.) 91 | 92 | 93 | ## Cron job 94 | 95 | * Look up open rounds 96 | * Look up round sources with open rounds 97 | * Ignore "round" and "selection" methods 98 | * For gists, redownload the gist and add_round_entries as need be 99 | * For categories, recheck the db and add_round_entries as need be 100 | * For removed entries, do nothing (current behavior) or disqualify 101 | 102 | ## Research data collection opt-in 103 | 104 | * Set by coordinator on campaign (on creation) 105 | * Seen by users on campaign tile 106 | * Anonymized csv voting download 107 | * Row per vote in CSV + Round column 108 | * Endpoint for setting/unsetting value 109 | * Only settable from start/before votes? 110 | * Unsetting may be useful 111 | * Inherent in coordinator opt-in language: "By checking this you 112 | assert that all jurors have been informed/consented that anonymized 113 | voting data will may be used for research." 114 | -------------------------------------------------------------------------------- /montage/rendered_admin.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import absolute_import 3 | from .admin_endpoints import (get_index, 4 | get_campaign, 5 | get_round, 6 | get_flagged_entries, 7 | get_disqualified, 8 | get_round_entries, 9 | get_results) 10 | 11 | 12 | def get_rendered_routes(): 13 | # all campaigns 14 | # -- create_campaign 15 | # -- campaign details 16 | # -- -- edit_campaign 17 | # -- -- create round 18 | # -- -- round details 19 | # -- -- -- edit round 20 | # -- -- -- disqualification 21 | # -- -- -- view flags 22 | # -- -- -- view juror stats 23 | # -- -- -- -- per juror tasks 24 | # -- -- results 25 | # -- -- -- download 26 | # -- -- entries 27 | # -- -- -- download 28 | 29 | routes = [('/m/admin', view_index, 'admin_index.html'), 30 | ('/m/admin/campaign/create', 31 | create_campaign, 'campaign_create.html'), 32 | ('/m/admin/campaign/', 33 | view_campaign, 'campaign.html'), 34 | ('/m/admin/campaign//edit', 35 | edit_campaign, 'campaign_edit.html'), 36 | ('/m/admin/campaign//round/create', 37 | create_round, 'round_create.html'), 38 | ('/m/admin/campaign//round/', 39 | view_round, 'round.html'), 40 | ('/m/admin/campaign//round//edit', 41 | edit_round, 'round_edit.html'), 42 | ('/m/admin/campaign//round//flags', 43 | view_flags, 'flags_view.html'), 44 | ('/m/admin/campaign//round//juror/', 45 | view_juror, 'juror_view.html'), 46 | ('/m/admin/campaign//round//disqualified', 47 | view_disqualified, 'disqualified_view.html') 48 | ('/m/admin/campaign//round//entries', 49 | view_entries, 'entries_view.html') 50 | , ('/m/admin/campaign//round//results', 51 | view_results, 'results.html')] 52 | return routes 53 | 54 | 55 | def view_index(user_dao): 56 | raw = get_index(user_dao) 57 | return raw['data'] 58 | 59 | def create_campaign(user_dao): 60 | pass 61 | 62 | def view_campaign(user_dao, campaign_id): 63 | raw = get_campaign(user_dao, campaign_id) 64 | return raw['data'] 65 | 66 | def edit_campaign(user_dao, campaign_id): 67 | raw = get_campaign(user_dao, campaign_id) 68 | return raw['data'] 69 | 70 | def create_round(user_dao, campaign_id): 71 | raw = get_campaign(user_dao, campaign_id) 72 | return raw['data'] 73 | 74 | def view_round(user_dao, round_id): 75 | raw = get_round(user_dao, round_id) 76 | return raw['data'] 77 | 78 | def edit_round(user_dao, round_id): 79 | raw = get_round(user_dao, round_id) 80 | return raw['data'] 81 | 82 | def view_flags(user_dao, round_id): 83 | raw = get_flagged_entries(user_dao, round_id) 84 | return raw['data'] 85 | 86 | def view_jurors(user_dao, round_id, user_id): 87 | pass 88 | 89 | def view_disqualified(user_dao, round_id): 90 | raw = get_disqualified(user_dao, round_id) 91 | return raw['data'] 92 | 93 | def view_entries(user_dao, round_id): 94 | raw = get_round_entries(user_dao, round_id) 95 | return raw['data'] 96 | 97 | def view_results(user_dao, round_id): 98 | raw = get_results(user_dao, round_id) 99 | return raw['data'] 100 | -------------------------------------------------------------------------------- /montage/mw/sqlprof.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | WSGI middleware for profiling SQLAlchemy, based on SQLTap. 4 | 5 | See https://github.com/bradbeattie/sqltap 6 | """ 7 | 8 | import queue 9 | import urllib.parse as urlparse 10 | 11 | import sqltap 12 | from werkzeug import Response, Request 13 | from clastic import Middleware 14 | 15 | class SQLTapWSGIiddleware: 16 | """ SQLTap dashboard middleware for WSGI applications. 17 | 18 | For example, if you are using Flask:: 19 | 20 | app.wsgi_app = SQLTapMiddleware(app.wsgi_app) 21 | 22 | And then you can use SQLTap dashboard from ``/__sqltap__`` page (this 23 | path prefix can be set by ``path`` parameter). 24 | 25 | :param app: A WSGI application object to be wrap. 26 | :param path: A path prefix for access. Default is `'/__sqltap__'` 27 | """ 28 | 29 | def __init__(self, app, path='/__sqltap__'): 30 | self.app = app 31 | self.path = path.rstrip('/') 32 | self.on = False 33 | self.collector = queue.Queue(0) 34 | self.stats = [] 35 | self.profiler = sqltap.ProfilingSession(collect_fn=self.collector.put) 36 | 37 | def __call__(self, environ, start_response): 38 | path = environ.get('PATH_INFO', '') 39 | if path == self.path or path == self.path + '/': 40 | return self.render(environ, start_response) 41 | 42 | query = urlparse.parse_qs(environ.get('QUERY_STRING', '')) 43 | enable = query.get('profilesql', [''])[0].lower() == 'true' 44 | if enable: 45 | self.profiler.start() 46 | 47 | try: 48 | resp = self.app(environ, start_response) 49 | finally: 50 | if enable: 51 | self.profiler.stop() 52 | 53 | return resp 54 | 55 | def render(self, environ, start_response): 56 | verb = environ.get('REQUEST_METHOD', 'GET').strip().upper() 57 | if verb not in ('GET', 'POST'): 58 | response = Response('405 Method Not Allowed', status=405, 59 | mimetype='text/plain') 60 | response.headers['Allow'] = 'GET, POST' 61 | return response(environ, start_response) 62 | try: 63 | while True: 64 | self.stats.append(self.collector.get(block=False)) 65 | except queue.Empty: 66 | pass 67 | 68 | return self.render_response(environ, start_response) 69 | 70 | def render_response(self, environ, start_response): 71 | html = sqltap.report(self.stats, middleware=self, report_format="wsgi") 72 | response = Response(html.encode('utf-8'), mimetype="text/html") 73 | return response(environ, start_response) 74 | 75 | 76 | class SQLProfilerMiddleware(Middleware): 77 | def endpoint(self, next, api_act, request: Request): 78 | enabled = request.method in ['GET', 'POST', 'PUT', 'DELETE'] and request.args.get('profilesql', 'false').lower() == 'true' 79 | 80 | if not enabled: 81 | return next() 82 | 83 | stats = [] 84 | collector = queue.Queue(0) 85 | profiler = sqltap.ProfilingSession(collect_fn=collector.put) 86 | profiler.start() 87 | try: 88 | resp = next() 89 | finally: 90 | profiler.stop() 91 | try: 92 | while True: 93 | stats.append(collector.get(block=False)) 94 | except queue.Empty: 95 | pass 96 | 97 | text_report = sqltap.report(stats, report_format="text") 98 | 99 | if isinstance(resp, dict): 100 | resp['__sql_profile__'] = text_report 101 | else: 102 | api_act['sql_profile'] = f'unsupported response type {type(resp)} for sql profile ({len(text_report)} bytes)' 103 | 104 | return resp 105 | 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /montage/labs.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import os 3 | 4 | try: 5 | import pymysql 6 | except ImportError: 7 | pymysql = None 8 | 9 | 10 | DB_CONFIG = os.path.expanduser('~/replica.my.cnf') 11 | 12 | 13 | IMAGE_COLS = ['img_width', 14 | 'img_height', 15 | 'img_name', 16 | 'img_major_mime', 17 | 'img_minor_mime', 18 | 'IFNULL(oi.actor_user, ci.actor_user) AS img_user', 19 | 'IFNULL(oi.actor_name, ci.actor_name) AS img_user_text', 20 | 'IFNULL(oi_timestamp, img_timestamp) AS img_timestamp', 21 | 'img_timestamp AS rec_img_timestamp', 22 | 'ci.actor_user AS rec_img_user', 23 | 'ci.actor_name AS rec_img_text', 24 | 'oi.oi_archive_name AS oi_archive_name'] 25 | 26 | 27 | class MissingMySQLClient(RuntimeError): 28 | pass 29 | 30 | 31 | def fetchall_from_commonswiki(query, params): 32 | if pymysql is None: 33 | raise MissingMySQLClient('could not import pymysql, check your' 34 | ' environment and restart the service') 35 | db_title = 'commonswiki_p' 36 | db_host = 'commonswiki.labsdb' 37 | connection = pymysql.connect(db=db_title, 38 | host=db_host, 39 | read_default_file=DB_CONFIG, 40 | charset='utf8') 41 | cursor = connection.cursor(pymysql.cursors.DictCursor) 42 | cursor.execute(query, params) 43 | res = cursor.fetchall() 44 | 45 | # looking at the schema on labs, it's all varbinary, not varchar, 46 | # so this block converts values 47 | ret = [] 48 | for rec in res: 49 | new_rec = {} 50 | for k, v in rec.items(): 51 | if isinstance(v, bytes): 52 | v = v.decode('utf8') 53 | new_rec[k] = v 54 | ret.append(new_rec) 55 | return ret 56 | 57 | 58 | def get_files(category_name): 59 | query = ''' 60 | SELECT {cols} 61 | FROM commonswiki_p.image AS i 62 | LEFT JOIN actor AS ci ON img_actor=ci.actor_id 63 | LEFT JOIN (SELECT oi_name, 64 | oi_actor, 65 | actor_user, 66 | actor_name, 67 | oi_timestamp, 68 | oi_archive_name 69 | FROM oldimage 70 | LEFT JOIN actor ON oi_actor=actor.actor_id) AS oi ON img_name=oi.oi_name 71 | JOIN page ON page_namespace = 6 72 | AND page_title = img_name 73 | JOIN categorylinks ON cl_from = page_id 74 | AND cl_type = 'file' 75 | AND cl_to = %s 76 | GROUP BY img_name 77 | ORDER BY oi_timestamp ASC; 78 | '''.format(cols=', '.join(IMAGE_COLS)) 79 | params = (category_name.replace(' ', '_'),) 80 | 81 | results = fetchall_from_commonswiki(query, params) 82 | 83 | return results 84 | 85 | 86 | def get_file_info(filename): 87 | query = ''' 88 | SELECT {cols} 89 | FROM commonswiki_p.image AS i 90 | LEFT JOIN actor AS ci ON img_actor=ci.actor_id 91 | LEFT JOIN (SELECT oi_name, 92 | oi_actor, 93 | actor_user, 94 | actor_name, 95 | oi_timestamp, 96 | oi_archive_name 97 | FROM oldimage 98 | LEFT JOIN actor ON oi_actor=actor.actor_id) AS oi ON img_name=oi.oi_name 99 | WHERE img_name = %s 100 | GROUP BY img_name 101 | ORDER BY oi_timestamp ASC; 102 | '''.format(cols=', '.join(IMAGE_COLS)) 103 | params = (filename.replace(' ', '_'),) 104 | results = fetchall_from_commonswiki(query, params) 105 | if results: 106 | return results[0] 107 | else: 108 | return None 109 | 110 | 111 | if __name__ == '__main__': 112 | imgs = get_files('Images_from_Wiki_Loves_Monuments_2015_in_France') 113 | import pdb; pdb.set_trace() 114 | -------------------------------------------------------------------------------- /frontend/src/assets/logo_white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 43 | 45 | 46 | 48 | image/svg+xml 49 | 51 | 52 | 53 | 54 | 55 | 60 | 65 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /montage/static/dist/images/logo_white_fat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 43 | 45 | 46 | 48 | image/svg+xml 49 | 51 | 52 | 53 | 54 | 55 | 60 | 65 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /frontend/src/i18n/ce.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "Умар" 5 | ] 6 | }, 7 | "montage-about": "Проектах лаьцна", 8 | "montage-credit": "Montage командера", 9 | "montage-source-code": "Йуьхьанцара код", 10 | "montage-login-heading": "Дехар до, системин чугӀо", 11 | "montage-login-description": "Хьо дӀахьажо Meta-Wiki чу, хьан персоналан хаамаш тӀечӀагӀбархьама. Montage материалаш а зорбане доккхур дац Викимедиа проекташкахь хьан аккаунт лелош.", 12 | "montage-login-account-instructions": "Нагахь санна хьан Wikimedia аккаунт йацахь, ахь иза кхолла мегар ду {0} тӀехь.", 13 | "montage-login-metawiki": "Мета-вики", 14 | "montage-login-button": "Викимедиа аккаунтехула чугӀо", 15 | "montage-login-logout": "ЧугӀо", 16 | "montage-active-campaigns": "Жигара кампанеш", 17 | "montage-all-campaigns": "Йерриге а кампанеш", 18 | "montage-new-campaign": "Керла кампани кхолла", 19 | "montage-manage-current": "Карарчу хенахь йолчу кампанешна урхалла де лахахь", 20 | "montage-manage-all": "Хьажа массо а кампанеш, жигара а, архивехь а лахахь", 21 | "montage-or": "йа", 22 | "montage-view-all": "массо а кампанеш а, раундаш а хьажа.", 23 | "montage-view-active": "жигара кампанеш а, раундаш а бен ма хьажа.", 24 | "montage-coordinator-campaigns": "Кампанийн координатор", 25 | "montage-juror-campaigns": "векаллийн кампанеш", 26 | "montage-active-voting-round": "Жигара кхаьжнаш тасаран раундаш", 27 | "montage-latest-round": "ТӀаьххьара раунд", 28 | "montage-coordinators": "Координаторш", 29 | "montage-voting-deadline": "Кхаж тасаран хан", 30 | "montage-directions": "Некъ", 31 | "montage-your-progress": "Хьан прогресс", 32 | "montage-vote": "Кхажтаса", 33 | "montage-edit-previous-vote": "Хьалха хилла кхаьжнаш нисде", 34 | "montage-progress-status": "{1} тӀера {0}", 35 | "montage-new-campaig-heading": "Керла кампани", 36 | "montage-placeholder-campaign-name": "Хаамбаран цӀе", 37 | "montage-description-campaign-name": "Кампанин цӀе йазйе", 38 | "montage-required-campaign-name": "Кампанин цӀе йазйан йеза", 39 | "montage-description-campaign-url": "Кампанин агӀонан URL йазйе, масала, Commons йа локалан Wiki Loves тӀехь.", 40 | "montage-description-date-range": "Суьрташ хьалхарчу раунде импортйича, де а, хан а хийцаро Ӏаткъам бийр бац хьан жюрино хьажа йиш йолчу суьрташна. Амма импорт йале и мах 1-чу раунде хийцаро тешалла дийр ду, чаккхенан де а, хан а кхачале чудаьхна суьрташ бен хьан жюрина гуш ца хилар.", 41 | "montage-description-campaign-coordinators": "Координаторш — кампанин а, раундан а, раундийн статистика а хийца аьтто болу нах бу.", 42 | "montage-required-campaign-coordinators": "Мел кӀезга а цхьа координатор оьшу", 43 | "montage-campaign-added-success": "Кампани кхиамца тӀетоьхна", 44 | "montage-something-went-wrong": "Цхьа хӀума нийса дац", 45 | "montage-btn-create-campaign": "Кхолла кампани", 46 | "montage-btn-save": "Ӏалашйан", 47 | "montage-btn-cancel": "Йухайаккхар", 48 | "montage-close-campaign": "ДӀакъовла кампани", 49 | "montage-archive": "Архив", 50 | "montage-unarchive": "Архив чуьра арайаккха", 51 | "montage-edit-campaign": "Хийца кампани", 52 | "montage-round-add": "ТӀетоха раунд", 53 | "montage-round-open-date": "Схьайиллина терахь (UTC)", 54 | "montage-round-open-time": "Схьайиллина хан (UTC)", 55 | "montage-round-close-date": "ДӀакъоьвлина терахь (UTC)", 56 | "montage-round-close-time": "ДӀакъоьвлина хан (UTC)", 57 | "montage-round-name": "Раундан цӀе", 58 | "montage-round-deadline": "Кхаж тасаран хан", 59 | "montage-description-round-stats": "Гойтуш хила йеза шен кхаьжнаш тасаран статистика (масала, тӀеэцна йа йухатоьхна суьртийн терахь) векаллийн раундан.", 60 | "montage-round-stats-description": "Гойтуш хила йеза шен кхаьжнаш тасаран статистика (масала, тӀеэцна йа йухатоьхна суьртийн терахь) векаллийн раундан.", 61 | "montage-round-source-category-help": "Конкурсан массо а суьрташ гулдеш йолу Wikimedia Commons йукъара категори. Масала: «Wiki Loves Monuments 2017» Ганехь конкурсера суьрташ.", 62 | "montage-round-source-csv-help": "CSV кепехь ларйина а, Google Sheet йа Gist кепехь чуйаьхна а файлийн могӀам.", 63 | "montage-vote-no-images": "Ахь кхаж тесна массо а суьрташна кху раундехь. ХӀинца а хьайн хьалха дина кхаьжнаш хийца йиш йу хьан, лахахь йолчу нуьйдица.", 64 | "permission-denied-message": "ХӀокху агӀонна тӀекхача оьшу бакъонаш йац хьан.", 65 | "montage-required-voting-deadline": "Кхаж таса нийса хан харжа", 66 | "montage-required-fill-inputs": "Dexar du masso a yäşkanaş yüza" 67 | } 68 | -------------------------------------------------------------------------------- /frontend/cypress/e2e/campaign.cy.js: -------------------------------------------------------------------------------- 1 | describe('Campaign Details Page', () => { 2 | beforeEach(() => { 3 | cy.setCookie('clastic_cookie', ''); 4 | cy.visit('http://localhost:5173/#/') 5 | cy.get('div.coordinator-campaign-cards').find('.coordinator-campaign-card').first().click() 6 | cy.url().should('match', /\/campaign\/\d+/) 7 | }) 8 | 9 | it('should display campaign title and rounds section', () => { 10 | cy.get('.campaign-title').should('be.visible') 11 | cy.get('.campaign-rounds').should('be.visible') 12 | }) 13 | 14 | it('should enter edit mode and show editable fields', () => { 15 | cy.get('.campaign-button-group') 16 | .find('button') 17 | .contains('Edit campaign') 18 | .click() 19 | 20 | cy.get('.campaign-name-input').should('be.visible') 21 | cy.get('.date-time-inputs').should('be.visible') 22 | }) 23 | 24 | it('should cancel edit mode', () => { 25 | cy.get('.campaign-button-group') 26 | .find('button') 27 | .contains('Edit campaign') 28 | .click() 29 | 30 | cy.get('.cancel-button').click() 31 | cy.get('.campaign-name-input').should('not.exist') 32 | cy.get('.campaign-title').should('be.visible') 33 | }) 34 | 35 | it('should show new round form after clicking "Add Round"', () => { 36 | cy.get('.add-round-button').click() 37 | cy.get('.juror-campaign-round-card').should('be.visible') 38 | cy.get('.form-container').should('be.visible') 39 | }) 40 | 41 | it('should enter campaign edit mode and show editable fields', () => { 42 | cy.get('[datatest="editbutton"]').click() 43 | cy.get('.campaign-name-input').should('be.visible') 44 | cy.get('.date-time-inputs').should('be.visible') 45 | }) 46 | 47 | it('should save campaign edits', () => { 48 | cy.get('button').contains('Edit').first().click() 49 | cy.get('.campaign-name-input input').clear().type('Updated Campaign Name'); 50 | cy.get('button').contains('Save').click() 51 | cy.contains('Updated Campaign Name').should('be.visible') 52 | }) 53 | 54 | it('should cancel editing campaign details', () => { 55 | cy.get('[datatest="editbutton"]').click() 56 | cy.get('.cancel-button').click() 57 | cy.get('.campaign-name-input').should('not.exist') 58 | cy.get('.campaign-title').should('be.visible') 59 | }) 60 | 61 | it('should not allow creating a new round when one is already active or paused', () => { 62 | cy.intercept('GET', '/v1/admin/campaign/*', { 63 | fixture: 'campaignWithActiveRound.json' 64 | }).as('getCampaign') 65 | 66 | cy.visit('/') 67 | cy.get('div.coordinator-campaign-cards').find('.coordinator-campaign-card').first().click() 68 | cy.wait('@getCampaign') 69 | 70 | cy.get('.add-round-button').click() 71 | cy.contains('Only one round can be maintained at a time').should('be.visible') 72 | cy.get('.juror-campaign-round-card').should('not.exist') 73 | }) 74 | 75 | it('should create a new round successfully', () => { 76 | cy.get('.add-round-button').click() 77 | cy.get('.form-container input[type="text"]').first().clear().type('My Test Round') 78 | cy.get('.form-container').within(() => { 79 | cy.get('input[placeholder="YYYY-MM-DD"]').first().clear().type('2025-08-15') 80 | }) 81 | cy.get('input[type="number"]').first().clear().type('3') 82 | cy.get('[data-testid="userlist-search"] input').type('AadarshM07'); 83 | cy.get('[data-testid="userlist-search"]') 84 | .find('li') 85 | .first() 86 | .click(); 87 | cy.get('.button-group button').contains('Add Round').click().click(); 88 | cy.log(' Round created successfully'); 89 | }) 90 | 91 | 92 | it('should cancel round creation', () => { 93 | cy.get('.add-round-button').click() 94 | 95 | cy.get('.button-group') 96 | .find('button') 97 | .contains('Cancel') 98 | .click() 99 | 100 | cy.get('.juror-campaign-round-card').should('not.exist') 101 | cy.get('.add-round-button').should('be.visible') 102 | }) 103 | 104 | 105 | 106 | it('should delete a round with confirmation', () => { 107 | cy.get('button').contains('Edit round').first().click() 108 | cy.get('button').contains('Delete').click() 109 | cy.get('.cdx-dialog') 110 | .find('button') 111 | .contains('Delete') 112 | .click() 113 | cy.wait(1000) 114 | }) 115 | }) 116 | -------------------------------------------------------------------------------- /frontend/src/i18n/pt-br.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "EPorto (WMB)", 5 | "YuriNikolai" 6 | ] 7 | }, 8 | "montage-about": "Sobre", 9 | "montage-credit": "pela equipe do Montage", 10 | "montage-source-code": "Código-fonte", 11 | "montage-login-heading": "Por favor faça o login", 12 | "montage-login-description": "Você será redirecionado(a) para o Meta-Wiki para confirmar a sua identidade. O Montage não publicará nada nos projetos Wikimedia usando a sua conta.", 13 | "montage-login-account-instructions": "Se você não tem uma conta Wikimedia, pode criar uma em {0}.", 14 | "montage-login-metawiki": "Meta-Wiki", 15 | "montage-login-button": "Faça login usando a sua conta Wikimedia", 16 | "montage-login-logout": "Sair da conta", 17 | "montage-active-campaigns": "Campanhas ativas", 18 | "montage-all-campaigns": "Todas as campanhas", 19 | "montage-new-campaign": "Criar nova campanha", 20 | "montage-manage-current": "Gerenciar as campanhas atuais abaixo", 21 | "montage-manage-all": "Ver todas as campanhas, ativas e arquivadas, abaixo", 22 | "montage-or": "ou", 23 | "montage-view-all": "ver todas as campanhas e rodadas.", 24 | "montage-view-active": "ver apenas campanhas e rodadas ativas.", 25 | "montage-coordinator-campaigns": "Coordenador(a) de campanhas", 26 | "montage-juror-campaigns": "Jurados(as) da campanha", 27 | "montage-active-voting-round": "Rodadas de votação ativas", 28 | "montage-latest-round": "Rodada mais recente", 29 | "montage-coordinators": "Coordenadores(as)", 30 | "montage-voting-deadline": "Prazo final para votação", 31 | "montage-directions": "Orientações", 32 | "montage-your-progress": "Seu progresso", 33 | "montage-vote": "Votar", 34 | "montage-edit-previous-vote": "Editar votos anteriores", 35 | "montage-progress-status": "{0} de {1}", 36 | "montage-new-campaig-heading": "Nova campanha", 37 | "montage-placeholder-campaign-name": "Nome da campanha", 38 | "montage-description-campaign-name": "Digite o nome da campanha", 39 | "montage-required-campaign-name": "Nome da campanha é obrigatório", 40 | "montage-placeholder-campaign-url": "URL da campanha", 41 | "montage-description-campaign-url": "Digite a URL da página da campanha, e.g., no Commons ou Wiki Loves local.", 42 | "montage-required-campaign-url": "A URL da campanha é obrigatória", 43 | "montage-invalid-campaign-url": "URL inválida", 44 | "montage-round-source": "Fonte", 45 | "montage-round-source-category": "Categoria no Wikimedia Commons", 46 | "montage-round-category-placeholder": "Inserir categoria", 47 | "montage-round-category-label": "Inserir categoria", 48 | "montage-round-no-category": "Nenhuma categoria encontrada.", 49 | "montage-vote-accept": "Aceitar", 50 | "montage-vote-decline": "Recusar", 51 | "montage-vote-keyboard-instructions": "Você também pode usar o teclado para votar.", 52 | "montage-vote-actions": "Ações", 53 | "montage-vote-add-favorites": "Adicionar aos favoritos", 54 | "montage-vote-added-favorites": "Imagem adicionada aos favoritos", 55 | "montage-vote-remove-favorites": "Remover dos favoritos", 56 | "montage-vote-removed-favorites": "Imagem removida dos favoritos", 57 | "montage-vote-skip": "Pular (votar mais tarde)", 58 | "montage-vote-description": "Descrição", 59 | "montage-vote-version": "Versão", 60 | "montage-vote-last-version": "Última versão em {0}", 61 | "montage-vote-all-done": "Tudo feito!", 62 | "montage-vote-hide-panel": "Ocultar painel", 63 | "montage-vote-show-panel": "Mostrar painel", 64 | "montage-vote-image": "Imagem", 65 | "montage-vote-image-remains": "{0} imagens restantes", 66 | "montage-vote-rating-instructions": "Uma a cinco estrelas", 67 | "montage-vote-round-part-of-campaign": "Parte de {0}", 68 | "montage-vote-grid-size-large": "Grande", 69 | "montage-vote-grid-size-medium": "Médio", 70 | "montage-vote-grid-size-small": "Pequeno", 71 | "montage-vote-order-by": "Ordenar por:", 72 | "montage-vote-gallery-size": "Tamanho da galeria", 73 | "montage-option-yes": "Sim", 74 | "montage-option-no": "Não", 75 | "montage-round-cancelled-tasks": "Tarefas canceladas", 76 | "montage-round-disqualified-files": "Arquivos desqualificados", 77 | "montage-round-open-tasks": "Tarefas abertas", 78 | "montage-round-files": "Arquivos", 79 | "montage-round-tasks": "Tarefas", 80 | "permission-denied-title": "Permissão Negada", 81 | "permission-denied-message": "Você não tem as permissões necessárias para acessar esta página.", 82 | "permission-denied-home": "Ir para Página Inicial", 83 | "montage-required-fill-inputs": "Por favor, preencha todos os campos." 84 | } 85 | -------------------------------------------------------------------------------- /frontend/src/i18n/ps.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "شاه زمان پټان" 5 | ] 6 | }, 7 | "montage-about": "په اړه", 8 | "montage-credit": "مونټ‌اېج ډلې له خوا", 9 | "montage-source-code": "سرچينې کوډ", 10 | "montage-login-heading": "مهرباني وکړئ، غونډال ته ننوځئ", 11 | "montage-login-description": "تاسو به د خپل هويت تاييد په موخ مېټاويکي ته ورولېږل شئ. مونټ‌اېج به ستاسو د گڼون کارولو سره د ويکيمېډيا هيڅ پروژه کې څه خپاره نه کړي.", 12 | "montage-login-account-instructions": "که تاسو ويکيمېډيا گڼون نه لرئ په {0} کې يې جوړولی شئ.", 13 | "montage-login-metawiki": "مېټا-ويکي", 14 | "montage-login-button": "ويکيمېډيا گڼون کارولو سره ننوتل", 15 | "montage-login-logout": "وتل", 16 | "montage-active-campaigns": "چارن شوې ټاکنيزې سيالۍ", 17 | "montage-all-campaigns": "ټولې ټاکنيزې سيالۍ", 18 | "montage-new-campaign": "ټاکنيزه سيالۍ پيلول", 19 | "montage-manage-current": "لاندې د اوسنۍ ټاکنيزې سيالۍ سمول", 20 | "montage-manage-all": "لاندې ټولې چارن شوې او خونديځ شوې ټاکنيزې سيالۍ کتلی شئ", 21 | "montage-or": "يا", 22 | "montage-view-all": "يا ټولې ټاکنيزې سيالۍ او پړاوونه کتلی شئ.", 23 | "montage-view-active": "يوازې چارن شوې ټاکنيزې سيالۍ او پړاوونه کتل.", 24 | "montage-coordinator-campaigns": "همغږي کوونکي ټاکنيزې سيالۍ", 25 | "montage-juror-campaigns": "نياويزه ټاکنيزې سيالۍ", 26 | "montage-active-voting-round": "رايې ورکولو اوسمهاله پړاوونه", 27 | "montage-latest-round": "وروستی پړاو", 28 | "montage-coordinators": "همغږي کوونکي", 29 | "montage-voting-deadline": "د رايې ورکولو وروستۍ نېټه", 30 | "montage-directions": "تگلوري", 31 | "montage-your-progress": "ستاسو پرمختگ", 32 | "montage-vote": "رايه ورکول", 33 | "montage-edit-previous-vote": "پخوانۍ رایې سمول", 34 | "montage-progress-status": "{0} له {1} څخه", 35 | "montage-new-campaig-heading": "نوې ټاکنيزه سيالۍ", 36 | "montage-placeholder-campaign-name": "ټاکنيزې سيالۍ نوم", 37 | "montage-description-campaign-name": "د ټاکنيزې سيالۍ نوم دننه کړئ", 38 | "montage-required-campaign-name": "ټاکنيزې سيالۍ نوم اړين دی", 39 | "montage-placeholder-campaign-url": "ټاکنيزې سيالۍ وېب‌پته", 40 | "montage-description-campaign-url": "ټاکنيزې سيالۍ ځمکپاڼې يوآراېل دننه کړئ، د بېلگې په توگه په خونديځ يا سيمه‌ييزه ويکي مينه کې.", 41 | "montage-required-campaign-url": "ټاکنيزې سيالۍ يو‌آر‌اېل اړين دی", 42 | "montage-invalid-campaign-url": "ناسم يو‌آر‌اېل", 43 | "montage-label-date-range": "نېټې بريد (UTC)", 44 | "montage-label-open-date": "پرانيستې نېټه", 45 | "montage-required-open-date": "پرانيستې نېټه اړينه ده", 46 | "montage-label-open-time": "پرانيستې وخت", 47 | "montage-required-open-time": "پرانيستې وخت اړين دی", 48 | "montage-label-close-date": "تړلو نېټه", 49 | "montage-required-close-date": "تړلو نېټه اړينه ده", 50 | "montage-label-close-time": "تړلو وخت", 51 | "montage-required-close-time": "تړلو وخت اړين دی", 52 | "montage-label-campaign-coordinators": "ټاکنيزې سيالۍ همغږي‌کوونکي", 53 | "montage-description-campaign-coordinators": "همغږي‌کوونکي هغه وگړي دي چې د ټاکنيزې سيالۍ، پړاوونو او پړاو شمېرنو د سمولو واک لري.", 54 | "montage-required-campaign-coordinators": "لږ تر لږه يو همغږي‌کوونکی اړين دی", 55 | "montage-campaign-added-success": "ټاکنيزه سيالۍ په برياليتوب سره ورگډه شوه", 56 | "montage-something-went-wrong": "يوه ستونزه رامنځته شوه", 57 | "montage-btn-create-campaign": "ټاکنيزه سيالۍ جوړول", 58 | "montage-btn-save": "خوندي‌کول", 59 | "montage-btn-cancel": "ناگارل", 60 | "montage-close-campaign": "ټاکنيزه سيالۍ تړل", 61 | "montage-archive": "خونديځ", 62 | "montage-unarchive": "ناخونديځ", 63 | "montage-edit-campaign": "ټاکنيزه سيالۍ سمول", 64 | "montage-round-add": "پړاو ورگډول", 65 | "montage-round-open-date": "پرانيستې نېټه (يو‌ټي‌سي)", 66 | "montage-round-open-time": "پرانيستې وخت (يو‌ټي‌سي)", 67 | "montage-round-close-date": "تړلو نېټه (يو‌ټي‌سي)", 68 | "montage-round-close-time": "تړلو وخت (يو‌ټي‌سي)", 69 | "montage-round-name": "پړاو نوم", 70 | "montage-round-deadline": "رايې ورکولو وروستۍ نېټه", 71 | "montage-label-round-stats": "خپلې شمارنې ښودل (ازمايښتي)", 72 | "montage-round-file-setting": "پړاو دوتنې اوڼنې", 73 | "montage-round-delete": "پړاو ړنگول", 74 | "montage-round-edit": "پړاو سمول", 75 | "montage-round-save": "پړاو خوندي‌‌کول", 76 | "montage-round-delete-confirm": "ايا تاسو ډاډه ياست چې دا پړاو ړنگول غواړئ؟", 77 | "montage-round-allowed-filetypes": "د دوتنې پرښول شوي ډولونه", 78 | "montage-round-dq-by-filetype": "دوتنې‌ډول له مخې ويستل شوي", 79 | "montage-round-dq-by-resolution": "پرېکړې له مخې ويستل شوي", 80 | "montage-round-dq-by-upload-date": "د راپورته کولو نېټې له مخې ويستل شوي", 81 | "montage-required-fill-inputs": "مهرباني وکړئ ټول بکسونه ډک کړئ" 82 | } 83 | -------------------------------------------------------------------------------- /montage/check_rdb.py: -------------------------------------------------------------------------------- 1 | 2 | from sqlalchemy import exc 3 | from sqlalchemy import event 4 | from sqlalchemy import select 5 | from sqlalchemy import inspect 6 | from sqlalchemy.ext.declarative.clsregistry import _ModuleMarker 7 | from sqlalchemy.orm import RelationshipProperty 8 | 9 | 10 | def get_schema_errors(base_type, session): 11 | """Check whether the current database matches the models declared in 12 | model base. 13 | 14 | Currently we check that all tables exist with all columns. What is 15 | not checked: 16 | 17 | * Column types are not verified 18 | * Relationships are not verified at all (TODO) 19 | 20 | :param base_type: Declarative base type for SQLAlchemy models to check 21 | :param session: SQLAlchemy session bound to an engine 22 | :return: True if all declared models have corresponding tables and columns. 23 | """ 24 | # based on http://stackoverflow.com/a/30653553/178013 25 | 26 | engine = session.get_bind() 27 | iengine = inspect(engine) 28 | 29 | errors = [] 30 | 31 | tables = iengine.get_table_names() 32 | 33 | # Go through all SQLAlchemy models 34 | for name, model_type in base_type._decl_class_registry.items(): 35 | 36 | if isinstance(model_type, _ModuleMarker): 37 | # Not a model 38 | continue 39 | 40 | table = model_type.__tablename__ 41 | if table not in tables: 42 | errors.append("Model %s table %s missing from database %s" 43 | % (model_type, table, engine)) 44 | continue 45 | 46 | # Check all columns are found 47 | # Looks like: 48 | # [{'default': "nextval('sanity_check_test_id_seq'::regclass)", 49 | # 'autoincrement': True, 'nullable': False, 'type': 50 | # INTEGER(), 'name': 'id'}] 51 | 52 | columns = [c["name"] for c in iengine.get_columns(table)] 53 | mapper = inspect(model_type) 54 | 55 | for column_prop in mapper.attrs: 56 | if isinstance(column_prop, RelationshipProperty): 57 | # TODO: Add sanity checks for relations 58 | pass 59 | else: 60 | for column in column_prop.columns: 61 | # Assume normal flat column 62 | if column.key in columns: 63 | continue 64 | errors.append("Model %s missing column %s from database %s" 65 | % (model_type, column.key, engine)) 66 | 67 | return errors 68 | 69 | 70 | def ping_connection(connection, branch): 71 | # from: 72 | # http://docs.sqlalchemy.org/en/latest/core/pooling.html#disconnect-handling-pessimistic 73 | # post-hoc hack: recipe caused/didn't catch ResourceClosedError, 74 | # ironically enough, trying a modification to fix this. 75 | if branch: 76 | # "branch" refers to a sub-connection of a connection, 77 | # we don't want to bother pinging on these. 78 | return 79 | 80 | # turn off "close with result". This flag is only used with 81 | # "connectionless" execution, otherwise will be False in any case 82 | save_should_close_with_result = connection.should_close_with_result 83 | connection.should_close_with_result = False 84 | 85 | try: 86 | # run a SELECT 1. use a core select() so that 87 | # the SELECT of a scalar value without a table is 88 | # appropriately formatted for the backend 89 | connection.scalar(select([1])) 90 | except (exc.DBAPIError, exc.ResourceClosedError) as err: 91 | # catch SQLAlchemy's DBAPIError, which is a wrapper 92 | # for the DBAPI's exception. It includes a .connection_invalidated 93 | # attribute which specifies if this connection is a "disconnect" 94 | # condition, which is based on inspection of the original exception 95 | # by the dialect in use. 96 | if getattr(err, 'connection_invalidated', True): 97 | # run the same SELECT again - the connection will re-validate 98 | # itself and establish a new connection. The disconnect detection 99 | # here also causes the whole connection pool to be invalidated 100 | # so that all stale connections are discarded. 101 | connection.scalar(select([1])) 102 | else: 103 | raise 104 | finally: 105 | # restore "close with result" 106 | connection.should_close_with_result = save_should_close_with_result 107 | return 108 | -------------------------------------------------------------------------------- /frontend/src/components/Campaign/JurorCampaignCard.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 82 | 83 | 147 | -------------------------------------------------------------------------------- /montage/cors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from clastic.middleware import Middleware 4 | 5 | class CORSMiddleware(Middleware): 6 | """Middleware for handling Cross-Origin Resource Sharing (CORS). 7 | 8 | This middleware adds the necessary headers to enable CORS for a Clastic application. 9 | It handles preflight OPTIONS requests and adds CORS headers to all responses. 10 | """ 11 | def __init__(self, 12 | allow_origins='*', 13 | allow_methods=None, 14 | allow_headers=None, 15 | allow_credentials=True, 16 | expose_headers=None, 17 | max_age=None): 18 | 19 | if allow_origins is None or allow_origins == '*': 20 | self.allow_all_origins = True 21 | self.allow_origins = [] 22 | else: 23 | self.allow_all_origins = False 24 | if not isinstance(allow_origins, list): 25 | allow_origins = [allow_origins] 26 | self.allow_origins = allow_origins 27 | 28 | if allow_methods is None: 29 | allow_methods = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'] 30 | 31 | if allow_headers is None: 32 | allow_headers = ['Content-Type', 'Authorization'] 33 | 34 | if expose_headers is None: 35 | expose_headers = [] 36 | 37 | self.allow_origins = allow_origins 38 | self.allow_methods = allow_methods 39 | self.allow_headers = allow_headers 40 | self.allow_credentials = allow_credentials 41 | self.expose_headers = expose_headers 42 | self.max_age = max_age 43 | 44 | def request(self, next, request): 45 | origin = request.headers.get('Origin') 46 | 47 | # If this is a preflight OPTIONS request, handle it directly 48 | if request.method == 'OPTIONS' and 'Access-Control-Request-Method' in request.headers: 49 | resp = self._handle_preflight(request) 50 | return resp 51 | 52 | # Otherwise, proceed with regular request handling and add CORS headers to response 53 | resp = next() 54 | self._add_cors_headers(resp, origin) 55 | return resp 56 | 57 | def _handle_preflight(self, request): 58 | from werkzeug.wrappers import Response 59 | 60 | origin = request.headers.get('Origin') 61 | resp = Response('') 62 | resp.status_code = 200 63 | 64 | # Add CORS headers 65 | self._add_cors_headers(resp, origin) 66 | 67 | # Add preflight-specific headers 68 | request_method = request.headers.get('Access-Control-Request-Method') 69 | if request_method and request_method in self.allow_methods: 70 | resp.headers['Access-Control-Allow-Methods'] = ', '.join(self.allow_methods) 71 | 72 | request_headers = request.headers.get('Access-Control-Request-Headers') 73 | if request_headers: 74 | resp.headers['Access-Control-Allow-Headers'] = ', '.join(self.allow_headers) 75 | 76 | if self.max_age is not None: 77 | resp.headers['Access-Control-Max-Age'] = str(self.max_age) 78 | 79 | return resp 80 | 81 | def _add_cors_headers(self, response, origin): 82 | if origin: 83 | if self.allow_all_origins or origin in self.allow_origins: 84 | response.headers['Access-Control-Allow-Origin'] = origin 85 | # Handle both standard Response objects and exception-based responses 86 | if hasattr(response, 'vary'): 87 | response.vary.add('Origin') 88 | else: 89 | # Fallback to direct header manipulation for non-standard responses 90 | vary_header = response.headers.get('Vary', '') 91 | if vary_header: 92 | if 'Origin' not in vary_header: 93 | response.headers['Vary'] = vary_header + ', Origin' 94 | else: 95 | response.headers['Vary'] = 'Origin' 96 | 97 | if self.allow_credentials: 98 | response.headers['Access-Control-Allow-Credentials'] = 'true' 99 | 100 | if self.expose_headers: 101 | response.headers['Access-Control-Expose-Headers'] = ', '.join(self.expose_headers) 102 | 103 | def __repr__(self): 104 | cn = self.__class__.__name__ 105 | return '%s(allow_origins=%r, allow_methods=%r)' % (cn, self.allow_origins, self.allow_methods) 106 | -------------------------------------------------------------------------------- /deployment.md: -------------------------------------------------------------------------------- 1 | # Montage Deployment 2 | 3 | These are instructions for deploying Montage on Toolforge. 4 | 5 | ## Deploying on Toolforge from scratch 6 | These instructions is only first time when setuping project on Toolforge 7 | 8 | ##### 1. Get the OAuth credentials. 9 | [Register your app](https://meta.wikimedia.org/wiki/Special:OAuthConsumerRegistration/propose) and save your consumer token and secret token for later. 10 | 11 | ##### 2. SSH to Toolforge and then inside tool 12 | ```bash 13 | ssh @login.toolforge.org 14 | become montage-beta 15 | ``` 16 | Here, we are using `montage-beta` instance but it can be `montage` or `montage-dev` as well. 17 | 18 | ##### 3. Clone the repo as src directory 19 | ```bash 20 | mkdir -p $HOME/www/python 21 | cd $HOME/www/python 22 | git clone https://github.com/hatnote/montage.git src 23 | ``` 24 | 25 | ##### 4. Make the frontend build 26 | ```bash 27 | toolforge webservice node18 shell -m 2G 28 | cd $HOME/www/python/src/frontend 29 | npm install 30 | npm run toolforge:build 31 | exit 32 | ``` 33 | This will build the vue prod bundle and put in backend's `template` and `static` directory. 34 | 35 | ##### 5. Create your database 36 | * Get the user name of database (`cat ~/replica.my.cnf`) 37 | * Open up MariaDB with `sql local` 38 | * Create a [Toolforge user database](https://wikitech.wikimedia.org/wiki/Help:Toolforge/Database#User_databases) (`create database __;`), and remember the name for the config 39 | 40 | ##### 6. Set up the montage config 41 | * Make a copy of `config.default.yaml` for your environment 42 | * You may need to update `USER_ENV_MAP` in `montage/utils.py` if you need to add a new environment 43 | * Add the `oauth_consumer_token` and `oauth_secret_token` 44 | * Add a `cookie_secret: ` 45 | * Add the `db_url` with your user database name, and the password from `~/.replica.my.cnf` 46 | * The format is: `mysql://:@tools.labsdb/?charset=utf8` 47 | * Add `api_log_path: /data/project//logs/montage_api.log` 48 | * Add `replay_log_path: /data/project//logs/montage_replay.log` 49 | * Add `labs_db: True` 50 | * Add `db_echo: False` 51 | * Add `root_path: '/'` 52 | 53 | 54 | ##### 7. Creating a virtual environment 55 | ```bash 56 | toolforge webservice python3.9 shell 57 | python3 -m venv $HOME/www/python/venv 58 | source $HOME/www/python/venv/bin/activate 59 | pip install --upgrade pip wheel 60 | pip install -r $HOME/www/python/src/requirements.txt 61 | exit 62 | ``` 63 | 64 | ##### 8. Start the backend service 65 | ```bash 66 | toolforge webservice python3.9 start 67 | ``` 68 | 69 | ##### 9. Testing of deployment 70 | * Visit /meta to see the API. Example: https://montage-beta.toolforge.org/meta/ 71 | * In the top section, you should see that the service was restarted in the last few seconds/minutes. 72 | 73 | 74 | --- 75 | 76 | 77 | ## Deploying new changes 78 | 79 | If montage is already deployed then you just need following to deploy new changes. 80 | 81 | ##### 1. Check the instance usage 82 | Login to the tool webapp. Make sure, you are maintainer on the webapp instance. Use the audit log endpoint to check that the instance isn't in active use. Example: https://montage-beta.toolforge.org/v1/logs/audit 83 | 84 | This will tell latest usage of instance by audit `create_date`. You can continue if instance is not being used. 85 | 86 | Sometimes, instance can in use, but there can be important bugfix and we can push anyways. 87 | 88 | ##### 2. SSH to Toolforge and then inside tool 89 | ```bash 90 | ssh @login.toolforge.org 91 | become montage-beta 92 | ``` 93 | Here, we are using `montage-beta` instance but it can be `montage` or `montage-dev` as well. 94 | 95 | ##### 3. Get new changes from remote 96 | ```bash 97 | cd $HOME/www/python/src 98 | git pull 99 | ``` 100 | 101 | ##### 4. Make the frontend build 102 | ```bash 103 | toolforge webservice node18 shell -m 2G 104 | cd $HOME/www/python/src/frontend 105 | npm install 106 | npm run toolforge:build 107 | exit 108 | ``` 109 | 110 | ##### 5. (Optional) Install python packages 111 | If you added new python packages in changes then you have to install them in pod. 112 | ```bash 113 | toolforge webservice python3.9 shell 114 | source $HOME/www/python/venv/bin/activate 115 | pip install -r $HOME/www/python/src/requirements.txt 116 | exit 117 | ``` 118 | 119 | ##### 8. Restart the backend service 120 | ```bash 121 | toolforge webservice python3.9 restart 122 | ``` 123 | 124 | ##### 9. Testing of deployment 125 | * Visit /meta to see the API. Example: https://montage-beta.toolforge.org/meta/ 126 | * In the top section, you should see that the service was restarted in the last few seconds/minutes. -------------------------------------------------------------------------------- /montage/templates/report.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 82 | 83 | 84 | 85 |
86 |
87 |

{campaign.name}

88 |
89 |
90 | 91 |
92 |

About

93 |

{campaign.name} was organized by {#coordinators}{@last} and{/last} {username}{@sep}, {/sep}{/coordinators} from {campaign.open_date} - {campaign.close_date}, receiving {#rounds}{@first}{total_round_entries}{/first}{/rounds} submissions from 832 users on Wikimedia Commons.

94 |

After three rounds of review, the jury of {@size key=all_jurors /} selected {@size key=winners /} winners, seen below.

95 |
96 | 97 |
98 |

Winners

99 | {#winners} 100 |
101 | 102 | 103 |
104 |
105 | {ranking} 106 |
107 |

Photograph by {entry.upload_user_text}, {juror_ranking_map}

108 |
109 |
110 | {/winners} 111 |
112 | 113 |
114 |

Rounds

115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | {#rounds} 129 | 130 | 131 | 132 | 134 | 135 | 136 | 137 | 138 | {/rounds} 139 | 140 |
NameEntriesUsersDateJurorsVoting typeMinimum to advance
{name}{total_round_entries}{total_users} 133 | {open_date} - {close_date}{@size key=jurors /}{vote_method}TODO
141 | 142 |

Jury

143 |

Thank you to all of the jury members for {campaign.name}: {#all_jurors}{@last}and {/last}{.}{@sep}, {/sep}{/all_jurors}.

144 | 145 |
146 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /montage/meta_endpoints.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import absolute_import 3 | import json 4 | import datetime 5 | 6 | from clastic import render_basic, GET, POST 7 | from clastic.errors import Forbidden 8 | from boltons.strutils import indent 9 | from boltons.jsonutils import reverse_iter_lines 10 | 11 | from .rdb import MaintainerDAO 12 | 13 | DEFAULT_LINE_COUNT = 500 14 | 15 | # These are populated at the bottom of the module 16 | META_API_ROUTES, META_UI_ROUTES = None, None 17 | 18 | 19 | def get_meta_routes(): 20 | api = [GET('/maintainer/active_users', get_active_users), 21 | GET('/logs/audit', get_audit_logs), 22 | GET('/logs/api', get_api_log_tail, render_basic), 23 | GET('/logs/api_exc', get_api_exc_log_tail, render_basic), 24 | GET('/logs/feel', get_frontend_error_log, render_basic), 25 | POST('/logs/feel', post_frontend_error_log, render_basic)] 26 | ui = [] 27 | return api, ui 28 | 29 | 30 | def get_active_users(user_dao): 31 | maint_dao = MaintainerDAO(user_dao) 32 | users = maint_dao.get_active_users() 33 | data = [] 34 | for user in users: 35 | ud = user.to_details_dict() 36 | data.append(ud) 37 | return {'data': data} 38 | 39 | 40 | def get_audit_logs(user_dao, request): 41 | """ 42 | Available filters (as query parameters): 43 | 44 | - limit (default 10) 45 | - offset (default 0) 46 | - campaign_id 47 | - round_id 48 | - action 49 | """ 50 | limit = request.values.get('limit', 10) 51 | offset = request.values.get('offset', 0) 52 | log_campaign_id = request.values.get('campaign_id') 53 | round_id = request.values.get('round_id') 54 | log_id = request.values.get('id') 55 | action = request.values.get('action') 56 | 57 | maint_dao = MaintainerDAO(user_dao) 58 | 59 | audit_logs = maint_dao.get_audit_log(limit=limit, 60 | offset=offset, 61 | campaign_id=log_campaign_id, 62 | round_id=round_id, 63 | log_id=log_id, 64 | action=action) 65 | data = [l.to_info_dict() for l in audit_logs] 66 | return {'data': data} 67 | 68 | 69 | def get_api_log_tail(config, user, request_dict): 70 | if not user.is_maintainer: 71 | raise Forbidden() 72 | request_dict = request_dict or {} 73 | count = int(request_dict.get('count', DEFAULT_LINE_COUNT)) 74 | 75 | log_path = config.get('api_log_path') 76 | if not log_path: 77 | return ['(no API log path configured)'] 78 | 79 | lines = _get_tail_from_path(log_path, count=count) 80 | 81 | return lines 82 | 83 | 84 | def get_api_exc_log_tail(config, user, request_dict): 85 | if not user.is_maintainer: 86 | raise Forbidden() 87 | request_dict = request_dict or dict() 88 | count = int(request_dict.get('count', DEFAULT_LINE_COUNT)) 89 | 90 | log_path = config.get('api_exc_log_path') 91 | if not log_path: 92 | return ['(no API exception log path configured)'] 93 | lines = _get_tail_from_path(log_path, count=count) 94 | 95 | return lines 96 | 97 | 98 | def _get_tail_from_path(path, count=DEFAULT_LINE_COUNT): 99 | log_path = open(path, 'rb') 100 | 101 | rliter = reverse_iter_lines(log_path) 102 | lines = [] 103 | for i, line in enumerate(rliter): 104 | if i > count: 105 | break 106 | lines.append(line) 107 | lines.reverse() 108 | return lines 109 | 110 | 111 | def post_frontend_error_log(user, config, request_dict): 112 | feel_path = config.get('feel_log_path', None) 113 | if not feel_path: 114 | return ['(no front-end error log configured)'] 115 | now = datetime.datetime.utcnow() 116 | now_str = now.isoformat() 117 | 118 | username = user.username if user else '' 119 | err_str = json.dumps(request_dict, sort_keys=True, indent=2) 120 | err_str = indent(err_str, ' ') 121 | with open(feel_path, 'a') as feel_file: 122 | feel_file.write('Begin error at %s:\n\n' % now_str) 123 | feel_file.write(' + Username: ' + username + '\n') 124 | feel_file.write(err_str) 125 | feel_file.write('\n\nEnd error at %s\n\n' % now_str) 126 | 127 | return 128 | 129 | 130 | def get_frontend_error_log(config, request_dict): 131 | # TODO 132 | request_dict = request_dict or dict() 133 | count = int(request_dict.get('count', DEFAULT_LINE_COUNT)) 134 | feel_path = config.get('feel_log_path', None) 135 | if not feel_path: 136 | return ['(no front-end error log configured)'] 137 | 138 | return _get_tail_from_path(feel_path, count=count) 139 | 140 | 141 | META_API_ROUTES, META_UI_ROUTES = get_meta_routes() 142 | -------------------------------------------------------------------------------- /frontend/cypress/e2e/login.cy.js: -------------------------------------------------------------------------------- 1 | describe('Home View Conditional Rendering', () => { 2 | 3 | beforeEach(() => { 4 | cy.setCookie('clastic_cookie',''); 5 | cy.visit('http://localhost:5173/#/'); 6 | }) 7 | 8 | it('should display main dashboard elements', () => { 9 | cy.get('.dashboard-container', { timeout: 10000 }).should('exist') 10 | cy.get('.dashboard-header h1').should('be.visible') 11 | }) 12 | 13 | it('should show "New Campaign" button for organizers', () => { 14 | cy.get('.dashboard-container', { timeout: 10000 }).should('exist') 15 | cy.get('body').then(($body) => { 16 | if ($body.find('a[href="/campaign/new"]').length > 0) { 17 | cy.get('.dashboard-header-heading').within(() => { 18 | cy.contains('New Campaign').should('exist') 19 | cy.get('a[href="/campaign/new"]').should('exist') 20 | }) 21 | } 22 | }) 23 | }) 24 | 25 | it('should create a new campaign via the form submission', () => { 26 | cy.get('.dashboard-container', { timeout: 10000 }).should('exist') 27 | cy.get('body').then(($body) => { 28 | if ($body.find('a[href="/campaign/new"]').length > 0) { 29 | cy.get('a[href="/campaign/new"]').click() 30 | cy.url().should('include', '/campaign/new') 31 | cy.get('.new-campaign-card').within(() => { 32 | cy.get('input[placeholder="Campaign name"]').type('Test Campaign') 33 | cy.get('input[placeholder="example-slug"]').type('test-campaign') 34 | cy.get('input[placeholder="YYYY-MM-DD"]').eq(0).type('2025-08-01') 35 | cy.get('input[placeholder="HH:mm"]').eq(0).type('10:00') 36 | cy.get('input[placeholder="YYYY-MM-DD"]').eq(1).type('2025-08-10') 37 | cy.get('input[placeholder="HH:mm"]').eq(1).type('18:00') 38 | cy.get('.create-button').click() 39 | }) 40 | cy.url().should('match', /\/campaign\/\d+/) 41 | } 42 | }) 43 | }) 44 | 45 | it('should show new campaign card after creation', () => { 46 | cy.visit('http://localhost:5173/#/'); 47 | cy.get('.dashboard-container', { timeout: 10000 }).should('exist') 48 | cy.get('body').then(($body) => { 49 | if ($body.find('.new-campaign-card').length > 0) { 50 | cy.get('.new-campaign-card').should('be.visible') 51 | cy.get('.new-campaign-card h2').should('contain', 'Test Campaign') 52 | } 53 | }) 54 | }); 55 | 56 | 57 | it('should show "Add Organizer" button for maintainers and it', () => { 58 | cy.get('.dashboard-container', { timeout: 10000 }).should('exist') 59 | cy.get('body').then(($body) => { 60 | if ($body.find('button:contains("Add Organizer")').length > 0) { 61 | cy.contains('Add Organizer').should('exist') 62 | 63 | } 64 | }) 65 | }) 66 | 67 | it('should open dialog when "Add Organizer" button is clicked', () => { 68 | cy.get('.dashboard-container', { timeout: 10000 }).should('exist') 69 | cy.get('body').then(($body) => { 70 | if ($body.find('button:contains("Add Organizer")').length > 0) { 71 | cy.contains('Add Organizer').click() 72 | cy.get('[role="dialog"]', { timeout: 5000 }).should('exist') 73 | cy.contains('Add').should('exist') 74 | } 75 | }) 76 | }) 77 | 78 | it('should display juror campaigns if available', () => { 79 | cy.visit('http://localhost:5173/#/'); 80 | cy.get('.dashboard-container', { timeout: 10000 }).should('exist') 81 | cy.get('body').then(($body) => { 82 | if ($body.find('.juror-campaigns').length > 0) { 83 | cy.get('.juror-campaigns h2').should('contain', 'Active voting rounds') 84 | cy.get('.juror-campaigns juror-campaign-card').should('exist') 85 | } 86 | }) 87 | }) 88 | 89 | it('should display coordinator campaign cards if campaigns exist', () => { 90 | cy.visit('http://localhost:5173/#/'); 91 | cy.get('.dashboard-container', { timeout: 10000 }).should('exist'); 92 | cy.get('body').then(($body) => { 93 | if ($body.find('section.coordinator-campaigns').length > 0) { 94 | cy.get('section.coordinator-campaigns').within(() => { 95 | cy.get('h2').should('contain', 'Coordinator campaigns'); 96 | cy.get('.coordinator-campaign-card').should('exist'); 97 | }); 98 | } 99 | }); 100 | }); 101 | 102 | it('should navigate to view all campaigns page', () => { 103 | cy.get('.dashboard-container', { timeout: 10000 }).should('exist') 104 | cy.get('.dashboard-info a').click() 105 | cy.url().should('include', '/campaign/all') 106 | }) 107 | 108 | it('should navigate to campaign details page', () => { 109 | cy.get('div.coordinator-campaign-cards') 110 | .find('.coordinator-campaign-card') 111 | .first() 112 | .click() 113 | cy.url().should('match', /\/campaign\/\d+/) 114 | }) 115 | 116 | 117 | }); 118 | -------------------------------------------------------------------------------- /frontend/src/components/Campaign/AllCampaign.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 120 | 121 | 168 | -------------------------------------------------------------------------------- /montage/simple_serdes.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import absolute_import 3 | import json 4 | import datetime 5 | 6 | from .utils import json_serial 7 | 8 | from sqlalchemy import inspect 9 | from sqlalchemy.types import TypeDecorator, Text 10 | from sqlalchemy.ext.mutable import Mutable 11 | 12 | 13 | from sqlalchemy.orm.state import InstanceState 14 | 15 | 16 | class EntityJSONEncoder(json.JSONEncoder): 17 | """ JSON encoder for custom classes: 18 | 19 | Uses __json__() method if available to prepare the object. 20 | Especially useful for SQLAlchemy models 21 | """ 22 | def __init__(self, *a, **kw): 23 | self.eager = kw.pop('eager', False) 24 | super(EntityJSONEncoder, self).__init__(*a, **kw) 25 | 26 | def default(self, o): 27 | if callable(getattr(o, 'to_json', None)): 28 | return o.to_json(eager=self.eager) 29 | 30 | return super(EntityJSONEncoder, self).default(o) 31 | 32 | 33 | def get_entity_propnames(entity): 34 | """ Get entity property names 35 | 36 | :param entity: Entity 37 | :type entity: sqlalchemy.ext.declarative.api.DeclarativeMeta 38 | :returns: Set of entity property names 39 | :rtype: set 40 | """ 41 | e = entity if isinstance(entity, InstanceState) else inspect(entity) 42 | ret = set(list(e.mapper.column_attrs.keys()) + list(e.mapper.relationships.keys())) 43 | 44 | type_props = [a for a in dir(entity.object) 45 | if isinstance(getattr(entity.object.__class__, a, None), property)] 46 | ret |= set(type_props) 47 | return ret 48 | 49 | 50 | def get_entity_loaded_propnames(entity): 51 | """ Get entity property names that are loaded (e.g. won't produce new queries) 52 | 53 | :param entity: Entity 54 | :type entity: sqlalchemy.ext.declarative.api.DeclarativeMeta 55 | :returns: Set of entity property names 56 | :rtype: set 57 | """ 58 | ins = inspect(entity) 59 | keynames = get_entity_propnames(ins) 60 | 61 | # If the entity is not transient -- exclude unloaded keys 62 | # Transient entities won't load these anyway, so it's safe to 63 | # include all columns and get defaults 64 | if not ins.transient: 65 | keynames -= ins.unloaded 66 | 67 | # If the entity is expired -- reload expired attributes as well 68 | # Expired attributes are usually unloaded as well! 69 | if ins.expired: 70 | keynames |= ins.expired_attributes 71 | 72 | # Finish 73 | return keynames 74 | 75 | 76 | class DictableBase(object): 77 | "Declarative Base mixin to allow objects serialization" 78 | 79 | def to_dict(self, eager=False, excluded=frozenset()): 80 | "This is called by clastic's json renderer" 81 | if eager: 82 | prop_names = get_entity_propnames(self) 83 | else: 84 | prop_names = get_entity_loaded_propnames(self) 85 | 86 | items = [] 87 | for attr_name in prop_names - excluded: 88 | val = getattr(self, attr_name) 89 | 90 | if isinstance(val, datetime.datetime): 91 | val = val.isoformat() 92 | items.append((attr_name, val)) 93 | return dict(items) 94 | 95 | def __repr__(self): 96 | prop_names = [col.name for col in self.__table__.c] 97 | 98 | parts = [] 99 | for name in prop_names[:2]: # TODO: configurable 100 | val = repr(getattr(self, name)) 101 | if len(val) > 40: 102 | val = val[:37] + '...' 103 | parts.append('%s=%s' % (name, val)) 104 | 105 | if not parts: 106 | return object.__repr__(self) 107 | 108 | cn = self.__class__.__name__ 109 | return '<%s %s>' % (cn, ' '.join(parts)) 110 | 111 | 112 | class JSONEncodedDict(TypeDecorator): 113 | impl = Text 114 | 115 | def process_bind_param(self, value, dialect): 116 | if value is None: 117 | value = {} 118 | return json.dumps(value, default=json_serial) 119 | 120 | def process_result_value(self, value, dialect): 121 | if value is None: 122 | value = '{}' 123 | return json.loads(value) 124 | 125 | 126 | class MutableDict(Mutable, dict): 127 | @classmethod 128 | def coerce(cls, key, value): 129 | "Convert plain dictionaries to MutableDict." 130 | 131 | if not isinstance(value, MutableDict): 132 | if isinstance(value, dict): 133 | return MutableDict(value) 134 | 135 | # this call will raise ValueError 136 | return Mutable.coerce(key, value) 137 | else: 138 | return value 139 | 140 | def __setitem__(self, key, value): 141 | "Detect dictionary set events and emit change events." 142 | dict.__setitem__(self, key, value) 143 | self.changed() 144 | 145 | def __delitem__(self, key): 146 | "Detect dictionary del events and emit change events." 147 | dict.__delitem__(self, key) 148 | self.changed() 149 | 150 | 151 | MutableDict.associate_with(JSONEncodedDict) 152 | -------------------------------------------------------------------------------- /report.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 79 | 80 | 81 | 82 |
83 |
84 |

Wiki Loves Monuments, Malta 2016

85 |
86 |
87 | 88 |
89 |

About

90 |

WLM Malta 2016 was Organized by LilyOfTheWest and Nevborg from September 1 - September 30, receiving 1105 submissions from 832 users on Wikimedia Commons.

91 |

After three rounds of review, the final jury selected ten winners for the year.

92 |
93 | 94 |
95 |

Winners

96 |
97 | 98 | 99 |
100 |
101 | 1 102 |
103 |

Lorem ipsum

104 |
105 |
106 | 107 |
108 | 109 | 110 |
111 |
112 | 2 113 |
114 |

Lorem ipsum

115 |
116 |
117 | 118 |
119 | 120 | 121 |
122 |
123 | 3 124 |
125 |

Lorem ipsum

126 |
127 |
128 |
129 | 130 |
131 |

Rounds

132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 |
NameEntriesUsersDateJurorsVoting typeMinimum to advance
Round 11105893 149 | October 12 - October 195yes/noat least 2 yes votes from 3 jurors
Round 2297221October 19 - October 2455 starsat least 3.5 star average from 3 jurors
Round 35050October 26 - October 295Rankedtop 10
174 | 175 |

Jury

176 |

Thank you to all of the jury members for WLM-Malta 2016, Smirkybec, Raffaella Zammit, Chrles coleiro, Dielja, Mariophotocity.

177 | 178 |
179 | 182 | 183 | 184 | -------------------------------------------------------------------------------- /frontend/src/components/Campaign/ActiveCampaign.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 154 | 155 | 202 | -------------------------------------------------------------------------------- /design.md: -------------------------------------------------------------------------------- 1 | # Design notes 2 | 3 | This tool lets administrators create a campaign. Each campaign is 4 | composed of a series of rounds (usually 3). Judges are invited to each 5 | round to rate photos along a common voting method. At the end of the 6 | round, the top-rated photos enter the next round of the 7 | competition. The final round produces a ranked list of winning photos. 8 | 9 | ## Views 10 | 11 | The two main areas of the tool are for admins 12 | (creating/editing/closing rounds), judges (participating in rounds). 13 | 14 | ### Campaign admin 15 | 16 | Admins can see a list of the campaigns they are associated with. Each 17 | campaign links to its round admin page. 18 | 19 | ### Round admin 20 | 21 | Admins can see a list of the rounds associated with a campaign they 22 | are associated with, and rounds are divided among active and 23 | inactive. Each round shows the type of ranking, the number of photos, 24 | number of jury members. Active rounds show the percentage 25 | complete. Inactive rounds show the total number of selected photos. 26 | 27 | Admins can create a round, or edit an existing round. 28 | 29 | ### Round details 30 | 31 | Admins can see the details for a selected round when creating or 32 | editing: 33 | 34 | - Name 35 | - Status (active, inactive, or closed) 36 | - Type (up/down votes, rating, ranking) 37 | - (The quorum for each image, for up/down and rating votes) 38 | - Images (imported from a Commons category, a previous round, or a 39 | CSV of filenames) 40 | - Jury members (list of Wikimedia usernames) 41 | - Note: Due to the way votes are assigned, the jury membership is 42 | only editable when a round is set as inactive. 43 | 44 | ### Round closing 45 | 46 | Admins can close out a round to select images. The round closing 47 | interface allows admins to choose how the round will close, by either 48 | specifying the number of images to select or the cutoff for 49 | selection. The page will show the number of images and number of 50 | authors will go to the next round. Once a round is marked as complete, 51 | there will be an option to download a list, create a follow up round, 52 | or see more details on the result. 53 | 54 | ### Round vote details 55 | 56 | Admins can see a list of all of each vote in a round in a campaign 57 | they are associated with. The votes are also downloadable as a CSV. 58 | 59 | ### Import dialog 60 | 61 | When creating a round, admins can choose how to import files. They can 62 | provide a list of commons categories, including an optional filter 63 | based on the resolution of an image. Before finalizing the import, it 64 | will show the number of images that will be imported. 65 | 66 | ### Campaign overview 67 | 68 | Jurors can see a list of the campaigns with rounds they are 69 | associated with. Each campaign links to the round overview page. 70 | 71 | ### Round overview 72 | 73 | Jurors can see a list of rounds they are associated with. Each active 74 | round links to the voting dashboard for that round. The round displays 75 | their progress, and the round's due date. 76 | 77 | ### Voting 78 | 79 | Jurors can see the next task in a round they are associated with. For 80 | up/down and rating type rounds, the interface includes a 81 | high-resolution version of the image, along with limited metadata (the 82 | image's resolution), and the juror can select up/down or a star 83 | rating. The juror also has the option of skipping a task, and getting 84 | another task from their queue. 85 | 86 | For ranking type rounds, the interface shows a rankable list of images 87 | with limited metadata (the image's resolution). The juror can arrange 88 | the photos in an order and then submit the round. 89 | 90 | The juror can also see their overall progress and the due date. 91 | 92 | Jurors have an option to enable a low-bandwidth version, which 93 | displays reduced resolution versions of images. 94 | 95 | ### Health 96 | 97 | The tool shows some simple stats that let you verify it's all in 98 | working order. 99 | 100 | ## Other notes 101 | 102 | - [Commons:Jury tools/WLM jury tool 103 | requirements](https://commons.wikimedia.org/wiki/Commons:Jury_tools/WLM_jury_tool_requirements) 104 | 105 | ## Montage User Roles 106 | 107 | Montage has a simple permission scheme tailored to the tasks of 108 | contest organization and judging. 109 | 110 | * Maintainers - Creators/operators/maintainers of Montage 111 | * Add Organizers 112 | * Organizers 113 | * Create campaigns 114 | * Add coordinators to campaigns they created 115 | * All actions available to coordinators. 116 | * Coordinators 117 | * Create/cancel/close a round 118 | * Add/remove jurors 119 | * Mark jurors active/inactive 120 | * Initiate a task reassignment 121 | * Download results and audit logs 122 | * Jurors 123 | * Rate and rank photos to which they are assigned 124 | * See their own progress 125 | 126 | Maintainers can technically do anything, as they have database access 127 | to the system, however they are intended to only add organizers. 128 | 129 | # Vote allocation 130 | 131 | ## Static versus dynamic 132 | 133 | As of writing, Montage is designed for vote pre-allocation. That is, 134 | on round initiation, voting tasks are preassigned to jury members. 135 | 136 | One design that's been debated is dynamic task assignment. The early 137 | design of Montage didn't support this for the following reasons: 138 | 139 | * Implementation complexity and potential performance issues 140 | * Potentially unfair results due to one or more jury members having 141 | more time/initiative, leading to them voting more than other jurors 142 | 143 | Preallocation is simpler, ensures an even distribution of votes, sets 144 | a clear expectation of work for juror and coordinator, ultimately 145 | leaving the coordinator in charge. 146 | 147 | A future version of Montage might want to support dynamic dispensing 148 | of votes. The current schema could support it, but the user_id would 149 | be left blank. Then, for each batch of votes, it's a matter of finding 150 | RoundEntries that have not been voted on by the current user. It may 151 | be possible to do this efficiently. 152 | 153 | The important feature, which I am about to implement, is allocation 154 | weighting. That is, coordinators should be able to set a minimum and 155 | maximum amount of work expected from each juror. (The version I am 156 | about to implement is still pre-allocated, so the minimum and maximum 157 | are the same value.) 158 | 159 | Dynamic voting would be necessary in the event Montage is used to 160 | organize a competition where voting is public, open to any 161 | Commons-registered user. 162 | -------------------------------------------------------------------------------- /frontend/src/components/Round/RoundEdit.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 190 | --------------------------------------------------------------------------------