├── 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 |
2 |
3 |
4 |
5 |
8 |
--------------------------------------------------------------------------------
/frontend/src/views/NewCampaignView.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
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 |
2 |
3 |
4 |
5 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
10 |
11 |
16 |
--------------------------------------------------------------------------------
/frontend/src/views/VoteView.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
2 |
6 |
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 | 
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 |
2 |
3 |
4 |
5 |
6 |
7 |
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 |
2 |
3 |
{{ $t('permission-denied-title') }}
4 |
{{ $t('permission-denied-message') }}
5 |
6 | {{
7 | $t('permission-denied-home')
8 | }}
9 |
10 |
11 |
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 |
2 |
20 |
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 |
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 |
2 |
3 |
4 |
5 | {{ user.username[0].toUpperCase() }}
6 |
7 | {{ user.username }}
8 |
9 |
10 |
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 |
2 |
9 | {{ $t('montage-no-results') }}
10 |
11 |
12 |
13 |
52 |
--------------------------------------------------------------------------------
/frontend/src/components/Vote/Vote.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
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 |
2 |
3 |
4 |
11 | {{ props.addMsg }}
12 |
13 |
14 |
15 |
16 |
17 |
64 |
78 |
--------------------------------------------------------------------------------
/frontend/src/components/LoginBox.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
{{ $t('montage-login-heading') }}
5 |
{{ $t('montage-login-description') }}
6 |
14 |
15 | {{ $t('montage-login-button') }}
16 |
17 |
18 |
19 |
20 |
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 |
2 |
10 |
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 |
2 |
22 |
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 |
2 |
3 |
4 |
10 |
16 |
22 |
23 |
24 |
{{ round.name }}
25 |
26 | {{
27 | round.vote_method === 'yesno'
28 | ? $t('montage-round-yesno')
29 | : round.vote_method === 'rating'
30 | ? $t('montage-round-rating')
31 | : $t('montage-round-ranking')
32 | }}
33 | . {{ round.status }}
34 |
35 |
36 |
37 |
38 |
39 |
40 | {{ isRoundEditing ? $t('montage-btn-cancel') : $t('montage-round-edit') }}
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
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 |
2 |
3 |
4 |
5 | {{ campaign.name }}
6 |
7 |
8 |
9 | {{ formatDate(campaign.open_date) }} - {{ formatDate(campaign.close_date) }}
10 |
11 |
12 |
13 |
14 |
{{ $t('montage-latest-round') }}
15 |
16 | {{
17 | lastRound
18 | ? $t(getVotingName(lastRound.round.vote_method)) + ' - ' + lastRound.round.status
19 | : '-'
20 | }}
21 |
22 |
23 | {{ $t('montage-coordinators') }} ({{ campaign.coordinators.length }})
24 |
25 |
26 |
32 | {{ coordinator.username[0] }}
33 |
34 |
35 |
36 |
37 |
38 |
39 |
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 |
2 |
3 |
4 | {{ campaign[0].campaign.name }}
5 |
6 |
7 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | {{ $t('montage-voting-deadline') }}:
23 | {{ round.deadline_date?.split('T')[0] }}
24 |
25 |
26 | {{ $t('montage-directions') }}: {{ round.directions }}
27 |
28 |
29 |
30 |
31 | {{ $t('montage-your-progress') }}:
32 | {{ (100 - round.percent_tasks_open).toFixed(1) }}% ({{
33 | $t('montage-progress-status', [
34 | (round.total_tasks - round.total_open_tasks) ?? 0,
35 | round.total_tasks ?? 0
36 | ])
37 | }})
38 |
39 |
40 | {{ $t('montage-vote') }}
47 | {{
48 | $t('montage-edit-previous-vote')
49 | }}
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
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 |
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 |
107 |
Photograph by {entry.upload_user_text}, {juror_ranking_map}
108 |
109 |
110 | {/winners}
111 |
112 |
113 |
114 |
Rounds
115 |
116 |
117 |
118 | Name
119 | Entries
120 | Users
121 | Date
122 | Jurors
123 | Voting type
124 | Minimum to advance
125 |
126 |
127 |
128 | {#rounds}
129 |
130 | {name}
131 | {total_round_entries}
132 | {total_users}
133 | {open_date} - {close_date}
134 | {@size key=jurors /}
135 | {vote_method}
136 | TODO
137 |
138 | {/rounds}
139 |
140 |
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 |
2 |
3 |
12 |
13 | {{ $t('montage-juror-campaigns') }}
14 |
19 |
20 |
21 | {{ $t('montage-coordinator-campaigns') }}
22 |
23 |
28 |
29 |
30 |
31 |
32 |
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 |
87 |
88 |
93 |
94 |
95 |
Winners
96 |
97 |
98 |
99 |
100 |
103 |
Lorem ipsum
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
114 |
Lorem ipsum
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
125 |
Lorem ipsum
126 |
127 |
128 |
129 |
130 |
131 |
Rounds
132 |
133 |
134 |
135 | Name
136 | Entries
137 | Users
138 | Date
139 | Jurors
140 | Voting type
141 | Minimum to advance
142 |
143 |
144 |
145 |
146 | Round 1
147 | 1105
148 | 893
149 | October 12 - October 19
150 | 5
151 | yes/no
152 | at least 2 yes votes from 3 jurors
153 |
154 |
155 | Round 2
156 | 297
157 | 221
158 | October 19 - October 24
159 | 5
160 | 5 stars
161 | at least 3.5 star average from 3 jurors
162 |
163 |
164 | Round 3
165 | 50
166 | 50
167 | October 26 - October 29
168 | 5
169 | Ranked
170 | top 10
171 |
172 |
173 |
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 |
2 |
3 |
27 |
28 | {{ $t('montage-active-voting-round') }}
29 |
34 |
35 |
36 | {{ $t('montage-coordinator-campaigns') }}
37 |
38 |
43 |
44 |
45 |
46 |
47 |
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 |
2 |
3 |
4 |
5 |
6 | {{ $t('montage-round-name') }}
7 |
8 |
9 |
10 |
12 | {{ $t('montage-round-deadline') }}
13 |
14 |
15 |
16 |
17 | {{ $t('montage-directions') }}
18 |
19 |
20 |
22 | {{ source.label }}
23 |
24 | {{ $t('montage-label-round-stats') }}
25 |
26 |
27 | {{ $t('montage-description-round-stats') }}
28 |
29 |
30 |
31 |
32 |
33 | {{ $t('montage-label-round-quorum') }}
34 |
35 | {{ $t('montage-description-round-quorum') }}
36 |
37 |
38 |
39 |
40 | {{ $t('montage-label-round-jurors') }}
41 |
42 | {{ $t('montage-description-round-jurors') }}
43 |
44 |
45 |
46 |
47 |
{{ $t('montage-round-file-setting') }}
48 |
49 |
50 | {{ $t('montage-round-' + key.replaceAll('_', '-')) }}
51 | {{ formData.config[key] }}
52 |
53 |
54 |
55 |
56 | {{ $t('montage-round-min-resolution') }}
57 |
58 |
59 |
60 |
61 |
62 | {{ $t('montage-round-delete') }}
63 |
64 |
65 |
66 | {{ $t('montage-btn-save') }}
67 |
68 |
69 |
70 | {{ $t('montage-btn-cancel') }}
71 |
72 |
73 |
74 |
75 |
190 |
--------------------------------------------------------------------------------