├── .dockerignore ├── .gitignore ├── .tox-coveragerc ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── PROJECT_LOG.md ├── README.md ├── TODO.md ├── _test_campaign_report.py ├── app.py ├── config.default.yaml ├── deployment.md ├── design.md ├── dev.md ├── docker-compose.yml ├── dockerfile ├── docs └── process.md ├── frontend ├── .env.default ├── .eslintrc.cjs ├── .gitignore ├── .prettierrc.json ├── .vscode │ └── extensions.json ├── README.md ├── index.html ├── jsconfig.json ├── package-lock.json ├── package.json ├── public │ └── favicon.ico ├── src │ ├── App.vue │ ├── assets │ │ ├── logo_white.svg │ │ └── main.css │ ├── components │ │ ├── AddOrganizer.vue │ │ ├── AppFooter.vue │ │ ├── AppHeader.vue │ │ ├── Campaign │ │ │ ├── ActiveCampaign.vue │ │ │ ├── AllCampaign.vue │ │ │ ├── CoordinatorCampaignCard.vue │ │ │ ├── JurorCampaignCard.vue │ │ │ ├── NewCampaign.vue │ │ │ └── ViewCampaign.vue │ │ ├── LoginBox.vue │ │ ├── Round │ │ │ ├── RoundEdit.vue │ │ │ ├── RoundInfo.vue │ │ │ ├── RoundNew.vue │ │ │ └── RoundView.vue │ │ ├── UserAvatarWithName.vue │ │ ├── UserList.vue │ │ └── Vote │ │ │ ├── ImageReviewDialog.vue │ │ │ ├── Vote.vue │ │ │ ├── VoteEdit.vue │ │ │ ├── VoteRanking.vue │ │ │ ├── VoteRating.vue │ │ │ └── VoteYesNo.vue │ ├── i18n.js │ ├── i18n │ │ ├── en.json │ │ ├── fa.json │ │ ├── fr.json │ │ ├── hi.json │ │ ├── ko.json │ │ ├── lt.json │ │ ├── mk.json │ │ ├── nl.json │ │ ├── pa.json │ │ ├── ps.json │ │ ├── pt-br.json │ │ ├── pt.json │ │ ├── qqq.json │ │ ├── sv.json │ │ ├── uk.json │ │ ├── zh-hans.json │ │ └── zh-hant.json │ ├── main.js │ ├── router │ │ └── index.js │ ├── services │ │ ├── adminService.js │ │ ├── alertService.js │ │ ├── api.js │ │ ├── dataService.js │ │ ├── dialogService.js │ │ └── jurorService.js │ ├── stores │ │ ├── loading.js │ │ └── user.js │ ├── utils.js │ └── views │ │ ├── AllCampaignView.vue │ │ ├── CampaignView.vue │ │ ├── HomeView.vue │ │ ├── NewCampaignView.vue │ │ ├── PermissionDenied.vue │ │ ├── VoteEditView.vue │ │ └── VoteView.vue └── vite.config.js ├── montage ├── __init__.py ├── __main__.py ├── admin_endpoints.py ├── app.py ├── check_rdb.py ├── clastic_sentry.py ├── cors.py ├── docs │ ├── api.md │ ├── faq.md │ └── index.md ├── imgutils.py ├── juror_endpoints.py ├── labs.py ├── loaders.py ├── log.py ├── meta_endpoints.py ├── mw │ ├── __init__.py │ └── sqlprof.py ├── public_endpoints.py ├── rdb.py ├── rendered_admin.py ├── server.py ├── simple_serdes.py ├── static │ ├── a │ │ └── index.html │ ├── dist │ │ └── images │ │ │ └── logo_white_fat.svg │ └── mdl │ │ ├── LICENSE │ │ ├── bower.json │ │ ├── material.css │ │ ├── material.js │ │ ├── material.min.css │ │ ├── material.min.css.map │ │ ├── material.min.js │ │ ├── material.min.js.map │ │ ├── package.json │ │ └── styles.css ├── templates │ ├── docs │ │ └── base.html │ └── report.html ├── tests │ ├── __init__.py │ ├── test_loaders.py │ └── test_web_basic.py └── utils.py ├── report.html ├── requirements-dev.txt ├── requirements.in ├── requirements.txt ├── setup.py ├── test_data ├── wlm2015_fr_12k.csv └── wlm2015_ir_5.csv ├── tools ├── _admin.py ├── admin.py ├── check_schema.py ├── create_schema.py ├── drop_schema.py └── trim_csv.py └── tox.ini /.dockerignore: -------------------------------------------------------------------------------- 1 | frontend 2 | client 3 | test_data 4 | fe 5 | docs -------------------------------------------------------------------------------- /.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 | # Spyder project settings 102 | .spyderproject 103 | 104 | # Rope project settings 105 | .ropeproject 106 | 107 | # PyCharm stuff: 108 | .idea/ 109 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Logo](https://i.imgur.com/EZD3p9r.png) 2 | 3 | ## Montage 4 | 5 | _Photo evaluation tool for and by Wiki Loves competitions_ 6 | 7 | Round-based photo evaluation is a crucial step in the "Wiki Loves" 8 | series of photography competitions. Montage provides a configurable 9 | workflow that adapts to the conventions of all groups. 10 | 11 | - [montage on Wikimedia Commons](https://commons.wikimedia.org/wiki/Commons:Montage) 12 | - [montage on Phabricator](https://phabricator.wikimedia.org/project/view/2287/) 13 | 14 | ## Testing 15 | 16 | `pip install tox` into your virtualenv, then `tox`. 17 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /dev.md: -------------------------------------------------------------------------------- 1 | # Developer Setup Guide 2 | 3 | Welcome to the **Montage Project**! This guide will help you set up the project for local development. 4 | 5 | --- 6 | 7 | ## Overview 8 | 9 | The **Montage Project** is a web application with two main components: 10 | 11 | 1. **Frontend**: 12 | - Built with **Vue 3**. 13 | - Includes **Wikimedia Codex** for UI components, **Axios** for API requests, and **Pinia** for state management. 14 | - Compiled using **Vite** for fast builds. 15 | 16 | 2. **Backend**: 17 | - Built with **Clastic**, a Python framework. 18 | - Uses various Python libraries such as: 19 | - **SQLAlchemy**: Database interactions. 20 | - **mwoauth**: Used for authentication with MediaWiki's OAuth. 21 | - **pymysql**: MySQL driver. 22 | - Serves the frontend and exposes API endpoints for application functionality. 23 | 24 | --- 25 | 26 | ## Prerequisites 27 | 28 | Ensure the following are installed: 29 | - **Docker** and **Docker Compose**: [Install Docker](https://www.docker.com/products/docker-desktop). 30 | - **Node.js** (v16 or above): [Install Node.js](https://nodejs.org). 31 | - **Make**: Available on most Unix-based systems. 32 | 33 | --- 34 | 35 | ## Setting Up the Project 36 | 37 | ### 1. Clone the Repository 38 | ```bash 39 | git clone git@github.com:hatnote/montage.git 40 | cd montage 41 | ``` 42 | 43 | ### 2. Set up the Frontend 44 | ```bash 45 | cd frontend 46 | npm install 47 | ``` 48 | 49 | ### 3. Configure Environment Variables 50 | ```bash 51 | cp .env.default .env 52 | ``` 53 | 54 | Edit the `.env` file to match your development environment. By default, it's configured to connect to a locally running backend at `http://localhost:5001`. 55 | 56 | ### 4. Run the Frontend in Development Mode 57 | ```bash 58 | npm run dev 59 | ``` 60 | 61 | This will start the Vite development server with hot module replacement. 62 | 63 | Other frontend development commands: 64 | * `npm run build`: Build for production 65 | * `npm run lint`: Lint the code 66 | * `npm run format`: Format the code 67 | 68 | ### 5. Use the Makefile to start the backend 69 | * Open a new terminal tab and change directory to root of repo 70 | * Copy and edit `config.dev.yaml` based on `config.default.yaml` 71 | * (Optional) In `config.dev.yaml` there is a line for `dev_local_cookie_value`. To get it, 72 | log in to the local app in your browser, and then copy the value from the 73 | `clastic_cookie` in the apps' cookies. This is your login cookie. 74 | * (Optional) Add your username as the `superuser` in the config. (This will allow you to 75 | add `su_to=` to the backend, if you want to test submitting as another 76 | juror.) 77 | * Add your username to the list of maintainers in [rdb.py line 113](https://github.com/hatnote/montage/blob/master/montage/rdb.py#L113). 78 | This will give your user top-level permissions in the full app, so you can view 79 | some logs (audit logs, active users), add/remove organizers, and get a 80 | coordinator view into all campaigns. 81 | * Start the montage backend 82 | ```bash 83 | make start 84 | ``` 85 | This will build the docker image for the montage backend and start the container. Apart from `make start`, these are other commands: 86 | * `make start-detached` : Start the backend container in detached mode 87 | * `make stop` : Stop the backend container 88 | * `make logs` : Stream the backend container logs in real-time. 89 | * `make restart` : Restart the backend container 90 | 91 | ### 6. Access the Application 92 | * With development server: Open http://localhost:5173 in your browser (frontend) 93 | * With backend serving frontend: Open http://localhost:5001 in your browser 94 | 95 | The application server runs on localhost port 5001, visit [http://localhost:5001/meta](http://localhost:5001/meta) to see a list 96 | of valid URL patterns and other details. 97 | 98 | Almost all endpoints from backend (except for OAuth and `/static/`) return JSON as long as the proper Accept header is set (done by most libraries) or `format=json` is passed in the query string. 99 | 100 | ## Project structure 101 | ```bash 102 | ├── DEV.md 103 | ├── Dockerfile 104 | ├── LICENSE 105 | ├── MANIFEST.in 106 | ├── Makefile 107 | ├── PROJECT_LOG.md 108 | ├── config 109 | │   ├── beta-uwsgi.ini 110 | │   ├── dev-uwsgi.ini 111 | │   └── prod-uwsgi.ini 112 | ├── config.default.yaml 113 | ├── deployment.md 114 | ├── design.md 115 | ├── docker-compose.yml 116 | ├── frontend 117 | │   ├── index.html 118 | │   ├── jsconfig.json 119 | │   ├── package-lock.json 120 | │   ├── package.json 121 | │ ├── .env.default 122 | │ ├── .env 123 | │   ├── public 124 | │   ├── src 125 | │   └── vite.config.js 126 | ├── montage 127 | │   ├── __init__.py 128 | │   ├── __main__.py 129 | │   ├── __pycache__ 130 | │   ├── admin_endpoints.py 131 | │   ├── app.py 132 | │   ├── check_rdb.py 133 | │   ├── clastic_sentry.py 134 | │   ├── docs 135 | │   ├── imgutils.py 136 | │   ├── juror_endpoints.py 137 | │   ├── labs.py 138 | │   ├── loaders.py 139 | │   ├── log.py 140 | │   ├── meta_endpoints.py 141 | │   ├── mw 142 | │   ├── public_endpoints.py 143 | │   ├── rdb.py 144 | │   ├── rendered_admin.py 145 | │   ├── server.py 146 | │   ├── simple_serdes.py 147 | │   ├── static 148 | │   ├── templates 149 | │   ├── tests 150 | │   └── utils.py 151 | ├── report.html 152 | ├── requirements-dev.txt 153 | ├── requirements.in 154 | ├── requirements.txt 155 | ├── setup.py 156 | ├── tools 157 | │   ├── _admin.py 158 | │   ├── admin.py 159 | │   ├── check_schema.py 160 | │   ├── create_schema.py 161 | │   ├── drop_schema.py 162 | │   └── trim_csv.py 163 | └── tox.ini 164 | ``` 165 | These provides a detailed explanation of the main components in the **Montage Project**. 166 | 167 | #### Directory: `montage` 168 | ##### **Core Files** 169 | - **`app.py`**: Initializes the application and defines middleware, routes, or app-wide configurations. 170 | - **`server.py`**: Starts the server, setting up the backend to listen on a specific port. 171 | - **`__main__.py`**: Entry point when running the backend module. 172 | 173 | ##### **API Endpoints** 174 | - **`admin_endpoints.py`**: Handles admin-specific routes (e.g., user management, settings). 175 | - **`juror_endpoints.py`**: Contains juror-related APIs (e.g., task assignments, voting). 176 | - **`meta_endpoints.py`**: General application metadata or system information. 177 | - **`public_endpoints.py`**: APIs accessible to public or unauthenticated users. 178 | 179 | ##### **Utilities** 180 | - **`imgutils.py`**: Handles image processing and manipulation. 181 | - **`simple_serdes.py`**: Serializes and deserializes objects to JSON or other formats. 182 | - **`log.py`**: Configures application logging. 183 | 184 | ##### **Database** 185 | - **`rdb.py`**: Manages database interactions (queries, migrations, etc.). 186 | - **`check_rdb.py`**: Verifies database schema integrity. 187 | 188 | ##### **Static and Templates** 189 | - **`static/`**: Holds CSS, JavaScript, and other assets. 190 | - **`templates/`**: Contains Jinja2 templates for dynamic HTML rendering. 191 | 192 | ##### **Testing** 193 | - **`tests/`**: Basic tests for backend components. 194 | 195 | 196 | #### Directory: `frontend` 197 | 198 | ##### **Core Files** 199 | - **`src/`**: Source code, including components, routes, and utilities. 200 | - **`public/`**: Static assets, such as images and global styles. 201 | - **`vite.config.js`**: Configuration for the Vite build tool. 202 | - **`.env.default`**: Template for environment configuration. 203 | - **`.env`**: Local environment configuration. 204 | 205 | 206 | #### Directory: `tools` 207 | 208 | ##### **Key Scripts** 209 | - **`create_schema.py`**: Creates database tables. 210 | - **`drop_schema.py`**: Drops all database tables. 211 | - **`check_schema.py`**: Verifies schema correctness. 212 | - **`trim_csv.py`**: Utility for cleaning CSV files 213 | 214 | 215 | #### Docker Files 216 | - **`Dockerfile`**: Builds the backend container. 217 | - **`docker-compose.yml`**: Orchestrates service for backend. -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/.env.default: -------------------------------------------------------------------------------- 1 | VITE_API_ENDPOINT=http://localhost:5001 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | 30 | *.tsbuildinfo 31 | -------------------------------------------------------------------------------- /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/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Montage-frontend 2 | 3 | This is montage frontend -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Montage 8 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | } 6 | }, 7 | "exclude": ["node_modules", "dist"] 8 | } -------------------------------------------------------------------------------- /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.7.7", 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.6", 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 | "eslint": "^8.57.0", 39 | "eslint-plugin-vue": "^9.23.0", 40 | "prettier": "^3.2.5", 41 | "vite": "^6.2.2" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hatnote/montage/238ffcd6886a7cd94f09175c84ce178734ad629a/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 15 | 16 | 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/AddOrganizer.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 65 | 79 | -------------------------------------------------------------------------------- /frontend/src/components/AppFooter.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 53 | -------------------------------------------------------------------------------- /frontend/src/components/AppHeader.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 64 | 65 | 102 | -------------------------------------------------------------------------------- /frontend/src/components/Campaign/ActiveCampaign.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 154 | 155 | 202 | -------------------------------------------------------------------------------- /frontend/src/components/Campaign/AllCampaign.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 120 | 121 | 168 | -------------------------------------------------------------------------------- /frontend/src/components/Campaign/CoordinatorCampaignCard.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 68 | 69 | 112 | -------------------------------------------------------------------------------- /frontend/src/components/Campaign/JurorCampaignCard.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 82 | 83 | 147 | -------------------------------------------------------------------------------- /frontend/src/components/Campaign/NewCampaign.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 166 | 167 | 196 | -------------------------------------------------------------------------------- /frontend/src/components/LoginBox.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 37 | 38 | 75 | -------------------------------------------------------------------------------- /frontend/src/components/Round/RoundEdit.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 190 | -------------------------------------------------------------------------------- /frontend/src/components/Round/RoundView.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 82 | 83 | 104 | -------------------------------------------------------------------------------- /frontend/src/components/UserAvatarWithName.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 29 | 30 | 58 | -------------------------------------------------------------------------------- /frontend/src/components/UserList.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 53 | -------------------------------------------------------------------------------- /frontend/src/components/Vote/ImageReviewDialog.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 97 | 98 | -------------------------------------------------------------------------------- /frontend/src/components/Vote/Vote.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /frontend/src/i18n/fa.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "Arian Ar" 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": "URL کمپین", 40 | "montage-description-campaign-url": "URL صفحه کمپین را وارد کنید، مثلاً در ویکی‌انبار یا پروژه ویکی دوست‌دار محلی.", 41 | "montage-required-campaign-url": "URL کمپین الزامی است", 42 | "montage-invalid-campaign-url": "URL نامعتبر", 43 | "montage-label-date-range": "بازه تاریخ (UTC)", 44 | "montage-btn-save": "ذخیره", 45 | "montage-btn-cancel": "لغو", 46 | "montage-archive": "بایگانی", 47 | "montage-round-file-type": "نوع پرونده", 48 | "montage-round-voting-details": "جزئیات رای‌گیری", 49 | "montage-round-activate": "فعال‌سازی", 50 | "montage-round-activated": "دور با موفقیت فعال شد.", 51 | "montage-round-pause": "مکث", 52 | "montage-round-finalize": "نهایی‌سازی", 53 | "montage-round-finalized": "دور با موفقیت نهایی شد.", 54 | "montage-round-download-results": "بارگیری نتایج", 55 | "montage-round-source": "منبع", 56 | "montage-round-source-category": "رده در ویکی‌انبار", 57 | "montage-round-category-placeholder": "درج رده", 58 | "montage-round-category-label": "درج رده", 59 | "montage-round-no-category": "هیچ رده‌ای یافت نشد", 60 | "montage-round-threshold": "آستانه", 61 | "montage-round-vote-method": "روش رای‌دادن", 62 | "montage-round-threshold-description": "حداقل میانگین امتیاز برای عکس", 63 | "montage-round-threshold-default": "آستانه را انتخاب کنید", 64 | "montage-vote-accept": "پذیرفتن", 65 | "montage-vote-decline": "رد کردن", 66 | "montage-vote-actions": "کنش‌ها", 67 | "montage-vote-description": "توضیح", 68 | "montage-vote-version": "نسخه", 69 | "montage-vote-last-version": "آخرین نسخه در {0}", 70 | "montage-vote-hide-panel": "پنهان کردن پنل", 71 | "montage-vote-show-panel": "نمایش پنل", 72 | "montage-vote-image": "تصویر", 73 | "montage-vote-image-remains": "{0} تصویر باقی مانده است", 74 | "montage-vote-rating-instructions": "یک تا پنج ستاره", 75 | "montage-vote-round-part-of-campaign": "بخشی از {0}", 76 | "montage-vote-grid-size-large": "بزرگ", 77 | "montage-vote-grid-size-medium": "متوسط", 78 | "montage-vote-grid-size-small": "کوچک", 79 | "montage-vote-image-review": "بررسی تصویر #{0}", 80 | "montage-vote-ordinal-place": "رتبه {0}", 81 | "montage-vote-order-by": "ترتیب بر اساس:", 82 | "montage-option-yes": "بله", 83 | "montage-option-no": "خیر", 84 | "montage-btn-add": "افزودن", 85 | "montage-round-files": "پرونده‌ها", 86 | "montage-round-tasks": "وظایف", 87 | "permission-denied-message": "شما مجوزهای لازم برای دسترسی به این صفحه را ندارید." 88 | } 89 | -------------------------------------------------------------------------------- /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 | } 71 | -------------------------------------------------------------------------------- /frontend/src/i18n/pa.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "Cabal" 5 | ] 6 | }, 7 | "montage-about": "ਬਾਬਤ", 8 | "montage-source-code": "ਸਰੋਤ ਕੋਡ", 9 | "montage-login-heading": "ਕਿਰਪਾ ਕਰਕੇ ਦਾਖ਼ਲ ਹੋਵੋ", 10 | "montage-login-button": "ਵਿਕੀਮੀਡੀਆ ਖਾਤੇ ਦੀ ਵਰਤੋਂ ਕਰਕੇ ਦਾਖ਼ਲ ਹੋਵੋ", 11 | "montage-login-logout": "ਬਾਹਰ ਆਉ", 12 | "montage-or": "ਜਾਂ", 13 | "montage-view-all": "ਸਾਰੀਆਂ ਮੁਹਿੰਮਾਂ ਅਤੇ ਗੇਡ਼ੇ ਵੇਖੋ।", 14 | "montage-active-voting-round": "ਸਰਗਰਮ ਚੋਣ ਗੇਡ਼ੇ", 15 | "montage-voting-deadline": "ਚੋਣਾਂ ਦੀ ਆਖਰੀ ਮਿਤੀ", 16 | "montage-your-progress": "ਤੁਹਾਡੀ ਤਰੱਕੀ", 17 | "montage-vote": "ਵੋਟ ਪਾਓ", 18 | "montage-progress-status": "{1} ਵਿੱਚੋਂ {0}", 19 | "montage-label-open-date": "ਖੁੱਲਣ ਦੀ ਮਿਤੀ", 20 | "montage-required-open-date": "ਖੁੱਲਣ ਦੀ ਮਿਤੀ ਲੋੜੀਂਦੀ ਹੈ", 21 | "montage-label-open-time": "ਖੁੱਲਣ ਦਾ ਸਮਾਂ", 22 | "montage-label-close-time": "ਬੰਦ ਕਰਨ ਦਾ ਸਮਾਂ", 23 | "montage-something-went-wrong": "ਕੁਝ ਗਲਤ ਹੋ ਗਿਆ", 24 | "montage-btn-save": "ਸਾਂਭੋ", 25 | "montage-btn-cancel": "ਰੱਦ ਕਰੋ", 26 | "montage-archive": "ਪੁਰਾਲੇਖ", 27 | "montage-unarchive": "ਪੁਰਾਲੇਖ ਨਾ ਕਰੋ", 28 | "montage-round-open-time": "ਖੁੱਲਣ ਦਾ ਸਮਾਂ (UTC)", 29 | "montage-round-close-time": "ਬੰਦ ਕਰਨ ਦਾ ਸਮਾਂ (UTC)", 30 | "montage-round-min-resolution": "ਘੱਟੋ-ਘੱਟ ਬਿੰਦੀਆਂ ਦੀ ਨਿਸ਼ਚਿਤ ਖੇਤਰ ਵਿੱਚ ਗਿਨਤੀ", 31 | "montage-round-show-stats": "ਅੰਕੜੇ ਵਿਖਾਓ", 32 | "montage-round-vote-ending": "{0} ਦਿਨਾਂ ਵਿੱਚ", 33 | "montage-round-ranking": "ਦਰਜਾਬੰਦੀ", 34 | "montage-round-yesno": "ਹਾਂ/ਨਹੀਂ", 35 | "montage-round-pause": "ਰੋਕੋ", 36 | "montage-round-source": "ਸਰੋਤ", 37 | "montage-round-threshold": "ਹੱਦ", 38 | "montage-vote-commons-page": "ਸ਼ਾਮਲਾਟ ਸਫ਼ਾ", 39 | "montage-vote-accept": "ਸਵੀਕਾਰ ਕਰੋ", 40 | "montage-vote-decline": "ਮਨਜ਼ੂਰ ਨਹੀਂ", 41 | "montage-vote-actions": "ਕਾਰਵਾਈਆਂ", 42 | "montage-vote-add-favorites": "ਮਨਪਸੰਦ ਵਿੱਚ ਸ਼ਾਮਲ ਕਰੋ", 43 | "montage-vote-remove-favorites": "ਮਨਪਸੰਦ ਵਿੱਚੋਂ ਹਟਾਓ", 44 | "montage-vote-skip": "ਛੱਡੋ (ਬਾਅਦ ਵਿੱਚ ਵੋਟ ਪਾਓ)", 45 | "montage-vote-description": "ਵੇਰਵਾ", 46 | "montage-vote-all-done": "ਸਭ ਕੁਝ ਹੋ ਗਿਆ!", 47 | "montage-vote-grid-size-medium": "ਦਰਮਿਆਨਾ", 48 | "montage-option-yes": "ਹਾਂ", 49 | "montage-option-no": "ਨਹੀਂ", 50 | "montage-btn-add": "ਜੋੜੋ", 51 | "montage-at-least-one-user": "ਕਿਰਪਾ ਕਰਕੇ ਘੱਟੋ-ਘੱਟ ਇੱਕ ਵਰਤੋਂਕਾਰ ਸ਼ਾਮਲ ਕਰੋ", 52 | "montage-only-one-user": "ਕਿਰਪਾ ਕਰਕੇ ਸਿਰਫ਼ ਇੱਕ ਵਰਤੋਂਕਾਰ ਸ਼ਾਮਲ ਕਰੋ", 53 | "montage-round-open-task-percentage": "ਖੁੱਲ੍ਹੇ ਕੰਮਾਂ ਦੀ ਪ੍ਰਤੀਸ਼ਤਤਾ", 54 | "montage-round-open-tasks": "ਖੁਲ੍ਹੇ ਕਾਰਜ", 55 | "montage-round-files": "ਫ਼ਾਈਲਾਂ", 56 | "montage-round-tasks": "ਕਾਰਜ", 57 | "permission-denied-title": "ਪ੍ਰਵਾਨਗੀ ਨਹੀਂ ਮਿਲੀ", 58 | "permission-denied-home": "ਘਰ 'ਤੇ ਜਾਓ" 59 | } 60 | -------------------------------------------------------------------------------- /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 | } 82 | -------------------------------------------------------------------------------- /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 | } 84 | -------------------------------------------------------------------------------- /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 | app.mount('#app') 29 | 30 | // Load additional language messages dynamically 31 | loadMessages().then(() => { 32 | console.log('All language messages loaded'); 33 | }); 34 | -------------------------------------------------------------------------------- /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/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 | // Direct download URLs (manual baseURL needed) 53 | downloadRound: (id) => `${apiBackend.defaults.baseURL}admin/round/${id}/results/download`, 54 | downloadEntries: (id) => `${apiBackend.defaults.baseURL}admin/round/${id}/entries/download`, 55 | downloadReviews: (id) => `${apiBackend.defaults.baseURL}admin/round/${id}/reviews` 56 | } 57 | 58 | export default adminService 59 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | format: 'json', 18 | iiprop: 'timestamp|user|userid|size|dimensions|url', 19 | iilimit: '10', 20 | origin: '*' 21 | } 22 | }) 23 | ) 24 | } 25 | 26 | try { 27 | return await Promise.all(promises) 28 | } catch (error) { 29 | console.error('Error fetching image info:', error) 30 | throw error 31 | } 32 | }, 33 | 34 | async searchUser(username) { 35 | try { 36 | const response = await apiCommons({ 37 | method: 'GET', 38 | params: { 39 | action: 'query', 40 | list: 'globalallusers', 41 | format: 'json', 42 | rawcontinue: 'true', 43 | agufrom: username, 44 | origin: '*' 45 | } 46 | }) 47 | return response 48 | } catch (error) { 49 | console.error('Error searching for user:', error) 50 | throw error 51 | } 52 | }, 53 | 54 | async searchCategory(category) { 55 | try { 56 | const response = await apiCommons({ 57 | method: 'GET', 58 | params: { 59 | action: 'opensearch', 60 | format: 'json', 61 | namespace: '14', 62 | limit: '10', 63 | search: category, 64 | origin: '*' 65 | } 66 | }) 67 | return response 68 | } catch (error) { 69 | console.error('Error searching for category:', error) 70 | throw error 71 | } 72 | } 73 | } 74 | 75 | export default dataService 76 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | const hists = _.values(responses[0].query.pages) 42 | hists.forEach((element) => { 43 | if (element && element.imageinfo) { 44 | const image = _.find(tasks, { 45 | entry: { url: element.imageinfo[0].url } 46 | }) 47 | if (image) { 48 | image.history = element.imageinfo 49 | } 50 | } 51 | }) 52 | return data 53 | }) 54 | }) 55 | } 56 | } 57 | 58 | export default jurorService 59 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /frontend/src/views/AllCampaignView.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /frontend/src/views/CampaignView.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /frontend/src/views/HomeView.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 15 | -------------------------------------------------------------------------------- /frontend/src/views/NewCampaignView.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/views/PermissionDenied.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 17 | 18 | 42 | -------------------------------------------------------------------------------- /frontend/src/views/VoteEditView.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /frontend/src/views/VoteView.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | 17 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /montage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hatnote/montage/238ffcd6886a7cd94f09175c84ce178734ad629a/montage/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | response.vary.add('Origin') 86 | 87 | if self.allow_credentials: 88 | response.headers['Access-Control-Allow-Credentials'] = 'true' 89 | 90 | if self.expose_headers: 91 | response.headers['Access-Control-Expose-Headers'] = ', '.join(self.expose_headers) 92 | 93 | def __repr__(self): 94 | cn = self.__class__.__name__ 95 | return '%s(allow_origins=%r, allow_methods=%r)' % (cn, self.allow_origins, self.allow_methods) 96 | -------------------------------------------------------------------------------- /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/docs/index.md: -------------------------------------------------------------------------------- 1 | # Montage Docs 2 | 3 | All good software needs good docs, and Montage is no exception. 4 | 5 | 6 | -------------------------------------------------------------------------------- /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" 20 | if isinstance(title, unicode): 21 | thash = hashlib.md5(title.encode('utf8')).hexdigest() 22 | url_title = six.moves.urllib.parse.quote(title.encode('utf8')) 23 | elif isinstance(title, bytes): 24 | thash = hashlib.md5(title).hexdigest() 25 | url_title = six.moves.urllib.parse.quote(title) 26 | else: 27 | raise TypeError('image title must be bytes or unicode') 28 | 29 | if size is None: 30 | size = 'orig' 31 | elif isinstance(size, int): 32 | size = '%spx' % size 33 | elif str(size).lower().startswith('sm'): 34 | size = '240px' 35 | elif str(size).lower().startswith('med'): 36 | size = '480px' 37 | 38 | if size != 'orig' and not str(size).endswith('px'): 39 | raise ValueError('size expected one of "sm", "med", "orig",' 40 | ' or an integer pixel value, not %r' % size) 41 | 42 | if size == 'orig': 43 | parts = [BASE, thash[:1], thash[:2], url_title] 44 | else: 45 | parts = [BASE, 'thumb', thash[:1], thash[:2], 46 | url_title, '%s-%s' % (size, url_title)] 47 | 48 | return u'/'.join(parts) 49 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /montage/templates/docs/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {title} - Montage Docs 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |
14 | 17 |

