Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
a5e0549
fix(dao): prevent silent discarding of SQLAlchemy filters in get_camp…
Apr 15, 2026
af95219
test(dao): add isolated unit tests for campaign round filtering and o…
Apr 21, 2026
35d88d6
refactor: modernize utils.py with type hints and remove legacy Py2 code
Apr 30, 2026
42c154b
Docs update, gitignore, and fix static route for local dev
lgelauff Apr 5, 2026
e989c13
dev.md: document frontend build step required before make start
lgelauff Apr 5, 2026
4336c09
dev.md: correct backend ready signal (Debugger PIN, not Serving on...)
lgelauff Apr 5, 2026
4d08ec7
Document debug mode, add debug option to config.default, remove dead …
lgelauff Apr 5, 2026
e4174ad
Make debug login configurable via debug_userid/debug_username config …
lgelauff Apr 7, 2026
575f93a
Update deployment.md and dev.md with beta/prod observations
lgelauff Apr 10, 2026
d691269
fix: remove unused router import in RoundEdit.vue
lgelauff May 21, 2026
5a214ce
fix: hide finalized/cancelled rounds from juror active view
lgelauff May 21, 2026
a8dac03
fix: exclude paused rounds from juror index view
lgelauff May 21, 2026
69d6903
fix: pass only_active parameter through in juror get_index
lgelauff May 21, 2026
2d27134
chore(disable_edit): Disabled Edit Round button when round is active …
SSpirate11 Jun 27, 2026
0c7e33d
Support quorum decrease, coupled juror removal, damped-mean display
mahmoud Aug 19, 2026
e72ae58
Fix stale quorum/jurors in RoundEdit found while screenshotting
mahmoud Aug 20, 2026
e358a26
fix(meta): show resource names, not config values; logs/feel for main…
lgelauff Oct 4, 2026
56fdf60
wip(#510): import errors, labs lookup by file_id, source fetch/parse
lgelauff Oct 5, 2026
399c528
wip(#510): import_check module (classify, same-name keys, check files)
lgelauff Oct 5, 2026
0befcf1
wip(#510): check/download endpoints, import only by check token
lgelauff Oct 5, 2026
fbc1255
wip(#510): endpoint tests (check, download, token import, retry, expiry)
lgelauff Oct 5, 2026
8046d37
wip(#510): round form checks the source; round created once
lgelauff Oct 5, 2026
059a144
docs(#510): MONTAGE_IMPORT_CHECK_PATH in deployment.md
lgelauff Oct 5, 2026
32c1c00
test(#510): an empty import is not reported as all disqualified (#208)
lgelauff Oct 6, 2026
3be4816
fix(round): create a first round together with its files, or not at a…
lgelauff Oct 5, 2026
bc9b776
fix(#510): review fixes, backend (pr-check 2026-10-06)
lgelauff Oct 6, 2026
edc8dd5
docs(#510): deploy preflight, folder rules, troubleshooting, API docs
lgelauff Oct 6, 2026
3ed34f4
fix(#510): review fixes, round form (pr-check 2026-10-06)
lgelauff Oct 6, 2026
9a48b5f
fix(#510): a check is valid for 1 hour; files deleted after at most 7…
lgelauff Oct 6, 2026
e4378b1
fix(#510): file list accepts a pasted CSV and bare file_ids; clearer …
lgelauff Oct 7, 2026
4d81bff
fix(#510): same-name wording and screen-reader spacing in the check r…
lgelauff Oct 7, 2026
3d3593e
fix(#510): check result: row numbers on one line, readable on phones,…
lgelauff Oct 7, 2026
ec5000d
fix(#510): a bare number that is a page ID or revision ID blocks, wit…
lgelauff Oct 7, 2026
f637b7f
Merge remote-tracking branch 'origin/master' into feat/import-check-510
lgelauff Oct 7, 2026
aaf5d3b
fix(#510): import check UI review: translated reasons, clearer texts,…
lgelauff Oct 8, 2026
a074aac
fix(#510): a name taken by another file in Montage is caught by the c…
lgelauff Oct 10, 2026
dcc90b1
fix(#510): a number is a file ID; page/revision IDs only explain errors
lgelauff Oct 10, 2026
1206105
Merge remote-tracking branch 'origin/master' into feat/import-check-510
lgelauff Oct 10, 2026
4f12cf2
fix(#510): warnings-only check names what to look at, incl. found by …
lgelauff Oct 10, 2026
f49e2a2
Merge origin/master into quorum-reduction
mahmoud Oct 10, 2026
bde63a8
ops: raise gunicorn's request timeout from the default 30 s to 120 s
mahmoud Oct 10, 2026
f51a686
perf(admin): list and download round entries in two queries, not two …
mahmoud Oct 10, 2026
489c813
fix(sentry): upgrade sentry-sdk to 2.71.0 and move clastic_sentry off…
mahmoud Oct 10, 2026
997a619
fix(import): lock the round during an import so a retry can't add eve…
mahmoud Oct 10, 2026
f7025fe
Merge pr/468: broken static route for local dev, clean up stale build…
mahmoud Oct 10, 2026
535c547
Merge pr/571: fix: juror round listing shows wrong rounds (beta integ…
mahmoud Oct 10, 2026
ef88014
Merge pr/500: fix(backend): prevent silent discarding of SQLAlchemy f…
mahmoud Oct 10, 2026
7ca7f6c
Merge pr/593: Disabled Edit Round button when round is active and add…
mahmoud Oct 10, 2026
39f9367
test: restore quorum 4 before the juror flow in test_home_client
mahmoud Oct 10, 2026
dd8f851
Merge pr/551: refactor: modernize utils.py with type hints and remove…
mahmoud Oct 10, 2026
408d625
fix(integration): drop utils.basestring/unicode imports that #551 rem…
mahmoud Oct 10, 2026
9b862b1
Merge pr/647: Show resource names, not config values, on /meta; front…
mahmoud Oct 10, 2026
ce7e0bd
Merge fix/sentry-sdk-py313: fix(sentry): sentry-sdk 2.71.0 and clasti…
mahmoud Oct 10, 2026
14230a3
Merge ops/gunicorn-timeout-120: ops: gunicorn --timeout=120 instead o…
mahmoud Oct 10, 2026
6dad461
Merge perf/round-entries-export-640: perf(admin): round entries list/…
mahmoud Oct 10, 2026
24e747d
Merge pr/656: Check first-round imports before importing; save a firs…
mahmoud Oct 10, 2026
eb89dc2
fix(integration): import through a check token in the #640 export test
mahmoud Oct 10, 2026
8bb348d
Merge fix/import-round-lock-635: lock the round during an import (#63…
mahmoud Oct 10, 2026
329a85b
fix(import-check): titlecase the first letter like MediaWiki, so Geor…
mahmoud Oct 10, 2026
f9a9313
fix(import-check): keep the full titlecase mapping for the first lett…
mahmoud Oct 10, 2026
734aecd
Merge quorum-reduction: quorum decrease, coupled juror removal, dampe…
mahmoud Oct 10, 2026
33c0c00
Merge pr/656 @ 4f12cf2: warnings-only check text names rows found by …
mahmoud Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Procfile
Original file line number Diff line number Diff line change
@@ -1 +1 @@
web: gunicorn --bind=0.0.0.0:8000 --workers=4 --forwarded-allow-ips=* app:app
web: gunicorn --bind=0.0.0.0:8000 --workers=4 --timeout=120 --forwarded-allow-ips=* app:app
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ series of photography competitions. Montage provides a configurable
workflow that adapts to the conventions of all groups.

- [montage on Wikimedia Commons](https://commons.wikimedia.org/wiki/Commons:Montage)
- [montage on Phabricator](https://phabricator.wikimedia.org/project/view/2287/)
- [montage on GitHub Issues](https://github.com/hatnote/montage/issues)
- [montage on Phabricator](https://phabricator.wikimedia.org/project/view/2287/) (archived)

## Running locally

See [dev.md](dev.md) for full setup instructions.

---

## Development

Expand Down
6 changes: 6 additions & 0 deletions config.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ db_url: "sqlite:///tmp_montage.db"
cookie_secret: ReplaceThisWithSomethingSomewhatSecret
superuser: Slaporte

# Optional: set to true to bypass OAuth and log in automatically (dev only)
debug: false
# Optional: set these to log in as yourself in debug mode (defaults to __montage_debug__ / 0)
# debug_username: Slaporte
# debug_userid: 6024474

dev_local_cookie_value: "contact maintainers for details"
dev_remote_cookie_value: "contact maintainers for details"
oauth_client_id: "register an OAuth 2.0 consumer at https://meta.wikimedia.org/wiki/Special:OAuthConsumerRegistration/propose"
Expand Down
58 changes: 57 additions & 1 deletion deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,26 @@ toolforge envvars create MONTAGE_DB_URL # mysql+pymysql://<user>:<pass>@
toolforge envvars create MONTAGE_SUPERUSERS # OPTIONAL — enables su-impersonation only, NOT admin access (see "Maintainer vs superuser" below)
toolforge envvars create MONTAGE_API_LOG_PATH # e.g. /data/project/montage-beta/montage_api.log
toolforge envvars create MONTAGE_REPLAY_LOG_PATH
toolforge envvars create MONTAGE_IMPORT_CHECK_PATH # the tool's own folder, e.g. /data/project/montage-beta/import_checks (see below)
```

`MONTAGE_IMPORT_CHECK_PATH` is required everywhere except local development: Montage refuses to
start without it. It is the folder for import checks (hatnote/montage#510): each check of a
first-round import source is written there as a JSON file (mode 0600). A check can be used to
save a round for 1 hour. The file is deleted after at most 7 days: every check, import, download
and app start deletes older files, and at most 5,000 are kept. The files hold the source link,
the rows and Commons' data per file, including Commons uploader names.

- It must be a folder **of its own**: Montage refuses to start if it holds other files, because
the cleanup deletes old check files there.
- It must be absolute and on the tool's NFS share (`/data/project/<tool>/...`, mounted with
`--mount all`), so that every pod and a restarted pod see the same files.

Create the folder first, in the tool's home (`$HOME` is `/data/project/<tool>`), and print the
value to enter at the `envvars create` prompt:

```bash
mkdir -m 700 "$HOME/import_checks" && ls -ld "$HOME/import_checks" && echo "$HOME/import_checks"
```

Optional env vars (all have sensible defaults):
Expand Down Expand Up @@ -191,7 +211,25 @@ git -C ~/www/python/src checkout master
git -C ~/www/python/src pull --ff-only
```

#### 4. Run the deploy script
#### 4. Check new required settings

A release can add a required environment variable; the app then refuses to start until it is
set. Since hatnote/montage#510 that is `MONTAGE_IMPORT_CHECK_PATH` (see Fresh install, step 3).
`deploy.sh` checks the required variables before it builds, and stops if one is missing.
Before the first deploy that includes it, create the folder and set the variable on that tool
(as the tool, after `become`):

```bash
mkdir -m 700 "$HOME/import_checks" && ls -ld "$HOME/import_checks" && echo "$HOME/import_checks"
```

```bash
toolforge envvars create MONTAGE_IMPORT_CHECK_PATH
```

Enter the path the first command printed (e.g. `/data/project/montage-beta/import_checks`).

#### 5. Run the deploy script

Which ref to deploy depends on the tool (see [Multi-environment setup](#multi-environment-setup)):

Expand Down Expand Up @@ -233,6 +271,24 @@ from `toolforge envvars`. A tool that worked on the legacy service can therefore
buildservice if the `MONTAGE_*` env vars (Fresh install step 3) are not set — and its database may
need migrating (see "Startup crash: missing column" below).

#### Import checks: troubleshooting and rollback

- **The pod does not start and its log says `MONTAGE_IMPORT_CHECK_PATH ...`:** create the folder
and the variable (step 4), then `toolforge webservice buildservice restart --mount all`. No
rebuild and no rollback are needed.
- **A check of a large source fails with a server error after about 30 s:** the request hit gunicorn's
worker timeout. Nothing was saved; try a smaller source (split a list, or a sub-category).
- **Old check files:** every use of the folder (and every app start) deletes files older than
7 days. To delete them by hand:

```bash
find "$HOME/import_checks" -name '*.json' -mtime +7 -delete
```

- **Rolling back to a version before #510:** the variable and the folder can stay (older
versions ignore them). Delete the check files (`rm "$HOME"/import_checks/*.json`): they hold
Commons uploader names that nothing reads any more. Entries already imported stay.

---

## Debugging
Expand Down
17 changes: 12 additions & 5 deletions dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ Other frontend development commands:
### 6. Use the Makefile to start the backend
* Open a new terminal tab and change directory to root of repo
* Copy and edit `config.dev.yaml` based on `config.default.yaml`
* (Optional) Set `debug: true` in `config.dev.yaml` to bypass OAuth entirely. This is the easiest way to get started locally without setting up OAuth. To log in, navigate to http://localhost:5001/complete_login directly — the "Login with Wikimedia" button still requires OAuth and won't work locally.
* (Optional) Set `debug_userid: <your numeric Wikipedia CentralAuth ID>` and `debug_username: <your Wikipedia username>` to be auto-logged in as yourself in debug mode. Your CentralAuth ID can be found at https://en.wikipedia.org/wiki/Special:CentralAuth/YOUR_USERNAME.
* (Optional) In `config.dev.yaml` there is a line for `dev_local_cookie_value`. To get it,
log in to the local app in your browser, and then copy the value from the
`clastic_cookie` in the apps' cookies. This is your login cookie.
Expand All @@ -106,11 +108,17 @@ juror.)
This will give your user top-level permissions in the full app, so you can view
some logs (audit logs, active users), add/remove organizers, and get a
coordinator view into all campaigns.
* Build the frontend for the backend (run from the `frontend/` directory):
```bash
npm run toolforge:build
```
* Start the montage backend
```bash
make start
```
This will build the docker image for the montage backend and start the container. Apart from `make start`, these are other commands:
The backend is ready when you see `Debugger PIN` in the logs. It is available at http://localhost:5001.

Apart from `make start`, these are other commands:
* `make start-detached` : Start the backend container in detached mode
* `make stop` : Stop the backend container
* `make logs` : Stream the backend container logs in real-time.
Expand Down Expand Up @@ -195,8 +203,8 @@ npm run build

## Project structure
```bash
├── DEV.md
├── Dockerfile
├── dev.md
├── dockerfile
├── LICENSE
├── MANIFEST.in
├── Makefile
Expand Down Expand Up @@ -240,7 +248,6 @@ npm run build
│   ├── templates
│   ├── tests
│   └── utils.py
├── report.html
├── requirements-dev.txt
├── requirements.in
├── requirements.txt
Expand Down Expand Up @@ -304,5 +311,5 @@ These provides a detailed explanation of the main components in the **Montage Pr


#### Docker Files
- **`Dockerfile`**: Builds the backend container.
- **`dockerfile`**: Builds the backend container.
- **`docker-compose.yml`**: Orchestrates service for backend.
85 changes: 83 additions & 2 deletions frontend/cypress/e2e/campaign.cy.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,39 @@
// #510: a clean check of one file, valid from now on
const stubCheck = () => {
const now = Date.now()
cy.intercept('POST', '/v1/admin/campaign/*/import/check', {
body: {
status: 'success',
data: {
token: 'cypress-check-token-000000000000',
checked_at: new Date(now).toISOString().replace(/\.\d+Z$/, 'Z'),
expires_at: new Date(now + 3600 * 1000).toISOString().replace(/\.\d+Z$/, 'Z'),
import_method: 'selected',
source: { file_names: ['Example.jpg'] },
columns: { name: null, file_id: null, ignored: [] },
counts: {
ok: 1, renamed: 0, duplicate: 0, unknown_name: 0,
unknown_file_id: 0, malformed_file_id: 0, same_name: 0
},
blocking: false,
total_rows: 1,
importable_count: 1,
issues: [],
issues_total: 0,
issues_truncated: false,
same_name_groups: []
}
}
}).as('checkImport')
}

const savedRound = () => ({
id: 999,
name: 'My Test Round',
import: { new_round_entry_count: 1, warnings: [], disqualified: [] }
})


describe('Campaign Details Page', () => {
beforeEach(() => {
cy.setCookie('clastic_cookie', '<cookie-validation-string>');
Expand Down Expand Up @@ -84,8 +120,53 @@ describe('Campaign Details Page', () => {
.find('li')
.first()
.click();
cy.get('.button-group button').contains('Add Round').click().click();
cy.log(' Round created successfully');
// #510 / #447: the source is checked first; Save sends the round together
// with its checked import, once
stubCheck()
cy.intercept('POST', '/v1/admin/campaign/*/add_round', (req) => {
req.reply({ delay: 500, body: { status: 'success', data: savedRound() } })
}).as('addRound')
cy.get('.form-container').contains('label', 'File List').click()
cy.get('.form-container textarea').first().type('Example.jpg')
cy.get('[data-testid="add-round-button"]').should('be.disabled')
cy.get('[data-testid="check-source-button"]').click()
cy.wait('@checkImport')
cy.get('[data-testid="import-check-result"]').should('be.visible')
// a double click must not save twice
cy.get('[data-testid="add-round-button"]').click().click({ force: true })
cy.wait('@addRound').its('request.body').should((body) => {
expect(body.import).to.deep.equal({
import_method: 'selected',
check_token: 'cypress-check-token-000000000000'
})
expect(body).not.to.have.property('file_names')
})
cy.get('@addRound.all').should('have.length', 1)
cy.get('.juror-campaign-round-card').should('not.exist')
})

it('keeps the form open when the server refuses the import', () => {
cy.get('.add-round-button').click()
cy.get('.form-container input[type="text"]').first().clear().type('My Test Round')
cy.get('.form-container').within(() => {
cy.get('input[placeholder="YYYY-MM-DD"]').first().clear().type('2025-08-15')
})
cy.get('[data-testid="userlist-search"] input').type('AadarshM07')
cy.get('[data-testid="userlist-search"]').find('li').first().click()
stubCheck()
cy.intercept('POST', '/v1/admin/campaign/*/add_round', {
statusCode: 400,
body: { status: 'failure', error_type: 'import_check_expired', detail: 'check expired' }
}).as('addRound')
cy.get('.form-container').contains('label', 'File List').click()
cy.get('.form-container textarea').first().type('Example.jpg')
cy.get('[data-testid="check-source-button"]').click()
cy.wait('@checkImport')
cy.get('[data-testid="add-round-button"]').click()
cy.wait('@addRound')
cy.get('.juror-campaign-round-card').should('exist')
cy.get('[data-testid="import-check-result"]').should('contain', 'expired')
cy.get('[data-testid="add-round-button"]').should('be.disabled')
})


Expand Down
Loading
Loading