Montage: {title}

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

26 |

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

27 |

28 |
29 |
30 |
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /montage/templates/report.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 82 | 83 | 84 | 85 |
86 |
87 |

{campaign.name}

88 |
89 |
90 | 91 |
92 |

About

93 |

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

94 |

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

95 |
96 | 97 |
98 |

Winners

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

Photograph by {entry.upload_user_text}, {juror_ranking_map}

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

Rounds

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

Jury

143 |

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

144 | 145 |
146 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /montage/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hatnote/montage/238ffcd6886a7cd94f09175c84ce178734ad629a/montage/tests/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /report.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 79 | 80 | 81 | 82 |
83 |
84 |

Wiki Loves Monuments, Malta 2016

85 |
86 |
87 | 88 |
89 |

About

90 |

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

91 |

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

92 |
93 | 94 |
95 |

Winners

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

Lorem ipsum

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

Lorem ipsum

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

Lorem ipsum

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

Rounds

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

Jury

176 |

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

177 | 178 |
179 | 182 | 183 | 184 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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) -------------------------------------------------------------------------------- /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 | git+https://github.com/the-maldridge/python-vote-core.git@f0b01e7e24f80673c4c237ee9e6118e8986cf0bb#egg=python3-vote-core 8 | # via -r requirements.in 9 | ashes==24.0.0 10 | # via 11 | # -r requirements.in 12 | # chert 13 | # clastic 14 | attrs==23.2.0 15 | # via 16 | # clastic 17 | # glom 18 | boltons==24.0.0 19 | # via 20 | # -r requirements.in 21 | # chert 22 | # clastic 23 | # face 24 | # glom 25 | # lithoxyl 26 | certifi==2024.2.2 27 | # via 28 | # requests 29 | # sentry-sdk 30 | cffi==1.16.0 31 | # via cryptography 32 | charset-normalizer==3.3.2 33 | # via requests 34 | chert==21.0.0 35 | # via -r requirements.in 36 | clastic==24.0.0 37 | # via -r requirements.in 38 | cryptography==40.0.2 39 | # via 40 | # -r requirements.in 41 | # pyopenssl 42 | face==20.1.1 43 | # via 44 | # -r requirements.in 45 | # chert 46 | # glom 47 | glom==23.5.0 48 | # via clastic 49 | html5lib==1.1 50 | # via chert 51 | hyperlink==21.0.0 52 | # via chert 53 | idna==3.7 54 | # via 55 | # hyperlink 56 | # requests 57 | importlib-metadata==7.1.0 58 | # via markdown 59 | lithoxyl==21.0.0 60 | # via 61 | # -r requirements.in 62 | # chert 63 | mako==1.3.3 64 | # via sqltap 65 | markdown==3.6 66 | # via chert 67 | markupsafe==2.1.5 68 | # via mako 69 | mwoauth==0.4.0 70 | # via -r requirements.in 71 | oauthlib==3.2.2 72 | # via 73 | # mwoauth 74 | # requests-oauthlib 75 | pycparser==2.22 76 | # via cffi 77 | pyjwt==2.8.0 78 | # via mwoauth 79 | pymysql==1.1.1 80 | # via -r requirements.in 81 | pyopenssl==23.2.0 82 | # via -r requirements.in 83 | python-dateutil==2.9.0.post0 84 | # via chert 85 | python-graph-core==1.8.2 86 | # via 87 | # -r requirements.in 88 | # python3-vote-core 89 | pyyaml==6.0.1 90 | # via chert 91 | requests==2.32.2 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.2.2 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 | -------------------------------------------------------------------------------- /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/_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------